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
563 changed files with 19376 additions and 30860 deletions

View file

@ -31,15 +31,12 @@ module.exports = {
'linebreak-style': 0,
'no-underscore-dangle': 0,
"no-shadow": "off",
"max-classes-per-file": "off",
"no-useless-constructor": "off",
"no-empty-function": "off",
"import/prefer-default-export": "off",
"import/extensions": "off",
"import/no-unresolved": "off",
"import/no-extraneous-dependencies": [
"off",
"error",
{
devDependencies: true,
},
@ -61,10 +58,8 @@ module.exports = {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error",
"@typescript-eslint/no-useless-constructor": "error",
"@typescript-eslint/no-empty-function": "error",
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-shadow": "error",
"@typescript-eslint/no-shadow": "error"
},
overrides: [
{

View file

@ -2,4 +2,5 @@ dist
node_modules
package.json
package-lock.json
LICENSE
LICENSE
harness

View file

@ -1,7 +0,0 @@
{
"format_on_save": "on",
"prettier": {
"parser": "",
"allowed": true
}
}

View file

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

View file

@ -1 +0,0 @@
{}

View file

@ -5,17 +5,7 @@
"featuredCommunities": {
"openAsDefault": false,
"spaces": [
"#cinny:matrix.org",
"#community:matrix.org",
"#space:unredacted.org",
"#librewolf-community:matrix.org",
"#stickers-and-emojis:tastytea.de",
"#videogames:waywardinn.com",
"#science-space:matrix.org",
"#libregaming-games:tchncs.de",
"#mathematics-on:matrix.org"
],
"spaces": ["#gamecult:gamecult.org"],
"rooms": [
"#tuwunel:grin.hu",
"#freesoftware:matrix.org",

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

947
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "cinny",
"version": "4.12.7",
"version": "4.12.8",
"description": "Yet another matrix client",
"main": "index.js",
"type": "module",
@ -18,7 +18,8 @@
"typecheck": "tsc --noEmit",
"prepare": "husky install",
"commit": "git-cz",
"bump": "node scripts/update-version.js"
"bump": "node scripts/update-version.js",
"test": "vitest run"
},
"lint-staged": {
"*.{ts,tsx,js,jsx}": "eslint",
@ -56,21 +57,20 @@
"emojibase-data": "15.3.2",
"file-saver": "2.0.5",
"focus-trap-react": "10.0.2",
"folds": "2.7.3",
"folds": "2.7.2",
"html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0",
"i18next": "23.12.2",
"i18next-browser-languagedetector": "8.0.0",
"i18next-http-backend": "4.0.3",
"idb": "8.0.3",
"immer": "9.0.16",
"is-hotkey": "0.2.0",
"jotai": "2.6.0",
"linkify-react": "4.3.2",
"linkifyjs": "4.3.2",
"matrix-widget-api": "1.20.0",
"matrix-js-sdk": "41.7.0",
"matrix-widget-api": "1.16.1",
"millify": "6.1.0",
"neurone": "0.0.9",
"pdfjs-dist": "4.2.67",
"prismjs": "1.30.0",
"react": "18.2.0",
@ -83,15 +83,18 @@
"react-i18next": "15.0.0",
"react-range": "1.8.14",
"react-router-dom": "6.30.6",
"sanitize-html": "2.17.7",
"sanitize-html": "2.18.1",
"slate": "0.123.0",
"slate-dom": "0.123.0",
"slate-history": "0.113.1",
"slate-react": "0.123.0",
"ua-parser-js": "1.0.35"
"ua-parser-js": "1.0.35",
"@fontsource/ubuntu": "5.2.5",
"@fontsource/montserrat": "5.2.5",
"@fontsource/ibm-plex-mono": "5.2.5"
},
"devDependencies": {
"@element-hq/element-call-embedded": "0.28.0",
"@element-hq/element-call-embedded": "0.20.1",
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@rollup/plugin-inject": "5.0.3",
"@rollup/plugin-wasm": "6.1.1",
@ -120,10 +123,11 @@
"husky": "9.1.7",
"lint-staged": "16.3.2",
"prettier": "2.8.1",
"typescript": "6.0.3",
"typescript": "4.9.4",
"vite": "5.4.19",
"vite-plugin-pwa": "0.20.5",
"vite-plugin-static-copy": "1.0.4",
"vite-plugin-top-level-await": "1.4.4"
"vite-plugin-top-level-await": "1.4.4",
"vitest": "1.6.0"
}
}

View file

@ -14,7 +14,7 @@ import {
Scroll,
config,
} from 'folds';
import { AccountDataEvent } from 'neurone/lib/api/types';
import { MatrixError } from 'matrix-js-sdk';
import { Cursor } from '../plugins/text-area';
import { syntaxErrorPosition } from '../utils/dom';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
@ -28,12 +28,17 @@ const EDITOR_INTENT_SPACE_COUNT = 2;
export type AccountDataSubmitCallback = (type: string, content: object) => Promise<void>;
type AccountDataInfo = {
type: string;
content: object;
};
type AccountDataEditProps = {
type: string;
defaultContent: string;
submitChange: AccountDataSubmitCallback;
onCancel: () => void;
onSave: (info: AccountDataEvent) => void;
onSave: (info: AccountDataInfo) => void;
};
function AccountDataEdit({
type,
@ -52,7 +57,7 @@ function AccountDataEdit({
EDITOR_INTENT_SPACE_COUNT
);
const [submitState, submit] = useAsyncCallback<void, unknown, [string, object]>(submitChange);
const [submitState, submit] = useAsyncCallback<void, MatrixError, [string, object]>(submitChange);
const submitting = submitState.status === AsyncStatus.Loading;
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
@ -154,9 +159,7 @@ function AccountDataEdit({
{submitState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>
{submitState.error instanceof Error ? submitState.error.message : 'Unknown Error'}
</b>
<b>{submitState.error.message}</b>
</Text>
)}
</Box>
@ -239,30 +242,34 @@ function AccountDataView({ type, defaultContent, onEdit }: AccountDataViewProps)
}
export type AccountDataEditorProps = {
event?: AccountDataEvent;
type?: string;
content?: object;
submitChange: AccountDataSubmitCallback;
requestClose: () => void;
};
export function AccountDataEditor({ event, submitChange, requestClose }: AccountDataEditorProps) {
const [data, setData] = useState<AccountDataEvent>(
event ?? {
type: '',
content: {},
}
);
export function AccountDataEditor({
type,
content,
submitChange,
requestClose,
}: AccountDataEditorProps) {
const [data, setData] = useState<AccountDataInfo>({
type: type ?? '',
content: content ?? {},
});
const [edit, setEdit] = useState(!data.type);
const [edit, setEdit] = useState(!type);
const closeEdit = useCallback(() => {
if (!event) {
if (!type) {
requestClose();
return;
}
setEdit(false);
}, [event, requestClose]);
}, [type, requestClose]);
const handleSave = useCallback((info: AccountDataEvent) => {
const handleSave = useCallback((info: AccountDataInfo) => {
setData(info);
setEdit(false);
}, []);

View file

@ -1,48 +1,33 @@
import React, { ReactNode, useState } from 'react';
import {
config,
Dialog,
Header,
Text,
IconButton,
Box,
Icon,
Icons,
Button,
OverlayBackdrop,
Overlay,
OverlayCenter,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { AuthResponse, AuthType, getUIAFlowForStages, UIAFlow } from '../utils/matrix-uia';
import React, { ReactNode } from 'react';
import { AuthDict, AuthType, IAuthData, UIAFlow } from 'matrix-js-sdk';
import { getUIAFlowForStages } from '../utils/matrix-uia';
import { useSupportedUIAFlows, useUIACompleted, useUIAFlow } from '../hooks/useUIAFlows';
import { UIAFlowOverlay } from './UIAFlowOverlay';
import { OAuthStage, PasswordStage, SSOStage } from './uia-stages';
import { useClient } from '../hooks/useClient';
import { PasswordStage, SSOStage } from './uia-stages';
import { useMatrixClient } from '../hooks/useMatrixClient';
export const SUPPORTED_IN_APP_UIA_STAGES = [AuthType.Password, AuthType.Sso, AuthType.OAuth];
export const SUPPORTED_IN_APP_UIA_STAGES = [AuthType.Password, AuthType.Sso];
export const pickUIAFlow = (uiaFlows: UIAFlow[]): UIAFlow | undefined => {
const passwordFlow = getUIAFlowForStages(uiaFlows, [AuthType.Password]);
if (passwordFlow) return passwordFlow;
return getUIAFlowForStages(uiaFlows, [AuthType.Sso]);
};
type ActionUIAProps = {
authData: AuthResponse;
authData: IAuthData;
ongoingFlow: UIAFlow;
action: (authDict: object) => void;
action: (authDict: AuthDict) => void;
onCancel: () => void;
};
export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIAProps) {
const ct = useClient();
const mx = useMatrixClient();
const completed = useUIACompleted(authData);
const { getStageToComplete } = useUIAFlow(authData, ongoingFlow);
const stageToComplete = getStageToComplete();
if (!stageToComplete) return null;
const ssoRedirectURL = new URL(`/auth/${AuthType.Sso}/fallback/web`, ct.session.baseUrl);
if (stageToComplete.session) {
ssoRedirectURL.searchParams.set('session', stageToComplete.session);
}
return (
<UIAFlowOverlay
currentStep={completed.length + 1}
@ -51,7 +36,7 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA
>
{stageToComplete.type === AuthType.Password && (
<PasswordStage
userId={ct.session.userId}
userId={mx.getUserId()!}
stageData={stageToComplete}
onCancel={onCancel}
submitAuthDict={action}
@ -59,110 +44,30 @@ export function ActionUIA({ authData, ongoingFlow, action, onCancel }: ActionUIA
)}
{stageToComplete.type === AuthType.Sso && stageToComplete.session && (
<SSOStage
ssoRedirectURL={ssoRedirectURL.toString()}
ssoRedirectURL={mx.getFallbackAuthUrl(AuthType.Sso, stageToComplete.session)}
stageData={stageToComplete}
onCancel={onCancel}
submitAuthDict={action}
/>
)}
{stageToComplete.type === AuthType.OAuth && (
<OAuthStage stageData={stageToComplete} onCancel={onCancel} submitAuthDict={action} />
)}
</UIAFlowOverlay>
);
}
function UIAFlowPicker({
flows,
onCancel,
onSelect,
}: {
flows: UIAFlow[];
onCancel: () => void;
onSelect: (flow: UIAFlow) => void;
}) {
const passwordFlow = getUIAFlowForStages(flows, [AuthType.Password]);
const ssoFlow = getUIAFlowForStages(flows, [AuthType.Sso]);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap focusTrapOptions={{ initialFocus: false, escapeDeactivates: false }}>
<Dialog>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Authentication</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box
as="form"
style={{ padding: `0 ${config.space.S400} ${config.space.S400}` }}
direction="Column"
gap="400"
>
<Box direction="Column" gap="400">
<Text size="T200">Choose an authentication method to perform this action.</Text>
</Box>
<Box direction="Column" gap="200">
{passwordFlow && (
<Button variant="Primary" type="button" onClick={() => onSelect(passwordFlow)}>
<Text as="span" size="B400">
Continue with Password
</Text>
</Button>
)}
{ssoFlow && (
<Button
variant="Secondary"
fill="Soft"
type="button"
onClick={() => onSelect(ssoFlow)}
>
<Text as="span" size="B400">
Continue with SSO
</Text>
</Button>
)}
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
type ActionUIAFlowsLoaderProps = {
authData: AuthResponse;
onCancel: () => void;
authData: IAuthData;
unsupported: () => ReactNode;
children: (ongoingFlow: UIAFlow) => ReactNode;
};
export function ActionUIAFlowsLoader({
authData,
onCancel,
unsupported,
children,
}: ActionUIAFlowsLoaderProps) {
const supportedFlows = useSupportedUIAFlows(authData.flows ?? [], SUPPORTED_IN_APP_UIA_STAGES);
const [ongoingFlow, setOngoingFlow] = useState(
supportedFlows.length === 1 ? supportedFlows[0] : undefined
);
const ongoingFlow = supportedFlows.length > 0 ? supportedFlows[0] : undefined;
if (supportedFlows.length === 0) return unsupported();
if (!ongoingFlow) {
return <UIAFlowPicker flows={supportedFlows} onCancel={onCancel} onSelect={setOngoingFlow} />;
}
if (!ongoingFlow) return unsupported();
return children(ongoingFlow);
}

View file

@ -1,18 +1,14 @@
import { ReactNode, useCallback, useEffect, useMemo } from 'react';
import { getLogin, GetLoginOK } from 'neurone/lib/api/matrix/client/v3/login';
import { postRegister } from 'neurone/lib/api/matrix/client/v3/register';
import { MatrixErrorData, parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request';
import { MatrixError, createClient } from 'matrix-js-sdk';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { useAutoDiscoveryInfo } from '../hooks/useAutoDiscoveryInfo';
import { promiseFulfilledResult } from '../utils/common';
import { promiseFulfilledResult, promiseRejectedResult } from '../utils/common';
import {
AuthFlows,
RegisterFlowStatus,
RegisterFlowsResponse,
parseRegisterErrResp,
} from '../hooks/useAuthFlows';
import { getJSONRequest } from '../../client/request';
import { AuthResponse } from '../utils/matrix-uia';
type AuthFlowsLoaderProps = {
fallback?: () => ReactNode;
@ -23,33 +19,24 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr
const autoDiscoveryInfo = useAutoDiscoveryInfo();
const baseUrl = autoDiscoveryInfo['m.homeserver'].base_url;
const requestJSON = useMemo(() => getJSONRequest(baseUrl), [baseUrl]);
const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]);
const [state, load] = useAsyncCallback(
useCallback(async () => {
const loginEndpoint = getLogin({});
const registerEndpoint = postRegister({});
const [loginResponse, registerResponse] = (
await Promise.allSettled([requestJSON(loginEndpoint), requestJSON(registerEndpoint)])
).map(promiseFulfilledResult);
const result = await Promise.allSettled([mx.loginFlows(), mx.registerRequest({})]);
const loginFlows = promiseFulfilledResult(result[0]);
const registerResp = promiseRejectedResult(result[1]) as MatrixError | undefined;
let registerFlows: RegisterFlowsResponse = { status: RegisterFlowStatus.InvalidRequest };
const [registerError] =
typeof registerResponse === 'object'
? await parseMatrixResp<undefined, AuthResponse | MatrixErrorData>(registerResponse)
: [];
if (registerError) {
registerFlows = parseRegisterErrResp(registerError);
if (typeof registerResp === 'object' && registerResp.httpStatus) {
registerFlows = parseRegisterErrResp(registerResp);
}
if (!loginResponse) {
if (!loginFlows) {
throw new Error('Missing auth flow!');
}
const [loginError, loginFlows] = await parseMatrixResp<GetLoginOK>(loginResponse);
if (loginError) {
throw new Error(`Failed to load auth flow! ${printMatrixError(loginError)}`);
if ('errcode' in loginFlows) {
throw new Error('Failed to load auth flow!');
}
const authFlows: AuthFlows = {
@ -58,7 +45,7 @@ export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderPr
};
return authFlows;
}, [requestJSON])
}, [mx])
);
useEffect(() => {

View file

@ -1,4 +1,6 @@
import React, { MouseEventHandler, useCallback, useState } from 'react';
import { useAtom } from 'jotai';
import { CryptoApi, KeyBackupInfo } from 'matrix-js-sdk/lib/crypto-api';
import {
Badge,
Box,
@ -17,113 +19,130 @@ import {
Text,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { BackupProgressStatus, backupRestoreProgressAtom } from '../state/backupRestore';
import { InfoCard } from './info-card';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import {
useKeyBackupInfo,
useKeyBackupStatus,
useKeyBackupSync,
useKeyBackupTrust,
} from '../hooks/useKeyBackup';
import { stopPropagation } from '../utils/keyboard';
import { bytesToSize } from '../utils/common';
import { useObservableValue } from '../hooks/useObservableValue';
import { useClient } from '../hooks/useClient';
import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback';
import { useRestoreBackupOnVerification } from '../hooks/useRestoreBackupOnVerification';
type BackupStatusProps = {
active: boolean;
enabled: boolean;
};
function BackupStatus({ active }: BackupStatusProps) {
function BackupStatus({ enabled }: BackupStatusProps) {
return (
<Box as="span" gap="100" alignItems="Center">
<Badge variant={active ? 'Success' : 'Critical'} fill="Solid" size="200" radii="Pill" />
<Badge variant={enabled ? 'Success' : 'Critical'} fill="Solid" size="200" radii="Pill" />
<Text
as="span"
size="L400"
style={{ color: active ? color.Success.Main : color.Critical.Main }}
style={{ color: enabled ? color.Success.Main : color.Critical.Main }}
>
{active ? 'Connected' : 'Disconnected'}
{enabled ? 'Connected' : 'Disconnected'}
</Text>
</Box>
);
}
function BackupSyncing() {
const ct = useClient();
const [countState] = useAsyncCallbackValue(useCallback(() => ct.machine.roomKeys.count(), [ct]));
const count = countState.status === AsyncStatus.Success ? countState.data : undefined;
const remaining = count ? count.total - count.backedUp : undefined;
type BackupSyncingProps = {
count: number;
};
function BackupSyncing({ count }: BackupSyncingProps) {
return (
<Box as="span" gap="100" alignItems="Center">
<Spinner size="50" variant="Primary" fill="Soft" />
<Text as="span" size="L400" style={{ color: color.Primary.Main }}>
Syncing ({remaining ?? '...'})
Syncing ({count})
</Text>
</Box>
);
}
function BackupProgressFetching({
receivedBytes,
totalBytes,
}: {
receivedBytes: number;
totalBytes?: number;
}) {
const receviedStr = bytesToSize(receivedBytes);
const totalStr = totalBytes ? bytesToSize(totalBytes) : undefined;
function BackupProgressFetching() {
return (
<Box grow="Yes" gap="200" alignItems="Center">
<Badge variant="Secondary" fill="Solid" radii="300">
<Text size="L400">Downloading</Text>
<Text size="L400">Restoring: 0%</Text>
</Badge>
<Box grow="Yes" direction="Column">
<ProgressBar
variant="Secondary"
size="300"
min={0}
max={totalBytes}
value={receivedBytes}
/>
<ProgressBar variant="Secondary" size="300" min={0} max={1} value={0} />
</Box>
<Badge variant="Secondary" fill="Soft" radii="Pill">
<Text size="L400">{totalStr ? `${receivedBytes} / ${totalStr}` : receviedStr}</Text>
</Badge>
<Spinner size="50" variant="Secondary" fill="Soft" />
</Box>
);
}
type BackupProgressProps = {
total?: number;
imported: number;
total: number;
downloaded: number;
};
function BackupProgress({ total, imported }: BackupProgressProps) {
function BackupProgress({ total, downloaded }: BackupProgressProps) {
return (
<Box grow="Yes" gap="200" alignItems="Center">
<Badge variant="Secondary" fill="Solid" radii="300">
<Text size="L400">
Restoring: {total ? `${Math.round(percent(0, total, imported))}%` : '0%'}
</Text>
<Text size="L400">Restoring: {`${Math.round(percent(0, total, downloaded))}%`}</Text>
</Badge>
<Box grow="Yes" direction="Column">
<ProgressBar variant="Secondary" size="300" min={0} max={total} value={imported} />
<ProgressBar variant="Secondary" size="300" min={0} max={total} value={downloaded} />
</Box>
{total && (
<Badge variant="Secondary" fill="Soft" radii="Pill">
<Text size="L400">
{imported} / {total}
</Text>
</Badge>
<Badge variant="Secondary" fill="Soft" radii="Pill">
<Text size="L400">
{downloaded} / {total}
</Text>
</Badge>
</Box>
);
}
type BackupTrustInfoProps = {
crypto: CryptoApi;
backupInfo: KeyBackupInfo;
};
function BackupTrustInfo({ crypto, backupInfo }: BackupTrustInfoProps) {
const trust = useKeyBackupTrust(crypto, backupInfo);
if (!trust) return null;
return (
<Box direction="Column">
{trust.matchesDecryptionKey ? (
<Text size="T200" style={{ color: color.Success.Main }}>
<b>Backup has trusted decryption key.</b>
</Text>
) : (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>Backup does not have trusted decryption key!</b>
</Text>
)}
{trust.trusted ? (
<Text size="T200" style={{ color: color.Success.Main }}>
<b>Backup has trusted by signature.</b>
</Text>
) : (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>Backup does not have trusted signature!</b>
</Text>
)}
</Box>
);
}
export function BackupRestoreTile() {
const ct = useClient();
const backupInfo = useObservableValue(ct.backupInfo);
const keyBackup = useObservableValue(ct.megolmBackup.keyBackup);
const restoreState = useObservableValue(ct.megolmBackup.restoreState);
const backupInProgress = useObservableValue(ct.megolmBackup.backupInProgress);
const restoring = restoreState.type === 'download' || restoreState.type === 'import';
type BackupRestoreTileProps = {
crypto: CryptoApi;
};
export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
const [restoreProgress, setRestoreProgress] = useAtom(backupRestoreProgressAtom);
const restoring =
restoreProgress.status === BackupProgressStatus.Fetching ||
restoreProgress.status === BackupProgressStatus.Loading;
const backupEnabled = useKeyBackupStatus(crypto);
const backupInfo = useKeyBackupInfo(crypto);
const [remainingSession, syncFailure] = useKeyBackupSync();
const [menuCords, setMenuCords] = useState<RectCords>();
@ -131,11 +150,19 @@ export function BackupRestoreTile() {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const [restoreState, restoreBackup] = useAsyncCallback<void, Error, []>(
useCallback(async () => {
await crypto.restoreKeyBackup({
progressCallback(progress) {
setRestoreProgress(progress);
},
});
}, [crypto, setRestoreProgress])
);
const handleRestore = () => {
setMenuCords(undefined);
if (keyBackup) {
ct.megolmBackup.restore(keyBackup);
}
restoreBackup();
};
return (
@ -144,99 +171,111 @@ export function BackupRestoreTile() {
title="Encryption Backup"
after={
<Box alignItems="Center" gap="200">
{backupInfo && (
<>
{backupInProgress ? <BackupSyncing /> : <BackupStatus active={!!keyBackup} />}
<IconButton
aria-pressed={!!menuCords}
size="300"
variant="Surface"
radii="300"
onClick={handleMenu}
>
<Icon size="100" src={Icons.VerticalDots} />
</IconButton>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ padding: config.space.S100 }}>
<Box direction="Column" gap="100">
<Box direction="Column" gap="200">
<InfoCard
variant="SurfaceVariant"
title="Backup Details"
description={
<>
<span>Version: {backupInfo.version}</span>
<br />
<span>Keys: {backupInfo.count}</span>
</>
}
/>
</Box>
<Button
size="300"
variant="Success"
radii="300"
aria-disabled={!keyBackup || restoring}
onClick={restoring ? undefined : handleRestore}
before={<Icon size="100" src={Icons.Download} />}
>
<Text size="B300">Restore Backup</Text>
</Button>
</Box>
</Menu>
</FocusTrap>
}
/>
</>
{remainingSession === 0 ? (
<BackupStatus enabled={backupEnabled} />
) : (
<BackupSyncing count={remainingSession} />
)}
<IconButton
aria-pressed={!!menuCords}
size="300"
variant="Surface"
radii="300"
onClick={handleMenu}
>
<Icon size="100" src={Icons.VerticalDots} />
</IconButton>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu
style={{
padding: config.space.S100,
}}
>
<Box direction="Column" gap="100">
<Box direction="Column" gap="200">
<InfoCard
variant="SurfaceVariant"
title="Backup Details"
description={
<>
<span>Version: {backupInfo?.version ?? 'NIL'}</span>
<br />
<span>Keys: {backupInfo?.count ?? 'NIL'}</span>
</>
}
/>
</Box>
<Button
size="300"
variant="Success"
radii="300"
aria-disabled={restoreState.status === AsyncStatus.Loading || restoring}
onClick={
restoreState.status === AsyncStatus.Loading || restoring
? undefined
: handleRestore
}
before={<Icon size="100" src={Icons.Download} />}
>
<Text size="B300">Restore Backup</Text>
</Button>
</Box>
</Menu>
</FocusTrap>
}
/>
</Box>
}
>
{!backupInfo && (
{syncFailure && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>No key backup has been configured.</b>
<b>{syncFailure}</b>
</Text>
)}
{backupInfo && !keyBackup && (
{!backupEnabled && backupInfo === null && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>Cannot start key backup because the backup could not be verified.</b>
<b>No backup present on server!</b>
</Text>
)}
{restoreState.type === 'download' && (
<BackupProgressFetching
receivedBytes={restoreState.receivedBytes}
totalBytes={restoreState.totalBytes}
/>
{!syncFailure && !backupEnabled && backupInfo && (
<BackupTrustInfo crypto={crypto} backupInfo={backupInfo} />
)}
{restoreState.type === 'import' && (
{restoreState.status === AsyncStatus.Loading && !restoring && <BackupProgressFetching />}
{restoreProgress.status === BackupProgressStatus.Fetching && <BackupProgressFetching />}
{restoreProgress.status === BackupProgressStatus.Loading && (
<BackupProgress
imported={restoreState.progress?.imported ?? 0}
total={restoreState.progress?.total}
total={restoreProgress.data.total}
downloaded={restoreProgress.data.downloaded}
/>
)}
{restoreState.type === 'error' && (
{restoreState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>{restoreState.error.data.error ?? restoreState.error.data.errcode}</b>
<b>{restoreState.error.message}</b>
</Text>
)}
</InfoCard>
);
}
export function AutoRestoreBackupOnVerification() {
useRestoreBackupOnVerification();
return null;
}

View file

@ -17,10 +17,15 @@ import {
Text,
toRem,
} from 'folds';
import { putSend } from 'neurone/lib/api/matrix/client/v3/rooms';
import { roomTimeline } from 'neurone/lib/utils/sync/v3/join';
import { ClientEventWithoutRoomId } from 'neurone/lib/api/types';
import { ObjectReader } from 'neurone/lib/utils';
import {
EventTimelineSetHandlerMap,
EventType,
RelationType,
Room,
RoomEvent,
} from 'matrix-js-sdk';
import { IRTCNotificationContent, RTCNotificationType } from 'matrix-js-sdk/lib/matrixrtc/types';
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
import {
CallEmbedContextProvider,
CallEmbedRefContextProvider,
@ -34,73 +39,57 @@ import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
import { CallEmbed } from '../plugins/call';
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
import { useMatrixClient } from '../hooks/useMatrixClient';
import CallSound from '../../../public/sound/call.ogg';
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
import { mDirectAtom } from '../state/mDirectList';
import { useMediaAuthentication } from '../hooks/useMediaAuthentication';
import { mxcUrlToHttp } from '../utils/matrix';
import { RoomAvatar, RoomIcon } from './room-avatar';
import { useRoomNavigate } from '../hooks/useRoomNavigate';
import { CallEventType, MessageEvent, StateEvent, StateEventType } from '../../types/matrix/room';
import { getStateEvent } from '../utils/room';
import { StateEvent } from '../../types/matrix/room';
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
import { useLivekitSupport } from '../hooks/useLivekitSupport';
import { CallAvatarAnimation } from '../styles/Animations.css';
import { webRTCSupported } from '../utils/rtc';
import { useClient } from '../hooks/useClient';
import { useGetRoomName } from '../hooks/storage/useGetRoomName';
import { useRoomHeroes } from '../hooks/storage/useRoomHeros';
import { useGetRoomAvatar } from '../hooks/storage/useGetRoomAvatar';
import { mxcToHttp } from '../../client/mxcToHttp';
import { RelationType } from '../../types/matrix/common';
import { ensureEncryptionSession } from '../hooks/useEnsureRoomEncryptionSession';
import { SyncValue } from '../../client/sync';
import { isCallRoom } from '../utils/room';
import { reference } from '../../client/room/relations';
import { rContent, rMentions, rRelatesTo } from '../../client/reader';
import { getJoinRoomStateId } from '../../client/storage/collections';
import { getPowerLevels } from '../hooks/usePowerLevels';
import { getRoomPermissionsAPI } from '../hooks/useRoomPermissions';
type IncomingCallInfo = {
roomId: string;
room: Room;
sender: string;
senderTs: number;
lifetime: number;
intent?: string;
silent?: boolean;
notificationType: RTCNotificationType;
refEventId: string;
};
type IncomingCallProps = {
dm: boolean;
info: IncomingCallInfo;
onIgnore: () => void;
onAnswer: (roomId: string, video: boolean) => void;
onReject: (roomId: string, eventId: string) => void;
onAnswer: (room: Room, video: boolean) => void;
onReject: (room: Room, eventId: string) => void;
};
function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallProps) {
const ct = useClient();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const livekitSupported = useLivekitSupport();
const rtcSupported = webRTCSupported();
const canAnswer = livekitSupported && rtcSupported;
const { roomId } = info;
const { room } = info;
const audioRef = useRef<HTMLAudioElement>(null);
const getRoomHeroes = useRoomHeroes();
const getRoomName = useGetRoomName(getRoomHeroes);
const getRoomAvatar = useGetRoomAvatar(getRoomHeroes);
const roomName = getRoomName(roomId);
const roomAvatar = getRoomAvatar(roomId);
const roomName = useRoomName(room);
const roomAvatar = useRoomAvatar(room, dm);
const avatarUrl = roomAvatar
? mxcToHttp(ct.session.baseUrl, roomAvatar, useAuthentication).thumbnail({
width: 96,
height: 96,
method: 'crop',
})
? mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const session = useCallSession(roomId);
const session = useCallSession(room);
useCallMembersChange(
session,
useCallback(
@ -119,10 +108,10 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
}, []);
useEffect(() => {
if (!info.silent) {
if (info.notificationType === 'ring') {
playSound();
}
}, [playSound, info.silent]);
}, [playSound, info.notificationType]);
return (
<>
@ -145,14 +134,14 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
<Box shrink="No">
<Avatar size="500" className={CallAvatarAnimation}>
<RoomAvatar
roomId={roomId}
roomId={room.roomId}
src={avatarUrl}
alt={roomName}
renderFallback={() => (
<RoomIcon
roomType={ct.roomToType.read.get(roomId)}
roomType={room.getType()}
size="400"
joinRule={ct.roomToJoinRule.read.get(roomId)}
joinRule={room.getJoinRule()}
filled
/>
)}
@ -192,7 +181,7 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
variant="Success"
size="400"
radii="400"
onClick={() => onAnswer(roomId, info.intent === 'video')}
onClick={() => onAnswer(room, info.intent === 'video')}
before={
<Icon
size="200"
@ -212,7 +201,7 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
fill="Soft"
size="400"
radii="400"
onClick={() => (dm ? onReject(roomId, info.refEventId) : onIgnore())}
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
before={<Icon size="200" src={Icons.Cross} filled />}
>
<Text as="span" size="B400">
@ -237,150 +226,117 @@ type IncomingCallListenerProps = {
joined?: boolean;
};
function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) {
const ct = useClient();
const mx = useMatrixClient();
const directs = useAtomValue(mDirectAtom);
const { navigateRoom } = useRoomNavigate();
const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
const dm = callInfo ? directs.has(callInfo.roomId) : false;
const startCall = useCallStart();
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
const startCall = useCallStart(dm);
const handleReferenceEvent = useCallback(
async (roomId: string, event: ClientEventWithoutRoomId) => {
let targetEvent = event;
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
async (event, room, toStartOfTimeline, removed, data) => {
// only process rtc notification reference events.
// we do not want to wait to decrypt all events.
if (event.getRelation()?.rel_type !== RelationType.Reference) return;
if (room?.isCallRoom()) return;
if (ct.encryptedRooms.read.has(roomId) && event.type === MessageEvent.RoomMessageEncrypted) {
targetEvent = await ct.decryption.tryDecryptEvent(roomId, event);
if (event.isEncrypted()) {
if (!event.isBeingDecrypted()) {
await event.attemptDecryption(mx.getCrypto() as CryptoBackend);
}
await event.getDecryptionPromise();
}
if (
targetEvent.type !== CallEventType.RTCInvite ||
targetEvent.sender === ct.session.userId
!room ||
event.getType() !== EventType.RTCNotification ||
event.getSender() === mx.getSafeUserId() ||
!data.liveEvent
) {
return;
}
const { sender, content } = targetEvent;
const senderTs = ObjectReader.number(content, 'sender_ts') ?? targetEvent.origin_server_ts;
const lifetime = Math.min(ObjectReader.number(content, 'lifetime') ?? 120000, 120000);
const refEventId = rRelatesTo.eventId(rContent.relatesTo(content));
const sender = event.getSender();
const content = event.getContent<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 =
rMentions.room(rContent.mentions(content)) ||
rMentions.userIds(rContent.mentions(content))?.includes(ct.session.userId);
if (!refEventId || !mention || Date.now() >= senderTs + lifetime) {
content['m.mentions']?.room ||
content['m.mentions']?.user_ids?.includes(mx.getSafeUserId());
if (!sender || !refEventId || !mention || Date.now() >= senderTs + lifetime) {
return;
}
const powerLevelsObj = await ct.storage.joinRoomState.read.get(
getJoinRoomStateId(roomId, StateEventType.RoomPowerLevels, '')
);
if (!powerLevelsObj) return;
const powerLevels = getPowerLevels(powerLevelsObj);
const creators = ct.roomToCreators.read.get(roomId);
const powerLevelsEvent = getStateEvent(room, StateEvent.RoomPowerLevels);
const powerLevels = getPowersLevelFromMatrixEvent(powerLevelsEvent);
const creators = getRoomCreatorsForRoomId(mx, room.roomId);
const permissions = getRoomPermissionsAPI(creators, powerLevels);
const hasCallPermission = permissions.stateEvent(
StateEvent.GroupCallMemberPrefix,
ct.session.userId
mx.getSafeUserId()
);
if (!hasCallPermission) return;
const info: IncomingCallInfo = {
roomId,
room,
sender,
senderTs,
lifetime,
intent: ObjectReader.string(content, 'm.call.intent'),
silent: !ct.directRooms.read.has(roomId),
intent:
'm.call.intent' in content && typeof content['m.call.intent'] === 'string'
? content['m.call.intent']
: undefined,
notificationType,
refEventId,
};
setCallInfo((currentInfo) => {
if (currentInfo) return currentInfo;
return info;
});
setCallInfo(info);
},
[ct]
[mx]
);
useEffect(() => {
const unsub = ct.sync.observable.subscribe(async (syncValue: SyncValue) => {
const [, data] = syncValue ?? [];
if (!data) return;
Object.keys(data.rooms?.join ?? {}).forEach(async (roomId) => {
if (isCallRoom(ct.roomToType.read.get(roomId))) return;
const joinedRoom = data.rooms?.join?.[roomId];
if (!joinedRoom) return;
const timelineEvents = roomTimeline(joinedRoom);
Array.from(timelineEvents)
.reverse()
.forEach((event) => {
if (reference(event)) handleReferenceEvent(roomId, event);
});
});
});
mx.on(RoomEvent.Timeline, handleTimelineEvent);
return () => {
unsub();
mx.removeListener(RoomEvent.Timeline, handleTimelineEvent);
};
}, [ct, handleReferenceEvent]);
}, [mx, handleTimelineEvent]);
const handleIgnore = useCallback(() => {
setCallInfo(undefined);
}, []);
const handleReject = useCallback(
async (roomId: string, eventId: string) => {
setCallInfo(undefined);
const content = {
(room: Room, eventId: string) => {
mx.sendEvent(room.roomId, EventType.RTCDecline, {
'm.relates_to': {
rel_type: RelationType.Reference,
event_id: eventId,
},
};
let encContent: object | undefined;
if (ct.encryptedRooms.read.has(roomId)) {
await ensureEncryptionSession(ct, roomId);
const encStr = await ct.machine.roomEncryption.encryptMessageEvent(
roomId,
CallEventType.RTCDecline,
content
);
encContent = JSON.parse(encStr);
}
ct.requestAuthJSON(
putSend({
pathParams: {
roomId,
eventType: encContent ? MessageEvent.RoomMessageEncrypted : CallEventType.RTCDecline,
txnId: ct.getTxnId(),
},
body: encContent ?? content,
})
);
});
setCallInfo(undefined);
},
[ct]
[mx]
);
const handleAnswer = useCallback(
(roomId: string, video: boolean) => {
startCall(roomId, { microphone: true, video, sound: true });
(room: Room, video: boolean) => {
startCall(room, { microphone: true, video, sound: true });
setCallInfo(undefined);
navigateRoom(roomId);
navigateRoom(room.roomId);
},
[startCall, navigateRoom]
);
if (callInfo && callEmbed?.roomId === callInfo.roomId) {
if (callInfo && callEmbed?.roomId === callInfo.room.roomId) {
return null;
}
return !joined && callInfo ? (

View file

@ -1,23 +1,15 @@
import { ReactNode, useCallback, useEffect } from 'react';
import { getCapabilities, GetCapabilitiesOK } from 'neurone/lib/api/matrix/client/v3/capabilities';
import { parseMatrixResp } from 'neurone/lib/matrix-request';
import { Capabilities } from 'matrix-js-sdk';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { useClient } from '../hooks/useClient';
import { Capabilities } from '../hooks/useCapabilities';
import { useMatrixClient } from '../hooks/useMatrixClient';
type CapabilitiesLoaderProps = {
children: (capabilities: Capabilities | undefined) => ReactNode;
};
export function CapabilitiesLoader({ children }: CapabilitiesLoaderProps) {
const ct = useClient();
const mx = useMatrixClient();
const [state, load] = useAsyncCallback(
useCallback(async () => {
const resp = await ct.requestAuthJSON(getCapabilities({}));
const [, result] = await parseMatrixResp<GetCapabilitiesOK>(resp);
return result?.capabilities;
}, [ct])
);
const [state, load] = useAsyncCallback(useCallback(() => mx.getCapabilities(), [mx]));
useEffect(() => {
load();

View file

@ -1,4 +1,11 @@
import {
ShowSasCallbacks,
VerificationPhase,
VerificationRequest,
Verifier,
} from 'matrix-js-sdk/lib/crypto-api';
import React, { CSSProperties, useCallback, useEffect, useState } from 'react';
import { VerificationMethod } from 'matrix-js-sdk/lib/types';
import {
Box,
Button,
@ -16,24 +23,13 @@ import {
} from 'folds';
import FocusTrap from 'focus-trap-react';
import {
CancelableVerificationStage,
CancelVerificationStage,
DoneVerificationStage,
InboundRequestStage,
InboundVerification,
OutboundRequestStage,
OutboundVerification,
ReadyVerificationStage,
SasAcceptStage,
SasCancelState,
SasMethod,
SasStartStage,
StartVerificationStage,
} from 'neurone/lib/crypto/machine/verification';
import { useVerificationRequestReceived } from '../hooks/useVerificationRequest';
useVerificationRequestPhase,
useVerificationRequestReceived,
useVerifierCancel,
useVerifierShowSas,
} from '../hooks/useVerificationRequest';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { ContainerColor } from '../styles/ContainerColor.css';
import { useObservableValue } from '../hooks/useObservableValue';
const DialogHeaderStyles: CSSProperties = {
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@ -74,12 +70,10 @@ function VerificationWaitAccept() {
}
type VerificationAcceptProps = {
stage: InboundRequestStage;
onAccept: () => Promise<void>;
};
function VerificationAccept({ stage }: VerificationAcceptProps) {
const [acceptState, accept] = useAsyncCallback(
useCallback(() => stage.ready(['m.sas.v1']), [stage])
);
function VerificationAccept({ onAccept }: VerificationAcceptProps) {
const [acceptState, accept] = useAsyncCallback(onAccept);
const accepting = acceptState.status === AsyncStatus.Loading;
return (
@ -108,12 +102,12 @@ function VerificationWaitStart() {
}
type VerificationStartProps = {
stage: ReadyVerificationStage;
onStart: () => Promise<void>;
};
function AutoVerificationStart({ stage }: VerificationStartProps) {
function AutoVerificationStart({ onStart }: VerificationStartProps) {
useEffect(() => {
stage.startSas();
}, [stage]);
onStart();
}, [onStart]);
return (
<Box direction="Column" gap="400">
@ -122,8 +116,8 @@ function AutoVerificationStart({ stage }: VerificationStartProps) {
);
}
function CompareEmoji({ stage }: { stage: SasAcceptStage }) {
const [confirmState, confirm] = useAsyncCallback(useCallback(() => stage.match(), [stage]));
function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData]));
const confirming =
confirmState.status === AsyncStatus.Loading || confirmState.status === AsyncStatus.Success;
@ -141,17 +135,17 @@ function CompareEmoji({ stage }: { stage: SasAcceptStage }) {
wrap="Wrap"
justifyContent="Center"
>
{stage.emoji?.map((emoji, index) => (
{sasData.sas.emoji?.map(([emoji, name], index) => (
<Box
// eslint-disable-next-line react/no-array-index-key
key={`${emoji.description}${index}`}
key={`${emoji}${name}${index}`}
direction="Column"
gap="100"
justifyContent="Center"
alignItems="Center"
>
<Text size="H1">{emoji.emoji}</Text>
<Text size="T200">{emoji.description}</Text>
<Text size="H1">{emoji}</Text>
<Text size="T200">{name}</Text>
</Box>
))}
</Box>
@ -168,7 +162,7 @@ function CompareEmoji({ stage }: { stage: SasAcceptStage }) {
<Button
variant="Primary"
fill="Soft"
onClick={() => stage.mismatch()}
onClick={() => sasData.mismatch()}
disabled={confirming}
>
<Text size="B400">Do not Match</Text>
@ -179,23 +173,21 @@ function CompareEmoji({ stage }: { stage: SasAcceptStage }) {
}
type SasVerificationProps = {
sas: SasMethod;
verifier: Verifier;
onCancel: () => void;
};
function SasVerification({ sas, onCancel }: SasVerificationProps) {
const stage = useObservableValue(sas.stage);
function SasVerification({ verifier, onCancel }: SasVerificationProps) {
const [sasData, setSasData] = useState<ShowSasCallbacks>();
useVerifierShowSas(verifier, setSasData);
useVerifierCancel(verifier, onCancel);
useEffect(() => {
if (stage instanceof SasStartStage) {
stage.accept();
}
if (stage instanceof SasCancelState) {
onCancel();
}
}, [stage, onCancel]);
verifier.verify();
}, [verifier]);
if (stage instanceof SasAcceptStage && stage.emoji) {
return <CompareEmoji stage={stage} />;
if (sasData) {
return <CompareEmoji sasData={sasData} />;
}
return (
@ -222,13 +214,12 @@ function VerificationDone({ onExit }: VerificationDoneProps) {
}
type VerificationCanceledProps = {
stage: CancelVerificationStage;
onClose: () => void;
};
function VerificationCanceled({ stage, onClose }: VerificationCanceledProps) {
function VerificationCanceled({ onClose }: VerificationCanceledProps) {
return (
<Box direction="Column" gap="400">
<Text>Verification has been canceled. {stage.reason}</Text>
<Text>Verification has been canceled.</Text>
<Button variant="Secondary" fill="Soft" onClick={onClose}>
<Text size="B400">Close</Text>
</Button>
@ -237,18 +228,23 @@ function VerificationCanceled({ stage, onClose }: VerificationCanceledProps) {
}
type DeviceVerificationProps = {
request: InboundVerification | OutboundVerification;
request: VerificationRequest;
onExit: () => void;
};
export function DeviceVerification({ request, onExit }: DeviceVerificationProps) {
const stage = useObservableValue(request.stage);
const phase = useVerificationRequestPhase(request);
const handleCancel = useCallback(() => {
if (stage instanceof CancelableVerificationStage) {
stage.cancel();
if (request.phase !== VerificationPhase.Done && request.phase !== VerificationPhase.Cancelled) {
request.cancel();
}
onExit();
}, [stage, onExit]);
}, [request, onExit]);
const handleAccept = useCallback(() => request.accept(), [request]);
const handleStart = useCallback(async () => {
await request.startVerification(VerificationMethod.Sas);
}, [request]);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
@ -270,27 +266,30 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
{stage instanceof InboundRequestStage && <VerificationAccept stage={stage} />}
{stage instanceof OutboundRequestStage && <VerificationWaitAccept />}
{stage instanceof ReadyVerificationStage &&
(request instanceof OutboundVerification ? (
<AutoVerificationStart stage={stage} />
{phase === VerificationPhase.Requested &&
(request.initiatedByMe ? (
<VerificationWaitAccept />
) : (
<VerificationAccept onAccept={handleAccept} />
))}
{phase === VerificationPhase.Ready &&
(request.initiatedByMe ? (
<AutoVerificationStart onStart={handleStart} />
) : (
<VerificationWaitStart />
))}
{stage instanceof StartVerificationStage &&
(stage.method ? (
<SasVerification sas={stage.method} onCancel={handleCancel} />
{phase === VerificationPhase.Started &&
(request.verifier ? (
<SasVerification verifier={request.verifier} onCancel={handleCancel} />
) : (
<VerificationUnexpected
message="Unexpected Error! Verification is started but verifier is missing."
onClose={handleCancel}
/>
))}
{stage instanceof DoneVerificationStage && <VerificationDone onExit={onExit} />}
{stage instanceof CancelVerificationStage && (
<VerificationCanceled stage={stage} onClose={handleCancel} />
{phase === VerificationPhase.Done && <VerificationDone onExit={onExit} />}
{phase === VerificationPhase.Cancelled && (
<VerificationCanceled onClose={handleCancel} />
)}
</Box>
</Dialog>
@ -301,7 +300,7 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
}
export function ReceiveSelfDeviceVerification() {
const [request, setRequest] = useState<InboundVerification | OutboundVerification>();
const [request, setRequest] = useState<VerificationRequest>();
useVerificationRequestReceived(setRequest);
@ -311,7 +310,7 @@ export function ReceiveSelfDeviceVerification() {
if (!request) return null;
if (!request.selfVerification) {
if (!request.isSelfVerification) {
return null;
}

View file

@ -1,11 +1,4 @@
import React, {
FormEventHandler,
forwardRef,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import React, { FormEventHandler, forwardRef, useCallback, useState } from 'react';
import {
Dialog,
Header,
@ -19,236 +12,154 @@ import {
Chip,
color,
Spinner,
ProgressBar,
} from 'folds';
import FileSaver from 'file-saver';
import { BackupManager } from 'neurone/lib/crypto/machine/backup';
import { putAccountData } from 'neurone/lib/api/matrix/client/v3/user';
import {
AesHmacSha2,
AesHmacSha2KeyDescription,
DefaultKey,
KeyStorage,
Passphrase,
RecoveryKey,
Secret,
SECRET_STORAGE_ALGORITHM_V1_AES_HMAC_SHA2,
} from 'neurone/lib/crypto/ssss';
import {
MatrixRequestRetrier,
MatrixUIAData,
MatrixUIAHandler,
randomString,
sleep,
} from 'neurone/lib/utils';
import { postVersion, PostVersionBody } from 'neurone/lib/api/matrix/client/v3/roomKeys';
import { parseMatrixResp } from 'neurone/lib/matrix-request';
import { postDeviceSigningUpload } from 'neurone/lib/api/matrix/client/v3/keys';
import to from 'await-to-js';
import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk';
import { PasswordInput } from './password-input';
import { ContainerColor } from '../styles/ContainerColor.css';
import { copyToClipboard } from '../utils/dom';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { clearSecretStorageKeys } from '../../client/secretStorageKeys';
import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA';
import { UseStateProvider } from './UseStateProvider';
import { useClient } from '../hooks/useClient';
import { getMxIdLocalPart, getMxIdServer } from '../utils/matrix';
import { AccountDataType } from '../../types/matrix/accountData';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useAlive } from '../hooks/useAlive';
import { UseStateProvider } from './UseStateProvider';
type SetupProgressChecklist = {
bootstrapCrossSinging?: boolean;
putMasterAccountData?: boolean;
putSelfAccountData?: boolean;
putUserAcountData?: boolean;
verfiyOwnDevice?: boolean;
postBackupVersion?: boolean;
putBackupAccountData?: boolean;
putRecoveryKeyAccountData?: boolean;
putDefaultKeyAccountData?: boolean;
type UIACallback<T> = (
authDict: AuthDict | null
) => Promise<[IAuthData, undefined] | [undefined, T]>;
type PerformAction<T> = (authDict: AuthDict | null) => Promise<T>;
type UIAAction<T> = {
authData: IAuthData;
callback: UIACallback<T>;
cancelCallback: () => void;
};
function makeUIAAction<T>(
authData: IAuthData,
performAction: PerformAction<T>,
resolve: (data: T) => void,
reject: (error?: any) => void
): UIAAction<T> {
const action: UIAAction<T> = {
authData,
callback: async (authDict) => {
const [error, data] = await to<T, MatrixError | Error>(performAction(authDict));
if (error instanceof MatrixError && error.httpStatus === 401) {
return [error.data as IAuthData, undefined];
}
if (error) {
reject(error);
throw error;
}
resolve(data);
return [undefined, data];
},
cancelCallback: reject,
};
return action;
}
type SetupVerificationProps = {
onComplete: (recoveryKey: string) => void;
};
function SetupVerification({ onComplete }: SetupVerificationProps) {
const ct = useClient();
const uia = useMemo(() => new MatrixUIAHandler(), []);
const [uiaData, setUIAData] = useState<MatrixUIAData>();
const [setupProgress, setSetupProgress] = useState<SetupProgressChecklist>();
const mx = useMatrixClient();
const alive = useAlive();
useEffect(() => uia.onUIA.subscribe(setUIAData), [uia]);
const [uiaAction, setUIAAction] = useState<UIAAction<void>>();
const [nextAuthData, setNextAuthData] = useState<IAuthData | null>(); // null means no next action.
const handleAction = useCallback(
(authDict: object) => {
setUIAData(undefined);
uia.retry(authDict);
async (authDict: AuthDict) => {
if (!uiaAction) {
throw new Error('Unexpected Error! UIA action is perform without data.');
}
if (alive()) {
setNextAuthData(null);
}
const [authData] = await uiaAction.callback(authDict);
if (alive() && authData) {
setNextAuthData(authData);
}
},
[uia]
[uiaAction, alive]
);
const handleCancel = useCallback(() => {
setUIAData(undefined);
uia.clear();
}, [uia]);
const resetUIA = useCallback(() => {
if (!alive()) return;
setUIAAction(undefined);
setNextAuthData(undefined);
}, [alive]);
const authUploadDeviceSigningKeys: UIAuthCallback<void> = useCallback(
(makeRequest) =>
new Promise<void>((resolve, reject) => {
makeRequest(null)
.then(() => {
resolve();
resetUIA();
})
.catch((error) => {
if (error instanceof MatrixError && error.httpStatus === 401) {
const authData = error.data as IAuthData;
const action = makeUIAAction(
authData,
makeRequest as PerformAction<void>,
resolve,
(err) => {
resetUIA();
reject(err);
}
);
if (alive()) {
setUIAAction(action);
} else {
reject(new Error('Authentication failed! Failed to setup device verification.'));
}
return;
}
reject(error);
});
}),
[alive, resetUIA]
);
const [setupState, setup] = useAsyncCallback<void, Error, [string | undefined]>(
useCallback(
async (passphrase) => {
setSetupProgress({});
const retrier = new MatrixRequestRetrier(sleep);
const crypto = mx.getCrypto();
if (!crypto) throw new Error('Unexpected Error! Crypto module not found!');
const defaultKeyId = randomString(16);
const passData = passphrase ? await Passphrase.create(passphrase) : undefined;
const recoveryKey = passData?.key ?? AesHmacSha2.generateRawKey();
const uploadDeviceSigningKeys = async (bodyStr: string) => {
const body = JSON.parse(bodyStr);
const resp = await retrier.execute(() =>
uia.execute((authData) =>
ct.requestAuthJSON(postDeviceSigningUpload({ body: { ...body, auth: authData } }))
)
);
const [err] = await parseMatrixResp(resp);
if (err) throw new Error(err.data.error ?? err.data.errcode);
};
await ct.machine.crossSigning.bootstrapCrossSigning(uploadDeviceSigningKeys);
const crossSigning = await ct.machine.crossSigning.exportKeys();
if (!crossSigning || !crossSigning.master || !crossSigning.self || !crossSigning.user) {
throw new Error('Failed to reset cross signing. No cross signing keys available.');
const recoveryKeyData = await crypto.createRecoveryKeyFromPassphrase(passphrase);
if (!recoveryKeyData.encodedPrivateKey) {
throw new Error('Unexpected Error! Failed to create recovery key.');
}
if (alive()) setSetupProgress((v) => ({ ...v, bootstrapCrossSinging: true }));
clearSecretStorageKeys();
const masterEnc = await AesHmacSha2.encrypt(
recoveryKey,
AccountDataType.CrossSigningMaster,
crossSigning.master
);
const selfEnc = await AesHmacSha2.encrypt(
recoveryKey,
AccountDataType.CrossSigningSelf,
crossSigning.self
);
const userEnc = await AesHmacSha2.encrypt(
recoveryKey,
AccountDataType.CrossSigningUser,
crossSigning.user
);
await crypto.bootstrapSecretStorage({
createSecretStorageKey: async () => recoveryKeyData,
setupNewSecretStorage: true,
});
const { userId } = ct.session;
const masterSecret: Secret = { encrypted: { [defaultKeyId]: masterEnc } };
const selfSecret: Secret = { encrypted: { [defaultKeyId]: selfEnc } };
const userSecret: Secret = { encrypted: { [defaultKeyId]: userEnc } };
await retrier.execute(() =>
ct.requestAuthJSON(
putAccountData({
pathParams: { userId, type: AccountDataType.CrossSigningMaster },
body: masterSecret,
})
)
);
if (alive()) setSetupProgress((v) => ({ ...v, putMasterAccountData: true }));
await retrier.execute(() =>
ct.requestAuthJSON(
putAccountData({
pathParams: { userId, type: AccountDataType.CrossSigningSelf },
body: selfSecret,
})
)
);
if (alive()) setSetupProgress((v) => ({ ...v, putSelfAccountData: true }));
await retrier.execute(() =>
ct.requestAuthJSON(
putAccountData({
pathParams: { userId, type: AccountDataType.CrossSigningUser },
body: userSecret,
})
)
);
if (alive()) setSetupProgress((v) => ({ ...v, putUserAcountData: true }));
await crypto.bootstrapCrossSigning({
authUploadDeviceSigningKeys,
setupNewCrossSigning: true,
});
const device = await ct.machine.userTracker.getDevice(
ct.session.userId,
ct.session.deviceId
);
if (device && !device.crossSigningTrusted) {
const resp = await device.verify();
const [err] = await parseMatrixResp(resp);
if (err) {
throw new Error(err.data.error ?? 'Failed to verify. Signature upload request failed.');
}
await ct.machine.outgoingRequests.queryKeys([ct.session.userId]);
}
if (alive()) setSetupProgress((v) => ({ ...v, verfiyOwnDevice: true }));
await crypto.resetKeyBackup();
const backupKey = BackupManager.createBackupKey();
await ct.megolmBackup.cacheDecryptionKey(backupKey.privateKey);
const backupEnc = await AesHmacSha2.encrypt(
recoveryKey,
AccountDataType.MegolmBackupV1,
backupKey.privateKey
);
const backupAuthData = {
public_key: backupKey.publicKey,
};
await ct.machine.sign(backupAuthData);
const backupVersion: PostVersionBody = {
algorithm: backupKey.algorithm,
auth_data: backupAuthData,
};
await retrier.execute(() => ct.requestAuthJSON(postVersion({ body: backupVersion })));
if (alive()) setSetupProgress((v) => ({ ...v, postBackupVersion: true }));
const backupSecret: Secret = { encrypted: { [defaultKeyId]: backupEnc } };
await retrier.execute(() =>
ct.requestAuthJSON(
putAccountData({
pathParams: { userId, type: AccountDataType.MegolmBackupV1 },
body: backupSecret,
})
)
);
if (alive()) setSetupProgress((v) => ({ ...v, putBackupAccountData: true }));
const { iv, mac } = await AesHmacSha2.driveKeyMetaData(recoveryKey);
const keyDescription: AesHmacSha2KeyDescription = {
algorithm: SECRET_STORAGE_ALGORITHM_V1_AES_HMAC_SHA2,
passphrase: passData?.passphrase,
iv,
mac,
};
await retrier.execute(() =>
ct.requestAuthJSON(
putAccountData({
pathParams: { userId, type: KeyStorage.keyIdentifier(defaultKeyId) },
body: keyDescription,
})
)
);
if (alive()) setSetupProgress((v) => ({ ...v, putRecoveryKeyAccountData: true }));
const defaultKey: DefaultKey = {
key: defaultKeyId,
};
await retrier.execute(() =>
ct.requestAuthJSON(
putAccountData({
pathParams: { userId, type: KeyStorage.defaultKeyIdentifier() },
body: defaultKey,
})
)
);
if (alive()) setSetupProgress((v) => ({ ...v, putDefaultKeyAccountData: true }));
onComplete(RecoveryKey.encode(recoveryKey));
onComplete(recoveryKeyData.encodedPrivateKey);
},
[ct, uia, onComplete, alive]
[mx, onComplete, authUploadDeviceSigningKeys]
)
);
@ -268,80 +179,43 @@ function SetupVerification({ onComplete }: SetupVerificationProps) {
setup(passphrase);
};
const setupSteps: {
key: keyof SetupProgressChecklist;
label: string;
}[] = useMemo(
() => [
{ key: 'bootstrapCrossSinging', label: 'Setting up cross-signing' },
{ key: 'putMasterAccountData', label: 'Securing your master signing key' },
{ key: 'putSelfAccountData', label: 'Securing your self signing key' },
{ key: 'putUserAcountData', label: 'Securing your user signing key' },
{ key: 'verfiyOwnDevice', label: 'Verifying this device' },
{ key: 'postBackupVersion', label: 'Creating your secure backup' },
{ key: 'putBackupAccountData', label: 'Securing your backup key' },
{ key: 'putRecoveryKeyAccountData', label: 'Saving your recovery key information' },
{ key: 'putDefaultKeyAccountData', label: 'Setting up your recovery key' },
],
[]
);
const completedSteps = setupSteps.filter(({ key }) => setupProgress?.[key] === true).length;
return (
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="400">
{setupProgress ? (
<Box direction="Column" gap="400">
<Text size="L400">Setup progress</Text>
<Box style={{ paddingLeft: config.space.S200 }} direction="Column" gap="100">
{setupSteps.map(({ key, label }) => (
<Text size="T200" key={key}>
{setupProgress[key] ? '✅' : '⏳'} {label}
</Text>
))}
</Box>
<ProgressBar variant="Secondary" size="300" min={0} value={completedSteps} />
</Box>
) : (
<>
<Text size="T300">
Generate a <b>Recovery Key</b> for verifying identity if you do not have access to other
devices. Additionally, setup a passphrase as a memorable alternative.
</Text>
<Box direction="Column" gap="100">
<Text size="L400">Passphrase (Optional)</Text>
<PasswordInput name="passphraseInput" size="400" readOnly={loading} />
</Box>
<Button
type="submit"
disabled={loading}
before={loading && <Spinner size="200" variant="Primary" fill="Solid" />}
>
<Text size="B400">Continue</Text>
</Button>
</>
)}
<Text size="T300">
Generate a <b>Recovery Key</b> for verifying identity if you do not have access to other
devices. Additionally, setup a passphrase as a memorable alternative.
</Text>
<Box direction="Column" gap="100">
<Text size="L400">Passphrase (Optional)</Text>
<PasswordInput name="passphraseInput" size="400" readOnly={loading} />
</Box>
<Button
type="submit"
disabled={loading}
before={loading && <Spinner size="200" variant="Primary" fill="Solid" />}
>
<Text size="B400">Continue</Text>
</Button>
{setupState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>{setupState.error ? setupState.error.message : 'Unexpected Error!'}</b>
</Text>
)}
{uiaData && (
{nextAuthData !== null && uiaAction && (
<ActionUIAFlowsLoader
authData={uiaData}
onCancel={handleCancel}
authData={nextAuthData ?? uiaAction.authData}
unsupported={() => (
<Text size="T200" style={{ color: color.Critical.Main }}>
<Text size="T200">
Authentication steps to perform this action are not supported by client.
</Text>
)}
>
{(ongoingFlow) => (
<ActionUIA
authData={uiaData}
authData={nextAuthData ?? uiaAction.authData}
ongoingFlow={ongoingFlow}
action={handleAction}
onCancel={handleCancel}
onCancel={uiaAction.cancelCallback}
/>
)}
</ActionUIAFlowsLoader>
@ -354,7 +228,6 @@ type RecoveryKeyDisplayProps = {
recoveryKey: string;
};
function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
const ct = useClient();
const [show, setShow] = useState(false);
const handleCopy = () => {
@ -362,13 +235,10 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
};
const handleDownload = () => {
const username = getMxIdLocalPart(ct.session.userId);
const servername = getMxIdServer(ct.session.userId);
const blob = new Blob([recoveryKey], {
type: 'text/plain;charset=us-ascii',
});
FileSaver.saveAs(blob, `Recovery Key - ${username} (${servername}).txt`);
FileSaver.saveAs(blob, 'recovery-key.txt');
};
const safeToDisplayKey = show ? recoveryKey : recoveryKey.replace(/[^\s]/g, '*');
@ -400,12 +270,12 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
</Box>
</Box>
<Box direction="Column" gap="200">
<Button onClick={handleDownload}>
<Text size="B400">Download</Text>
</Button>
<Button onClick={handleCopy} fill="Soft">
<Button onClick={handleCopy}>
<Text size="B400">Copy</Text>
</Button>
<Button onClick={handleDownload} fill="Soft">
<Text size="B400">Download</Text>
</Button>
</Box>
</Box>
);

View file

@ -1,21 +1,24 @@
import { ReactNode } from 'react';
import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api';
import {
useDeviceVerificationStatus,
VerificationStatus,
} from '../hooks/useDeviceVerificationStatus';
type DeviceVerificationStatusProps = {
crypto?: CryptoApi;
userId: string;
deviceId: string;
children: (verificationStatus: VerificationStatus) => ReactNode;
};
export function DeviceVerificationStatus({
crypto,
userId,
deviceId,
children,
}: DeviceVerificationStatusProps) {
const status = useDeviceVerificationStatus(userId, deviceId);
const status = useDeviceVerificationStatus(crypto, userId, deviceId);
return children(status);
}

View file

@ -13,43 +13,44 @@ import {
Button,
Spinner,
} from 'folds';
import { JoinRule } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../utils/keyboard';
import { getRoomIconSrc } from '../utils/room';
import { JoinRule } from '../../types/matrix/room';
export type ExtraJoinRules = 'knock_restricted';
export type ExtendedJoinRules = JoinRule | ExtraJoinRules;
type JoinRuleIcons = Record<JoinRule, IconSrc>;
type JoinRuleIcons = Record<ExtendedJoinRules, IconSrc>;
export const useJoinRuleIcons = (roomType?: string): JoinRuleIcons =>
useMemo(
() => ({
invite: getRoomIconSrc(Icons, roomType, 'invite'),
knock: getRoomIconSrc(Icons, roomType, 'knock'),
restricted: getRoomIconSrc(Icons, roomType, 'restricted'),
knock_restricted: getRoomIconSrc(Icons, roomType, 'knock_restricted'),
public: getRoomIconSrc(Icons, roomType, 'public'),
private: getRoomIconSrc(Icons, roomType, 'private'),
[JoinRule.Invite]: getRoomIconSrc(Icons, roomType, JoinRule.Invite),
[JoinRule.Knock]: getRoomIconSrc(Icons, roomType, JoinRule.Knock),
knock_restricted: getRoomIconSrc(Icons, roomType, JoinRule.Restricted),
[JoinRule.Restricted]: getRoomIconSrc(Icons, roomType, JoinRule.Restricted),
[JoinRule.Public]: getRoomIconSrc(Icons, roomType, JoinRule.Public),
[JoinRule.Private]: getRoomIconSrc(Icons, roomType, JoinRule.Private),
}),
[roomType]
);
type JoinRuleLabels = Record<JoinRule, string>;
type JoinRuleLabels = Record<ExtendedJoinRules, string>;
export const useRoomJoinRuleLabel = (): JoinRuleLabels =>
useMemo(
() => ({
invite: 'Invite Only',
knock: 'Knock & Invite',
[JoinRule.Invite]: 'Invite Only',
[JoinRule.Knock]: 'Knock & Invite',
knock_restricted: 'Space Members or Knock',
restricted: 'Space Members',
public: 'Public',
private: 'Invite Only',
[JoinRule.Restricted]: 'Space Members',
[JoinRule.Public]: 'Public',
[JoinRule.Private]: 'Invite Only',
}),
[]
);
type JoinRulesSwitcherProps<T extends JoinRule[]> = {
type JoinRulesSwitcherProps<T extends ExtendedJoinRules[]> = {
icons: JoinRuleIcons;
labels: JoinRuleLabels;
rules: T;
@ -58,7 +59,7 @@ type JoinRulesSwitcherProps<T extends JoinRule[]> = {
disabled?: boolean;
changing?: boolean;
};
export function JoinRulesSwitcher<T extends JoinRule[]>({
export function JoinRulesSwitcher<T extends ExtendedJoinRules[]>({
icons,
labels,
rules,
@ -74,7 +75,7 @@ export function JoinRulesSwitcher<T extends JoinRule[]>({
};
const handleChange = useCallback(
(selectedRule: JoinRule) => {
(selectedRule: ExtendedJoinRules) => {
setCords(undefined);
onChange(selectedRule);
},
@ -126,7 +127,7 @@ export function JoinRulesSwitcher<T extends JoinRule[]>({
fill="Soft"
radii="300"
outlined
before={<Icon size="100" src={icons[value] ?? 'restricted'} />}
before={<Icon size="100" src={icons[value] ?? icons[JoinRule.Restricted]} />}
after={
changing ? (
<Spinner size="100" variant="Secondary" fill="Soft" />

View file

@ -1,9 +1,10 @@
import React, { forwardRef, useCallback } from 'react';
import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { useClient } from '../hooks/useClient';
import { logoutClient } from '../../client/initMatrix';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useCrossSigningActive } from '../hooks/useCrossSigning';
import { InfoCard } from './info-card';
import { useCrossSigning } from '../hooks/useCrossSigning';
import {
useDeviceVerificationStatus,
VerificationStatus,
@ -14,16 +15,19 @@ type LogoutDialogProps = {
};
export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
({ handleClose }, ref) => {
const ct = useClient();
const hasEncryptedRoom = ct.encryptedRooms.read.size > 0;
const crossSigning = useCrossSigning();
const verificationStatus = useDeviceVerificationStatus(ct.session.userId, ct.session.deviceId);
const mx = useMatrixClient();
const hasEncryptedRoom = !!mx.getRooms().find((room) => room.hasEncryptionStateEvent());
const crossSigningActive = useCrossSigningActive();
const verificationStatus = useDeviceVerificationStatus(
mx.getCrypto(),
mx.getSafeUserId(),
mx.getDeviceId() ?? undefined
);
const [logoutState, logout] = useAsyncCallback<void, Error, []>(
useCallback(async () => {
await ct.logout();
}, [ct])
await logoutClient(mx);
}, [mx])
);
const ongoingLogout = logoutState.status === AsyncStatus.Loading;
@ -44,7 +48,7 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
{hasEncryptedRoom &&
(crossSigning ? (
(crossSigningActive ? (
verificationStatus === VerificationStatus.Unverified && (
<InfoCard
variant="Critical"

View file

@ -13,15 +13,13 @@ import {
color,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { AesHmacSha2, AesHmacSha2KeyDescription } from 'neurone/lib/crypto/ssss';
import { parseMatrixResp } from 'neurone/lib/matrix-request';
import { stopPropagation } from '../utils/keyboard';
import { SettingTile } from './setting-tile';
import { SecretStorageKeyContent } from '../../types/matrix/accountData';
import { SecretStorageRecoveryKey, SecretStorageRecoveryPassphrase } from './SecretStorage';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { useClient } from '../hooks/useClient';
import { getCrossSigningPrivateKeys, useCrossSigningSecretData } from '../hooks/useCrossSigning';
import { AccountDataType } from '../../types/matrix/accountData';
import { storePrivateKey } from '../../client/secretStorageKeys';
export enum ManualVerificationMethod {
RecoveryPassphrase = 'passphrase',
@ -114,7 +112,7 @@ export function ManualVerificationMethodSwitcher({
type ManualVerificationTileProps = {
secretStorageKeyId: string;
secretStorageKeyContent: AesHmacSha2KeyDescription;
secretStorageKeyContent: SecretStorageKeyContent;
options?: ReactNode;
};
export function ManualVerificationTile({
@ -122,8 +120,7 @@ export function ManualVerificationTile({
secretStorageKeyContent,
options,
}: ManualVerificationTileProps) {
const ct = useClient();
const csSecretData = useCrossSigningSecretData(secretStorageKeyId);
const mx = useMatrixClient();
const hasPassphrase = !!secretStorageKeyContent.passphrase;
const [method, setMethod] = useState(
@ -133,53 +130,25 @@ export function ManualVerificationTile({
);
const verifyAndRestoreBackup = useCallback(
async (recoveryKey: Uint8Array<ArrayBuffer>) => {
const status = await ct.machine.crossSigning.getStatus();
const machineHasKeys = status.master && status.self && status.user;
if (!machineHasKeys && csSecretData) {
const crossSigningKeys = await getCrossSigningPrivateKeys(recoveryKey, csSecretData);
const s = await ct.machine.crossSigning.importKeys(crossSigningKeys);
if (!s.master || !s.self || !s.user) {
throw new Error('Failed to import to CrossSigningSecrets keys!');
}
async (recoveryKey: Uint8Array) => {
const crypto = mx.getCrypto();
if (!crypto) {
throw new Error('Unexpected Error! Crypto object not found.');
}
const { userId, deviceId } = ct.session;
const device = await ct.machine.userTracker.getDevice(userId, deviceId);
if (device && !device.crossSigningTrusted) {
const resp = await device.verify();
const [err] = await parseMatrixResp(resp);
if (err) {
throw new Error(err.data.error ?? 'Failed to verify. Signature upload request failed.');
}
storePrivateKey(secretStorageKeyId, recoveryKey);
// device.verify uploads signatures to mark device as verifed
// query keys to receive signs now instead of update via /sync
// if we don't do that device remains unverified until next sync device_list change
await ct.machine.outgoingRequests.queryKeys([ct.session.userId]);
}
await crypto.bootstrapCrossSigning({});
await crypto.bootstrapSecretStorage({});
const backupKeyEncryptedData = ct.secrets.megolmBackup.read?.get(secretStorageKeyId);
if (!backupKeyEncryptedData) {
return;
}
const backupKey = await AesHmacSha2.decrypt(
recoveryKey,
AccountDataType.MegolmBackupV1,
backupKeyEncryptedData
);
await ct.megolmBackup.saveDecryptionKey(backupKey);
await crypto.loadSessionBackupPrivateKeyFromSecretStorage();
},
[ct, secretStorageKeyId, csSecretData]
[mx, secretStorageKeyId]
);
const [verifyState, handleDecodedRecoveryKey] = useAsyncCallback<
void,
Error,
[Uint8Array<ArrayBuffer>]
>(verifyAndRestoreBackup);
const [verifyState, handleDecodedRecoveryKey] = useAsyncCallback<void, Error, [Uint8Array]>(
verifyAndRestoreBackup
);
const verifying = verifyState.status === AsyncStatus.Loading;
return (
@ -205,7 +174,7 @@ export function ManualVerificationTile({
{method === ManualVerificationMethod.RecoveryKey && (
<SecretStorageRecoveryKey
processing={verifying}
keyDescription={secretStorageKeyContent}
keyContent={secretStorageKeyContent}
onDecodedRecoveryKey={handleDecodedRecoveryKey}
/>
)}
@ -213,8 +182,8 @@ export function ManualVerificationTile({
secretStorageKeyContent.passphrase && (
<SecretStorageRecoveryPassphrase
processing={verifying}
keyDescription={secretStorageKeyContent}
passphraseInfo={secretStorageKeyContent.passphrase}
keyContent={secretStorageKeyContent}
passphraseContent={secretStorageKeyContent.passphrase}
onDecodedRecoveryKey={handleDecodedRecoveryKey}
/>
)}

View file

@ -1,25 +1,15 @@
import { ReactNode, useCallback, useEffect } from 'react';
import { getConfig, GetConfigOK } from 'neurone/lib/api/matrix/client/v1/media';
import { parseMatrixResp } from 'neurone/lib/matrix-request';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { MediaConfig } from '../hooks/useMediaConfig';
import { useClient } from '../hooks/useClient';
type MediaConfigLoaderProps = {
children: (mediaConfig: MediaConfig | undefined) => ReactNode;
};
export function MediaConfigLoader({ children }: MediaConfigLoaderProps) {
const ct = useClient();
const mx = useMatrixClient();
const [state, load] = useAsyncCallback(
useCallback(async () => {
const resp = await ct.requestAuthJSON(getConfig({}));
const [, config] = await parseMatrixResp<GetConfigOK>(resp);
return config;
}, [ct])
);
const [state, load] = useAsyncCallback(useCallback(() => mx.getMediaConfig(), [mx]));
useEffect(() => {
load();

View file

@ -1,40 +0,0 @@
import React from 'react';
import { MessageEventWithoutRoomId } from 'neurone/lib/api/types';
import { DecryptionManager } from '../../client/decryption';
import { MessageEvent } from '../../types/matrix/room';
import { EncryptedMessageEvent } from './encrypted-event';
type MessageContentProps = {
event: MessageEventWithoutRoomId;
editedEvent?: MessageEventWithoutRoomId;
roomId: string;
decryptionManager: DecryptionManager;
reply?: boolean;
children: (event: MessageEventWithoutRoomId) => React.ReactNode;
};
export function MessageContent({
event,
editedEvent,
roomId,
decryptionManager,
reply,
children,
}: MessageContentProps) {
const effectiveEvent = editedEvent ?? event;
if (effectiveEvent.type === MessageEvent.RoomMessageEncrypted) {
return (
<EncryptedMessageEvent
roomId={roomId}
decryptionManager={decryptionManager}
event={effectiveEvent}
reply={reply}
>
{(decryptedEvent) => children(decryptedEvent)}
</EncryptedMessageEvent>
);
}
return children(effectiveEvent);
}

View file

@ -1,30 +1,43 @@
import React from 'react';
import { MsgType } from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs';
import { config } from 'folds';
import {
EmptyContent,
AudioContent,
DownloadFile,
FileContent,
ImageContent,
MAudio,
MBadEncrypted,
MEmote,
MFile,
MImage,
MLocation,
MNotice,
MSticker,
MText,
MVideo,
ReadPdfFile,
ReadTextFile,
RenderBody,
ThumbnailContent,
UnsupportedContent,
VideoContent,
} from './message';
import { MsgType } from '../../types/matrix/common';
import { rContent } from '../../client/reader';
import { MessageEvent } from '../../types/matrix/room';
import { UrlPreviewCard, UrlPreviewHolder } from './url-preview';
import { Image, MediaControl, Video } from './media';
import { ImageViewer } from './image-viewer';
import { PdfViewer } from './Pdf-viewer';
import { TextViewer } from './text-viewer';
import { testMatrixTo } from '../plugins/matrix-to';
import { IImageContent } from '../../types/matrix/common';
type RenderMessageContentProps = {
displayName: string;
eventType: string;
msgType: string;
ts: number;
edited?: boolean;
content: object;
getContent: <T>() => T;
mediaAutoLoad?: boolean;
urlPreview?: boolean;
highlightRegex?: RegExp;
@ -34,10 +47,10 @@ type RenderMessageContentProps = {
};
export function RenderMessageContent({
displayName,
eventType,
msgType,
ts,
edited,
content,
getContent,
mediaAutoLoad,
urlPreview,
highlightRegex,
@ -45,53 +58,90 @@ export function RenderMessageContent({
linkifyOpts,
outlineAttachment,
}: RenderMessageContentProps) {
if (eventType === MessageEvent.Sticker) {
const renderUrlsPreview = (urls: string[]) => {
const filteredUrls = urls.filter((url) => !testMatrixTo(url));
if (filteredUrls.length === 0) return undefined;
return (
<MSticker
content={content}
mediaAutoLoad={mediaAutoLoad}
outlineAttachment={outlineAttachment}
/>
<UrlPreviewHolder>
{filteredUrls.map((url) => (
<UrlPreviewCard key={url} url={url} ts={ts} />
))}
</UrlPreviewHolder>
);
}
if (eventType !== MessageEvent.RoomMessage) {
return <UnsupportedContent eventType={eventType} />;
}
};
const renderCaption = () => {
const filename = rContent.filename(content);
const body = rContent.body(content);
if (filename && filename !== body && body) {
const content: IImageContent = getContent();
if (content.filename && content.filename !== content.body) {
return (
<MText
style={{ marginTop: config.space.S100 }}
style={{ marginTop: config.space.S200 }}
edited={edited}
content={content}
urlPreview={urlPreview}
ts={ts}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
renderBody={(props) => (
<RenderBody
{...props}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
)}
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
/>
);
}
return null;
};
const msgType = rContent.msgtype(content);
const renderFile = () => (
<>
<MFile
content={getContent()}
renderFileContent={({ body, mimeType, info, encInfo, url }) => (
<FileContent
body={body}
mimeType={mimeType}
renderAsPdfFile={() => (
<ReadPdfFile
body={body}
mimeType={mimeType}
url={url}
encInfo={encInfo}
renderViewer={(p) => <PdfViewer {...p} />}
/>
)}
renderAsTextFile={() => (
<ReadTextFile
body={body}
mimeType={mimeType}
url={url}
encInfo={encInfo}
renderViewer={(p) => <TextViewer {...p} />}
/>
)}
>
<DownloadFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} info={info} />
</FileContent>
)}
outlined={outlineAttachment}
/>
{renderCaption()}
</>
);
if (msgType === MsgType.Text) {
return (
<MText
edited={edited}
content={content}
urlPreview={urlPreview}
ts={ts}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
content={getContent()}
renderBody={(props) => (
<RenderBody
{...props}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
)}
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
/>
);
}
@ -101,12 +151,16 @@ export function RenderMessageContent({
<MEmote
displayName={displayName}
edited={edited}
content={content}
urlPreview={urlPreview}
ts={ts}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
content={getContent()}
renderBody={(props) => (
<RenderBody
{...props}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
)}
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
/>
);
}
@ -115,12 +169,16 @@ export function RenderMessageContent({
return (
<MNotice
edited={edited}
content={content}
urlPreview={urlPreview}
ts={ts}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
content={getContent()}
renderBody={(props) => (
<RenderBody
{...props}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
)}
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
/>
);
}
@ -128,7 +186,18 @@ export function RenderMessageContent({
if (msgType === MsgType.Image) {
return (
<>
<MImage content={content} mediaAutoLoad={mediaAutoLoad} outlined={outlineAttachment} />
<MImage
content={getContent()}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
outlined={outlineAttachment}
/>
{renderCaption()}
</>
);
@ -137,7 +206,31 @@ export function RenderMessageContent({
if (msgType === MsgType.Video) {
return (
<>
<MVideo content={content} mediaAutoLoad={mediaAutoLoad} outlined={outlineAttachment} />
<MVideo
content={getContent()}
renderAsFile={renderFile}
renderVideoContent={({ body, info, ...props }) => (
<VideoContent
body={body}
info={info}
{...props}
renderThumbnail={
mediaAutoLoad
? () => (
<ThumbnailContent
info={info}
renderImage={(src) => (
<Image alt={body} title={body} src={src} loading="lazy" />
)}
/>
)
: undefined
}
renderVideo={(p) => <Video {...p} />}
/>
)}
outlined={outlineAttachment}
/>
{renderCaption()}
</>
);
@ -146,38 +239,30 @@ export function RenderMessageContent({
if (msgType === MsgType.Audio) {
return (
<>
<MAudio content={content} outlined={outlineAttachment} />
<MAudio
content={getContent()}
renderAsFile={renderFile}
renderAudioContent={(props) => (
<AudioContent {...props} renderMediaControl={(p) => <MediaControl {...p} />} />
)}
outlined={outlineAttachment}
/>
{renderCaption()}
</>
);
}
if (msgType === MsgType.File) {
return (
<>
<MFile content={content} outlined={outlineAttachment} />
{renderCaption()}
</>
);
return renderFile();
}
if (msgType === MsgType.Location) {
return <MLocation content={content} />;
return <MLocation content={getContent()} />;
}
if (Object.keys(content).length === 0) {
return <EmptyContent />;
if (msgType === 'm.bad.encrypted') {
return <MBadEncrypted />;
}
return (
<MText
edited={edited}
content={content}
urlPreview={urlPreview}
ts={ts}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
);
return <UnsupportedContent />;
}

View file

@ -1,141 +0,0 @@
import React from 'react';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs';
import { Text } from 'folds';
import {
MessageAudioContent,
MessageFileContent,
MessageImageContent,
MessageStickerContent,
MessageVideoContent,
UnsupportedContent,
ReplyMEmote,
ReplyMNotice,
ReplyMText,
ReplyMLocation,
EmptyContent,
} from './message';
import { MsgType } from '../../types/matrix/common';
import { rContent } from '../../client/reader';
import { MessageEvent } from '../../types/matrix/room';
type RenderReplyContentProps = {
displayName: string;
eventType: string;
edited?: boolean;
content: object;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
};
export function RenderReplyContent({
displayName,
eventType,
edited,
content,
highlightRegex,
htmlReactParserOptions,
linkifyOpts,
}: RenderReplyContentProps) {
if (eventType === MessageEvent.Sticker) {
return (
<Text size="T300" priority="300">
<MessageStickerContent />
</Text>
);
}
if (eventType !== MessageEvent.RoomMessage) {
return <UnsupportedContent size="T300" eventType={eventType} />;
}
const msgType = rContent.msgtype(content);
if (msgType === MsgType.Text) {
return (
<ReplyMText
edited={edited}
content={content}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
);
}
if (msgType === MsgType.Emote) {
return (
<ReplyMEmote
displayName={displayName}
edited={edited}
content={content}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
);
}
if (msgType === MsgType.Notice) {
return (
<ReplyMNotice
edited={edited}
content={content}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
);
}
if (msgType === MsgType.Image) {
return (
<Text size="T300" priority="300">
<MessageImageContent />
</Text>
);
}
if (msgType === MsgType.Video) {
return (
<Text size="T300" priority="300">
<MessageVideoContent />
</Text>
);
}
if (msgType === MsgType.Audio) {
return (
<Text size="T300" priority="300">
<MessageAudioContent />
</Text>
);
}
if (msgType === MsgType.File) {
return (
<Text size="T300" priority="300">
<MessageFileContent />
</Text>
);
}
if (msgType === MsgType.Location) {
return <ReplyMLocation content={content} />;
}
if (Object.keys(content).length === 0) {
return <EmptyContent size="T300" />;
}
return (
<ReplyMText
edited={edited}
content={content}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
);
}

View file

@ -1,41 +0,0 @@
import { Icon, Icons, IconSrc, Text, Tooltip, TooltipProvider } from 'folds';
import React from 'react';
import { JoinRule } from '../../types/matrix/room';
import { useClient } from '../hooks/useClient';
import { useObservableValue } from '../hooks/useObservableValue';
import { useRoomJoinRuleLabel } from './JoinRulesSwitcher';
export const getRoomAccessIconSrc = (joinRule?: JoinRule): IconSrc | undefined => {
if (!joinRule) return Icons.Lock;
if (joinRule === 'public') return Icons.Globe;
if (joinRule === 'invite' || joinRule === 'knock' || joinRule === 'private') {
return Icons.Lock;
}
return undefined;
};
export function RoomAccessIndicator({ roomId }: { roomId: string }) {
const ct = useClient();
const roomToJoinRule = useObservableValue(ct.roomToJoinRule);
const ruleToLabels = useRoomJoinRuleLabel();
const iconSrc = getRoomAccessIconSrc(roomToJoinRule.get(roomId));
if (!iconSrc) return null;
return (
<TooltipProvider
position="Right"
align="Center"
tooltip={
<Tooltip>
<Text size="L400">{ruleToLabels[roomToJoinRule.get(roomId) ?? 'private']}</Text>
</Tooltip>
}
>
{(triggerRef) => <Icon ref={triggerRef} opacity={0.8} src={iconSrc} size="50" />}
</TooltipProvider>
);
}

View file

@ -1,37 +1,22 @@
import { ReactElement, useCallback, useState } from 'react';
import { ReactNode, useCallback, useState } from 'react';
import { MatrixClient, Room } from 'matrix-js-sdk';
import { useQuery } from '@tanstack/react-query';
import { parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request';
import { getRoomSummary, GetRoomSummaryOK } from 'neurone/lib/api/matrix/client/v1/roomSummary';
import { getHierarchy, GetHierarchyOK } from 'neurone/lib/api/matrix/client/v1/rooms';
import { SpaceHierarchyRoomChunk } from 'neurone/lib/api/types';
import { AsyncState, AsyncStatus } from '../hooks/useAsyncCallback';
import { useClient } from '../hooks/useClient';
import { useSpecVersionSupport } from '../hooks/useSpecVersions';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { LocalRoomSummary, useLocalRoomSummary } from '../hooks/useLocalRoomSummary';
import { AsyncState, AsyncStatus } from '../hooks/useAsyncCallback';
export type IRoomSummary = Awaited<ReturnType<MatrixClient['getRoomSummary']>>;
type RoomSummaryLoaderProps = {
roomIdOrAlias: string;
children: (roomSummary?: GetRoomSummaryOK) => ReactElement;
children: (roomSummary?: IRoomSummary) => ReactNode;
};
export function RoomSummaryLoader({ roomIdOrAlias, children }: RoomSummaryLoaderProps) {
const ct = useClient();
const roomSummarySupported = useSpecVersionSupport('v1.15');
const mx = useMatrixClient();
const fetchSummary = useCallback(async (): Promise<GetRoomSummaryOK> => {
const endpoint = getRoomSummary({ pathParams: { roomIdOrAlias } });
if (!roomSummarySupported) {
endpoint.path = `/_matrix/client/unstable/im.nheko.summary/summary/${encodeURIComponent(
roomIdOrAlias
)}`;
}
const response = await ct.requestAuthJSON(endpoint);
const [err, result] = await parseMatrixResp<GetRoomSummaryOK>(response);
if (err) {
throw new Error(printMatrixError(err));
}
return result;
}, [ct, roomIdOrAlias, roomSummarySupported]);
const fetchSummary = useCallback(() => mx.getRoomSummary(roomIdOrAlias), [mx, roomIdOrAlias]);
const { data } = useQuery({
queryKey: [roomIdOrAlias, `summary`],
@ -42,42 +27,27 @@ export function RoomSummaryLoader({ roomIdOrAlias, children }: RoomSummaryLoader
}
export function LocalRoomSummaryLoader({
roomId,
room,
children,
}: {
roomId: string;
children: (summary: LocalRoomSummary) => ReactElement;
room: Room;
children: (roomSummary: LocalRoomSummary) => ReactNode;
}) {
const summary = useLocalRoomSummary(roomId);
const summary = useLocalRoomSummary(room);
return children(summary);
}
export function HierarchyRoomSummaryLoader({
roomId,
children,
}: {
roomId: string;
children: (state: AsyncState<SpaceHierarchyRoomChunk, Error>) => ReactElement;
children: (state: AsyncState<IHierarchyRoom, Error>) => ReactNode;
}) {
const ct = useClient();
const mx = useMatrixClient();
const fetchSummary = useCallback(async (): Promise<GetHierarchyOK> => {
const response = await ct.requestAuthJSON(
getHierarchy({
pathParams: { roomId },
searchParams: {
limit: 1,
max_depth: 1,
},
})
);
const [err, result] = await parseMatrixResp<GetHierarchyOK>(response);
if (err) {
throw new Error(printMatrixError(err));
}
return result;
}, [ct, roomId]);
const fetchSummary = useCallback(() => mx.getRoomHierarchy(roomId, 1, 1), [mx, roomId]);
const [errorMemo, setError] = useState<Error>();
const { data, error } = useQuery({
@ -92,7 +62,7 @@ export function HierarchyRoomSummaryLoader({
},
});
let state: AsyncState<SpaceHierarchyRoomChunk, Error> = {
let state: AsyncState<IHierarchyRoom, Error> = {
status: AsyncStatus.Loading,
};
if (error) {
@ -108,7 +78,7 @@ export function HierarchyRoomSummaryLoader({
};
}
const summary = data?.rooms[0];
const summary = data?.rooms[0] ?? undefined;
if (summary) {
state = {
status: AsyncStatus.Success,

View file

@ -0,0 +1,24 @@
import { ReactElement } from 'react';
import { Unread } from '../../types/matrix/room';
import { useRoomUnread, useRoomsUnread } from '../state/hooks/unread';
import { roomToUnreadAtom } from '../state/room/roomToUnread';
type RoomUnreadProviderProps = {
roomId: string;
children: (unread?: Unread) => ReactElement;
};
export function RoomUnreadProvider({ roomId, children }: RoomUnreadProviderProps) {
const unread = useRoomUnread(roomId, roomToUnreadAtom);
return children(unread);
}
type RoomsUnreadProviderProps = {
rooms: string[];
children: (unread?: Unread) => ReactElement;
};
export function RoomsUnreadProvider({ rooms, children }: RoomsUnreadProviderProps) {
const unread = useRoomsUnread(rooms, roomToUnreadAtom);
return children(unread);
}

View file

@ -1,51 +1,53 @@
import React, { FormEventHandler, useCallback } from 'react';
import { Box, Text, Button, Spinner, color } from 'folds';
import {
AesHmacSha2,
AesHmacSha2KeyDescription,
Passphrase,
PassphraseInfo,
RecoveryKey,
} from 'neurone/lib/crypto/ssss';
import { decodeRecoveryKey, deriveRecoveryKeyFromPassphrase } from 'matrix-js-sdk/lib/crypto-api';
import { PasswordInput } from './password-input';
import {
SecretStorageKeyContent,
SecretStoragePassphraseContent,
} from '../../types/matrix/accountData';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useAlive } from '../hooks/useAlive';
type SecretStorageRecoveryPassphraseProps = {
processing?: boolean;
keyDescription: AesHmacSha2KeyDescription;
passphraseInfo: PassphraseInfo;
onDecodedRecoveryKey: (recoveryKey: Uint8Array<ArrayBuffer>) => void;
keyContent: SecretStorageKeyContent;
passphraseContent: SecretStoragePassphraseContent;
onDecodedRecoveryKey: (recoveryKey: Uint8Array) => void;
};
export function SecretStorageRecoveryPassphrase({
processing,
keyDescription,
passphraseInfo,
keyContent,
passphraseContent,
onDecodedRecoveryKey,
}: SecretStorageRecoveryPassphraseProps) {
const mx = useMatrixClient();
const alive = useAlive();
const [driveKeyState, submitPassphrase] = useAsyncCallback<
Uint8Array<ArrayBuffer>,
Uint8Array,
Error,
[passphrase: string]
Parameters<typeof deriveRecoveryKeyFromPassphrase>
>(
useCallback(
async (passphrase) => {
const decodedRecoveryKey = await Passphrase.derive(passphrase, passphraseInfo);
const { mac: regeneratedMac } = await AesHmacSha2.driveKeyMetaData(
decodedRecoveryKey,
keyDescription.iv
async (passphrase, salt, iterations, bits) => {
const decodedRecoveryKey = await deriveRecoveryKeyFromPassphrase(
passphrase,
salt,
iterations,
bits
);
if (regeneratedMac !== keyDescription.mac) {
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
if (!match) {
throw new Error('Invalid recovery passphrase.');
}
return decodedRecoveryKey;
},
[keyDescription, passphraseInfo]
[mx, keyContent]
)
);
@ -62,7 +64,8 @@ export function SecretStorageRecoveryPassphrase({
const recoveryPassphrase = recoveryPassphraseInput.value.trim();
if (!recoveryPassphrase) return;
submitPassphrase(recoveryPassphrase).then((decodedRecoveryKey) => {
const { salt, iterations, bits } = passphraseContent;
submitPassphrase(recoveryPassphrase, salt, iterations, bits).then((decodedRecoveryKey) => {
if (alive()) {
recoveryPassphraseInput.value = '';
onDecodedRecoveryKey(decodedRecoveryKey);
@ -112,37 +115,31 @@ export function SecretStorageRecoveryPassphrase({
type SecretStorageRecoveryKeyProps = {
processing?: boolean;
keyDescription: AesHmacSha2KeyDescription;
onDecodedRecoveryKey: (recoveryKey: Uint8Array<ArrayBuffer>) => void;
keyContent: SecretStorageKeyContent;
onDecodedRecoveryKey: (recoveryKey: Uint8Array) => void;
};
export function SecretStorageRecoveryKey({
processing,
keyDescription,
keyContent,
onDecodedRecoveryKey,
}: SecretStorageRecoveryKeyProps) {
const mx = useMatrixClient();
const alive = useAlive();
const [driveKeyState, submitRecoveryKey] = useAsyncCallback<
Uint8Array<ArrayBuffer>,
Error,
[string]
>(
const [driveKeyState, submitRecoveryKey] = useAsyncCallback<Uint8Array, Error, [string]>(
useCallback(
async (recoveryKey) => {
const decodedRecoveryKey = RecoveryKey.decode(recoveryKey);
const decodedRecoveryKey = decodeRecoveryKey(recoveryKey);
const { mac: regeneratedMac } = await AesHmacSha2.driveKeyMetaData(
decodedRecoveryKey,
keyDescription.iv
);
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
if (regeneratedMac !== keyDescription.mac) {
if (!match) {
throw new Error('Invalid recovery key.');
}
return decodedRecoveryKey;
},
[keyDescription]
[mx, keyContent]
)
);

View file

@ -1,48 +1,48 @@
import { ReactNode, useCallback, useMemo } from 'react';
import { getCapabilities, GetCapabilitiesOK } from 'neurone/lib/api/matrix/client/v3/capabilities';
import { getConfig, GetConfigOK } from 'neurone/lib/api/matrix/client/v1/media';
import { getAuthMetadata, GetAuthMetadataOK } from 'neurone/lib/api/matrix/client/v1/authMetadata';
import { parseMatrixResp } from 'neurone/lib/matrix-request';
import { Capabilities, validateAuthMetadata, ValidatedAuthMetadata } from 'matrix-js-sdk';
import { AsyncStatus, useAsyncCallbackValue } from '../hooks/useAsyncCallback';
import { promiseFulfilledResult } from '../utils/common';
import { useClient } from '../hooks/useClient';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { MediaConfig } from '../hooks/useMediaConfig';
import { Capabilities } from '../hooks/useCapabilities';
import { getJSONRequest } from '../../client/request';
import { promiseFulfilledResult } from '../utils/common';
export type ServerConfigs = {
capabilities?: Capabilities;
mediaConfig?: MediaConfig;
authMetadata?: GetAuthMetadataOK;
authMetadata?: ValidatedAuthMetadata;
};
type ServerConfigsLoaderProps = {
children: (configs: ServerConfigs) => ReactNode;
};
export function ServerConfigsLoader({ children }: ServerConfigsLoaderProps) {
const ct = useClient();
const mx = useMatrixClient();
const fallbackConfigs = useMemo(() => ({}), []);
const [configsState] = useAsyncCallbackValue<ServerConfigs, unknown>(
useCallback(async () => {
const result = await Promise.allSettled([
ct.requestAuthJSON(getCapabilities({})).then(parseMatrixResp<GetCapabilitiesOK>),
ct.requestAuthJSON(getConfig({})).then(parseMatrixResp<GetConfigOK>),
getJSONRequest(ct.session.baseUrl)(getAuthMetadata({})).then(
parseMatrixResp<GetAuthMetadataOK>
),
mx.getCapabilities(),
mx.getMediaConfig(),
mx.getAuthMetadata(),
]);
const [, capResult] = promiseFulfilledResult(result[0]) ?? [];
const [, mediaConfig] = promiseFulfilledResult(result[1]) ?? [];
const [, authMetadata] = promiseFulfilledResult(result[2]) ?? [];
const capabilities = promiseFulfilledResult(result[0]);
const mediaConfig = promiseFulfilledResult(result[1]);
const authMetadata = promiseFulfilledResult(result[2]);
let validatedAuthMetadata: ValidatedAuthMetadata | undefined;
try {
validatedAuthMetadata = validateAuthMetadata(authMetadata);
} catch (e) {
console.error(e);
}
return {
capabilities: capResult?.capabilities,
capabilities,
mediaConfig,
authMetadata,
authMetadata: validatedAuthMetadata,
};
}, [ct])
}, [mx])
);
const configs: ServerConfigs =

View file

@ -2,29 +2,6 @@ import { ReactNode, useCallback, useEffect, useState } from 'react';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { SpecVersions, specVersions } from '../cs-api';
const getSpecVersionKey = (baseUrl: string) => `specVersions:${baseUrl}`;
const setSpecVersions = (baseUrl: string, data: SpecVersions) => {
localStorage.setItem(getSpecVersionKey(baseUrl), JSON.stringify(data));
};
const getSpecVersions = (baseUrl: string): SpecVersions | undefined => {
try {
const dataStr = localStorage.getItem(getSpecVersionKey(baseUrl));
if (dataStr === null) return undefined;
const data = JSON.parse(dataStr);
if (typeof data === 'object' && 'versions' in data && Array.isArray(data.versions)) {
return data;
}
return undefined;
} catch {
return undefined;
}
};
const FALLBACK_DATA: SpecVersions = {
versions: [],
};
type SpecVersionsLoaderProps = {
baseUrl: string;
fallback?: () => ReactNode;
@ -38,15 +15,8 @@ export function SpecVersionsLoader({
children,
}: SpecVersionsLoaderProps) {
const [state, load] = useAsyncCallback(
useCallback(async () => {
const data = await specVersions(fetch, baseUrl);
setSpecVersions(baseUrl, data);
return data;
}, [baseUrl])
useCallback(() => specVersions(fetch, baseUrl), [baseUrl])
);
const data = state.status === AsyncStatus.Success ? state.data : getSpecVersions(baseUrl);
const [ignoreError, setIgnoreError] = useState(false);
const ignoreCallback = useCallback(() => setIgnoreError(true), []);
@ -55,13 +25,19 @@ export function SpecVersionsLoader({
load();
}, [load]);
if (!data && (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading)) {
if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) {
return fallback?.();
}
if (!data && !ignoreError && state.status === AsyncStatus.Error) {
if (!ignoreError && state.status === AsyncStatus.Error) {
return error?.(state.error, load, ignoreCallback);
}
return children(state.status === AsyncStatus.Success ? state.data : data ?? FALLBACK_DATA);
return children(
state.status === AsyncStatus.Success
? state.data
: {
versions: [],
}
);
}

View file

@ -1,6 +1,6 @@
import { ReactNode } from 'react';
import { UIAFlow } from 'matrix-js-sdk';
import { useSupportedUIAFlows } from '../hooks/useUIAFlows';
import { UIAFlow } from '../utils/matrix-uia';
export function SupportedUIAFlowsLoader({
flows,

View file

@ -4,15 +4,22 @@ import FocusTrap from 'focus-trap-react';
import { useCloseUserRoomProfile, useUserRoomProfileState } from '../state/hooks/userRoomProfile';
import { UserRoomProfile } from './user-profile';
import { UserRoomProfileState } from '../state/userRoomProfile';
import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom';
import { stopPropagation } from '../utils/keyboard';
import { SpaceProvider } from '../hooks/useSpace';
import { RoomProvider } from '../hooks/useRoom';
function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState }) {
const { roomId, spaceId, userId, cords, position } = state;
const allJoinedRooms = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allJoinedRooms);
const room = getRoom(roomId);
const space = spaceId ? getRoom(spaceId) : undefined;
const close = useCloseUserRoomProfile();
if (!room) return null;
return (
<PopOut
anchor={cords}
@ -28,8 +35,8 @@ function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState })
}}
>
<Menu style={{ width: toRem(340) }}>
<SpaceProvider value={spaceId ?? null}>
<RoomProvider value={roomId}>
<SpaceProvider value={space ?? null}>
<RoomProvider value={room}>
<UserRoomProfile userId={userId} />
</RoomProvider>
</SpaceProvider>

View file

@ -1,50 +0,0 @@
import React, { ReactNode } from 'react';
import { Box, Header, Scroll } from 'folds';
import classNames from 'classnames';
import * as css from './styles.css';
import * as PatternsCss from '../../styles/Patterns.css';
import CinnySVG from '../../../../public/res/svg/cinny.svg';
import { AuthFooter } from './AuthFooter';
export function AuthLogo({ large }: { large?: boolean }) {
return <img className={css.AuthLogo({ large })} src={CinnySVG} alt="Cinny Logo" />;
}
type AuthLayoutProps = {
center?: boolean;
children?: ReactNode;
header?: ReactNode;
};
export function AuthLayout({ center, header, children }: AuthLayoutProps) {
return (
<Scroll variant="Background" visibility="Hover" size="0" hideTrack>
<Box
className={classNames(css.AuthLayout, PatternsCss.BackgroundDotPattern)}
direction="Column"
alignItems="Center"
gap="400"
>
<Box
style={{ width: '100%' }}
justifyContent={center ? 'Center' : undefined}
alignItems="Center"
direction="Column"
>
<Box direction="Column" className={css.AuthCard}>
{header && (
<Header className={css.AuthHeader} size="600" variant="Surface">
{header}
</Header>
)}
<Box className={css.AuthCardContent} direction="Column">
{children}
</Box>
</Box>
</Box>
<AuthFooter />
</Box>
</Scroll>
);
}

View file

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

View file

@ -1,64 +0,0 @@
import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds';
export const AuthLayout = style({
minHeight: '100%',
backgroundColor: color.Background.Container,
color: color.Background.OnContainer,
padding: config.space.S400,
paddingBottom: 0,
position: 'relative',
});
export const AuthCard = style({
marginTop: '1vh',
maxWidth: toRem(460),
width: '100%',
backgroundColor: color.Surface.Container,
color: color.Surface.OnContainer,
borderRadius: config.radii.R500,
boxShadow: config.shadow.E100,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
overflow: 'hidden',
});
export const AuthLogo = recipe({
base: [
DefaultReset,
{
width: toRem(26),
height: toRem(26),
borderRadius: '50%',
},
],
variants: {
large: {
true: {
width: toRem(48),
height: toRem(48),
},
},
},
});
export const AuthHeader = style({
paddingLeft: config.space.S400,
paddingRight: config.space.S300,
borderBottomWidth: config.borderWidth.B300,
});
export const AuthCardContent = style({
maxWidth: toRem(402),
width: '100%',
margin: 'auto',
padding: config.space.S400,
paddingTop: config.space.S700,
paddingBottom: toRem(44),
gap: toRem(44),
});
export const AuthFooter = style({
padding: config.space.S200,
});

View file

@ -27,19 +27,19 @@ import React, {
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../utils/matrix';
import { useDirectUsers } from '../../hooks/useDirectUsers';
import { SettingTile } from '../setting-tile';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
import { highlightText, makeHighlightRegex } from '../../plugins/react-custom-html-parser';
import { useClient } from '../../hooks/useClient';
export const useAdditionalCreators = (defaultCreators?: string[]) => {
const ct = useClient();
const mx = useMatrixClient();
const [additionalCreators, setAdditionalCreators] = useState<string[]>(
() => defaultCreators?.filter((id) => id !== ct.session.userId) ?? []
() => defaultCreators?.filter((id) => id !== mx.getSafeUserId()) ?? []
);
const addAdditionalCreator = (userId: string) => {
if (userId === ct.session.userId) return;
if (userId === mx.getSafeUserId()) return;
setAdditionalCreators((creators) => {
const creatorsSet = new Set(creators);
@ -83,7 +83,7 @@ export function AdditionalCreatorInput({
onRemove,
disabled,
}: AdditionalCreatorInputProps) {
const ct = useClient();
const mx = useMatrixClient();
const [menuCords, setMenuCords] = useState<RectCords>();
const directUsers = useDirectUsers();
@ -156,7 +156,7 @@ export function AdditionalCreatorInput({
<Box shrink="No" direction="Column" gap="100">
<Box gap="200" wrap="Wrap">
<Chip type="button" variant="Primary" radii="Pill" outlined>
<Text size="B300">{ct.session.userId}</Text>
<Text size="B300">{mx.getSafeUserId()}</Text>
</Chip>
{additionalCreators.map((creator) => (
<Chip

View file

@ -6,18 +6,17 @@ import React, {
useRef,
useState,
} from 'react';
import { MatrixError } from 'matrix-js-sdk';
import { Box, color, Icon, Icons, Input, Spinner, Text, toRem } from 'folds';
import { isKeyHotkey } from 'is-hotkey';
import { parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request';
import { getRoom, GetRoomOK } from 'neurone/lib/api/matrix/client/v3/directory';
import { getMxIdServer } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { replaceSpaceWithDash } from '../../utils/common';
import { AsyncState, AsyncStatus, useAsync } from '../../hooks/useAsyncCallback';
import { useDebounce } from '../../hooks/useDebounce';
import { useClient } from '../../hooks/useClient';
export function CreateRoomAliasInput({ disabled }: { disabled?: boolean }) {
const ct = useClient();
const mx = useMatrixClient();
const aliasInputRef = useRef<HTMLInputElement>(null);
const [aliasAvail, setAliasAvail] = useState<AsyncState<boolean, Error>>({
status: AsyncStatus.Idle,
@ -32,20 +31,18 @@ export function CreateRoomAliasInput({ disabled }: { disabled?: boolean }) {
const checkAliasAvail = useAsync(
useCallback(
async (aliasLocalPart: string) => {
const roomAlias = `#${aliasLocalPart}:${getMxIdServer(ct.session.userId)}`;
const response = await ct.requestAuthJSON(
getRoom({
pathParams: { roomAlias },
})
);
const [err, result] = await parseMatrixResp<GetRoomOK>(response);
if (err) {
if (err.status === 404) return true;
throw new Error(printMatrixError(err));
const roomAlias = `#${aliasLocalPart}:${getMxIdServer(mx.getSafeUserId())}`;
try {
const result = await mx.getRoomIdForAlias(roomAlias);
return typeof result.room_id !== 'string';
} catch (e) {
if (e instanceof MatrixError && e.httpStatus === 404) {
return true;
}
throw e;
}
return typeof result.room_id !== 'string';
},
[ct]
[mx]
),
setAliasAvail
);
@ -97,7 +94,7 @@ export function CreateRoomAliasInput({ disabled }: { disabled?: boolean }) {
}
after={
<Text style={{ maxWidth: toRem(150) }} truncate>
:{getMxIdServer(ct.session.userId)}
:{getMxIdServer(mx.getSafeUserId())}
</Text>
}
onKeyDown={handleAliasKeyDown}

View file

@ -1,19 +1,18 @@
import {
CreateRoomStateEvent,
postCreateRoom,
PostCreateRoomBody,
PostCreateRoomOK,
} from 'neurone/lib/api/matrix/client/v3/createRoom';
import { MatrixResponseResult, parseMatrixResp } from 'neurone/lib/matrix-request';
import { putStateEvent } from 'neurone/lib/api/matrix/client/v3/rooms';
import { JoinRulesContent, RoomType, StateEvent, StateEventType } from '../../../types/matrix/room';
import { getMxIdServer } from '../../utils/matrix';
import { ClientContext } from '../../../client/context';
ICreateRoomOpts,
ICreateRoomStateEvent,
JoinRule,
MatrixClient,
RestrictedAllowType,
Room,
} from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import { RoomType, StateEvent } from '../../../types/matrix/room';
import { getViaServers } from '../../plugins/via-servers';
import { getJoinRoomStateId } from '../../../client/storage/collections';
import { getMxIdServer } from '../../utils/matrix';
import { CreateRoomAccess } from './types';
const createRoomCreationContent = (
export const createRoomCreationContent = (
type: RoomType | undefined,
allowFederation: boolean,
additionalCreators: string[] | undefined
@ -32,28 +31,28 @@ const createRoomCreationContent = (
return content;
};
const createRoomJoinRulesState = (
export const createRoomJoinRulesState = (
access: CreateRoomAccess,
parentId: string | undefined,
parent: Room | undefined,
knock: boolean
) => {
let content: JoinRulesContent = {
join_rule: knock ? 'knock' : 'invite',
let content: RoomJoinRulesEventContent = {
join_rule: knock ? JoinRule.Knock : JoinRule.Invite,
};
if (access === CreateRoomAccess.Public) {
content = {
join_rule: 'public',
join_rule: JoinRule.Public,
};
}
if (access === CreateRoomAccess.Restricted && parentId) {
if (access === CreateRoomAccess.Restricted && parent) {
content = {
join_rule: knock ? 'knock_restricted' : 'restricted',
join_rule: knock ? ('knock_restricted' as JoinRule) : JoinRule.Restricted,
allow: [
{
type: 'm.room_membership',
room_id: parentId,
type: RestrictedAllowType.RoomMembership,
room_id: parent.roomId,
},
],
};
@ -66,12 +65,12 @@ const createRoomJoinRulesState = (
};
};
const createRoomParentState = (parentId: string, via: string[]) => ({
export const createRoomParentState = (parent: Room) => ({
type: StateEvent.SpaceParent,
state_key: parentId,
state_key: parent.roomId,
content: {
canonical: true,
via,
via: getViaServers(parent),
},
});
@ -102,7 +101,7 @@ export const createVoiceRoomPowerLevelsOverride = () => ({
export type CreateRoomData = {
version: string;
type?: RoomType;
parentId?: string;
parent?: Room;
access: CreateRoomAccess;
name: string;
topic?: string;
@ -112,33 +111,24 @@ export type CreateRoomData = {
allowFederation: boolean;
additionalCreators?: string[];
};
export const createRoom = async (
ct: ClientContext,
data: CreateRoomData
): Promise<MatrixResponseResult<string>> => {
const initialState: CreateRoomStateEvent[] = [];
export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise<string> => {
const initialState: ICreateRoomStateEvent[] = [];
if (data.encryption) {
initialState.push(createRoomEncryptionState());
}
if (data.parentId) {
const creators = ct.roomToCreators.read.get(data.parentId);
const powerLevelsObj = await ct.storage.joinRoomState.read.get(
getJoinRoomStateId(data.parentId, StateEventType.RoomPowerLevels, '')
);
const powerLevelsContent = powerLevelsObj?.event.content;
const via = getViaServers(creators, powerLevelsContent);
initialState.push(createRoomParentState(data.parentId, via));
if (data.parent) {
initialState.push(createRoomParentState(data.parent));
}
if (data.type === RoomType.Call) {
initialState.push(createRoomCallState());
}
initialState.push(createRoomJoinRulesState(data.access, data.parentId, data.knock));
initialState.push(createRoomJoinRulesState(data.access, data.parent, data.knock));
const body: PostCreateRoomBody = {
const options: ICreateRoomOpts = {
room_version: data.version,
name: data.name,
topic: data.topic,
@ -154,34 +144,23 @@ export const createRoom = async (
};
if (data.type === RoomType.Space) {
body.power_level_content_override = createSpacePowerLevelsOverride();
options.power_level_content_override = createSpacePowerLevelsOverride();
}
const response = await ct.requestAuthJSON(postCreateRoom({ body }));
const result = await mx.createRoom(options);
const [err, result] = await parseMatrixResp<PostCreateRoomOK>(response);
if (err) {
return [err, undefined];
}
if (data.parentId) {
const resp = await ct.requestAuthJSON(
putStateEvent({
pathParams: {
roomId: data.parentId,
eventType: StateEventType.SpaceChild,
stateKey: result.room_id,
},
body: {
auto_join: false,
suggested: false,
via: [getMxIdServer(ct.session.userId) ?? ''],
},
})
if (data.parent) {
await mx.sendStateEvent(
data.parent.roomId,
StateEvent.SpaceChild as any,
{
auto_join: false,
suggested: false,
via: [getMxIdServer(mx.getUserId() ?? '') ?? ''],
},
result.room_id
);
await parseMatrixResp(resp);
}
return [undefined, result.room_id];
return result.room_id;
};

View file

@ -5,11 +5,10 @@ import React, {
ReactNode,
forwardRef,
useCallback,
useEffect,
useState,
} from 'react';
import { Box, Scroll, Text } from 'folds';
import { Descendant, Editor, Transforms, createEditor } from 'slate';
import { Descendant, Editor, createEditor } from 'slate';
import {
Slate,
Editable,
@ -21,9 +20,17 @@ import {
import { withHistory } from 'slate-history';
import { BlockType } from './types';
import { RenderElement, RenderLeaf } from './Elements';
import { CustomElement } from './slate';
import * as css from './Editor.css';
import { toggleKeyboardShortcut } from './keyboard';
const initialValue: CustomElement[] = [
{
type: BlockType.Paragraph,
children: [{ text: '' }],
},
];
const withInline = (editor: Editor): Editor => {
const { isInline } = editor;
@ -52,13 +59,13 @@ export const useEditor = (): Editor => {
export type EditorChangeHandler = (value: Descendant[]) => void;
type CustomEditorProps = {
editableName?: string;
top?: ReactNode;
bottom?: ReactNode;
before?: ReactNode;
after?: ReactNode;
maxHeight?: string;
editor: Editor;
initialValue?: Descendant[];
placeholder?: string;
onKeyDown?: KeyboardEventHandler;
onKeyUp?: KeyboardEventHandler;
@ -68,13 +75,13 @@ type CustomEditorProps = {
export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
(
{
editableName,
top,
bottom,
before,
after,
maxHeight = '50vh',
editor,
initialValue,
placeholder,
onKeyDown,
onKeyUp,
@ -83,19 +90,6 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
},
ref
) => {
const [defaultValue] = useState<Descendant[]>(() => [
{
type: BlockType.Paragraph,
children: [{ text: '' }],
},
]);
useEffect(() => {
if (!initialValue?.length) return;
Transforms.select(editor, Editor.end(editor, []));
}, [editor, initialValue]);
const renderElement = useCallback(
(props: RenderElementProps) => <RenderElement {...props} />,
[]
@ -126,7 +120,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
return (
<div className={css.Editor} ref={ref}>
<Slate editor={editor} initialValue={initialValue ?? defaultValue} onChange={onChange}>
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
{top}
<Box alignItems="Start">
{before && (
@ -143,6 +137,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
hideTrack
>
<Editable
data-editable-name={editableName}
className={css.EditorTextarea}
placeholder={placeholder}
renderPlaceholder={renderPlaceholder}

View file

@ -10,11 +10,11 @@ import {
import * as css from '../../styles/CustomHtml.css';
import { CommandElement, EmoticonElement, LinkElement, MentionElement } from './slate';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getBeginCommand } from './utils';
import { BlockType } from './types';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useClient } from '../../hooks/useClient';
import { mxcToHttp } from '../../../client/mxcToHttp';
// Put this at the start and end of an inline component to work around this Chromium bug:
// https://bugs.chromium.org/p/chromium/issues/detail?id=1249405
@ -77,7 +77,7 @@ function RenderEmoticonElement({
element,
children,
}: { element: EmoticonElement } & RenderElementProps) {
const ct = useClient();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const selected = useSelected();
const focused = useFocused();
@ -93,7 +93,7 @@ function RenderEmoticonElement({
{element.key.startsWith('mxc://') ? (
<img
className={css.EmoticonImg}
src={mxcToHttp(ct.session.baseUrl, element.key, useAuthentication).url() ?? element.key}
src={mxcUrlToHttp(mx, element.key, useAuthentication) ?? element.key}
alt={element.shortcode}
/>
) : (

View file

@ -34,6 +34,7 @@ import { HeadingLevel } from './slate';
import { isMacOS } from '../../utils/user-agent';
import { KeySymbol } from '../../utils/key-symbol';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { stopPropagation } from '../../utils/keyboard';
function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) {
@ -259,7 +260,7 @@ export function Toolbar() {
const canEscape = isBlockActive(editor, BlockType.Paragraph)
? isAnyMarkActive(editor)
: ReactEditor.isFocused(editor);
const [isMarkdown, setIsMarkdown] = useSetting('isMarkdown');
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
return (
<Box className={css.EditorToolbarBase}>

View file

@ -1,8 +1,11 @@
import React, { KeyboardEvent as ReactKeyboardEvent, useEffect, useMemo } from 'react';
import { Editor } from 'slate';
import { Box, MenuItem, Text, toRem } from 'folds';
import { Room } from 'matrix-js-sdk';
import { AutocompleteQuery } from './autocompleteQuery';
import { AutocompleteMenu } from './AutocompleteMenu';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
import { onTabPress } from '../../../utils/keyboard';
import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils';
@ -10,18 +13,17 @@ import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
import { IEmoji, emojis } from '../../../plugins/emoji';
import { useKeyDown } from '../../../hooks/useKeyDown';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ImageUsage, PackImageReader } from '../../../plugins/custom-emoji';
import { getEmoticonSearchStr } from '../../../plugins/utils';
import { mxcToHttp } from '../../../../client/mxcToHttp';
import { useClient } from '../../../hooks/useClient';
type EmoticonCompleteHandler = (key: string, shortcode: string) => void;
type EmoticonSearchItem = PackImageReader | IEmoji;
type EmoticonAutocompleteProps = {
imagePackRooms: string[];
imagePackRooms: Room[];
editor: Editor;
query: AutocompleteQuery<string>;
requestClose: () => void;
@ -39,11 +41,11 @@ export function EmoticonAutocomplete({
query,
requestClose,
}: EmoticonAutocompleteProps) {
const ct = useClient();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [imagePacks] = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms);
const recentEmojiResult = useRecentEmoji(20);
const imagePacks = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms);
const recentEmoji = useRecentEmoji(mx, 20);
const searchList = useMemo(() => {
const list: Array<EmoticonSearchItem> = [];
@ -58,9 +60,7 @@ export function EmoticonAutocomplete({
getEmoticonSearchStr,
SEARCH_OPTIONS
);
const autoCompleteEmoticon: EmoticonSearchItem[] = result
? result.items.slice(0, 20)
: recentEmojiResult.value ?? [];
const autoCompleteEmoticon = result ? result.items.slice(0, 20) : recentEmoji;
useEffect(() => {
if (query.text) search(query.text);
@ -88,6 +88,7 @@ export function EmoticonAutocomplete({
{autoCompleteEmoticon.map((emoticon) => {
const isCustomEmoji = 'url' in emoticon;
const key = isCustomEmoji ? emoticon.url : emoticon.unicode;
const customEmojiUrl = mxcUrlToHttp(mx, key, useAuthentication);
return (
<MenuItem
@ -99,11 +100,11 @@ export function EmoticonAutocomplete({
}
onClick={() => handleAutocomplete(key, emoticon.shortcode)}
before={
isCustomEmoji ? (
isCustomEmoji && customEmojiUrl ? (
<Box
shrink="No"
as="img"
src={mxcToHttp(ct.session.baseUrl, key, useAuthentication).url() || key}
src={customEmojiUrl}
alt={emoticon.shortcode}
style={{ width: toRem(24), height: toRem(24), objectFit: 'contain' }}
/>

View file

@ -1,34 +1,30 @@
import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react';
import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect } from 'react';
import { Editor } from 'slate';
import { Avatar, Icon, Icons, MenuItem, Text } from 'folds';
import { JoinRule, MatrixClient } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
import { getDirectRoomAvatarUrl } from '../../../utils/room';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AutocompleteQuery } from './autocompleteQuery';
import { AutocompleteMenu } from './AutocompleteMenu';
import { getMxIdServer, isRoomAlias, isRoomId } from '../../../utils/matrix';
import { getMxIdServer, isRoomAlias } from '../../../utils/matrix';
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
import { onTabPress } from '../../../utils/keyboard';
import { useKeyDown } from '../../../hooks/useKeyDown';
import { mDirectAtom } from '../../../state/mDirectList';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { factoryRoomIdByAtoZ } from '../../../utils/sort';
import { factoryRoomIdByActivity } from '../../../utils/sort';
import { RoomAvatar, RoomIcon } from '../../room-avatar';
import { getViaServers } from '../../../plugins/via-servers';
import { useClient } from '../../../hooks/useClient';
import { useRoomHeroes } from '../../../hooks/storage/useRoomHeros';
import { useGetRoomName } from '../../../hooks/storage/useGetRoomName';
import { useGetRoomAvatar } from '../../../hooks/storage/useGetRoomAvatar';
import { mxcToHttp } from '../../../../client/mxcToHttp';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useSetting } from '../../../state/hooks/settings';
type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void;
const roomAliasFromQueryText = (myUserId: string, text: string) =>
const roomAliasFromQueryText = (mx: MatrixClient, text: string) =>
isRoomAlias(`#${text}`)
? `#${text}`
: `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(myUserId)}`;
: `#${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
function UnknownRoomMentionItem({
query,
@ -37,8 +33,8 @@ function UnknownRoomMentionItem({
query: AutocompleteQuery<string>;
handleAutocomplete: MentionAutoCompleteHandler;
}) {
const ct = useClient();
const roomAlias: string = roomAliasFromQueryText(ct.session.userId, query.text);
const mx = useMatrixClient();
const roomAlias: string = roomAliasFromQueryText(mx, query.text);
const handleSelect = () => handleAutocomplete(roomAlias, roomAlias);
@ -80,31 +76,22 @@ export function RoomMentionAutocomplete({
query,
requestClose,
}: RoomMentionAutocompleteProps) {
const ct = useClient();
const useAuthentication = useMediaAuthentication();
const mx = useMatrixClient();
const mDirects = useAtomValue(mDirectAtom);
const rooms = useAtomValue(allRoomsAtom);
const [showAvatar] = useSetting('roomListShowAvatar');
const getRoomHeroes = useRoomHeroes();
const getRoomName = useGetRoomName(getRoomHeroes);
const getRoomAvatar = useGetRoomAvatar(getRoomHeroes);
const allRooms = useAtomValue(allRoomsAtom).sort(factoryRoomIdByActivity(mx));
const allRooms = useMemo(
() => Array.from(rooms).sort(factoryRoomIdByAtoZ(getRoomName)),
[rooms, getRoomName]
);
const [result, search, resetSearch] = useAsyncSearch(
allRooms,
useCallback(
(rId) => {
const name = getRoomName(rId);
if (!name) return 'Unknown Room';
const alias = ct.roomToAlias.read.get(rId);
if (alias) return [name, alias];
return name;
const r = mx.getRoom(rId);
if (!r) return 'Unknown Room';
const alias = r.getCanonicalAlias();
if (alias) return [r.name, alias];
return r.name;
},
[ct, getRoomName]
[mx]
),
SEARCH_OPTIONS
);
@ -117,14 +104,12 @@ export function RoomMentionAutocomplete({
}, [query.text, search, resetSearch]);
const handleAutocomplete: MentionAutoCompleteHandler = (roomAliasOrId, name) => {
const creators = isRoomId(roomAliasOrId)
? ct.roomToCreators.read.get(roomAliasOrId)
: undefined;
const viaServers = creators ? getViaServers(creators) : undefined;
const mentionRoom = mx.getRoom(roomAliasOrId);
const viaServers = mentionRoom ? getViaServers(mentionRoom) : undefined;
const mentionEl = createMentionElement(
roomAliasOrId,
name.startsWith('#') ? name : `#${name}`,
roomId === roomAliasOrId || ct.roomToAlias.read.get(roomId) === roomAliasOrId,
roomId === roomAliasOrId || mx.getRoom(roomId)?.getCanonicalAlias() === roomAliasOrId,
undefined,
viaServers
);
@ -136,13 +121,14 @@ export function RoomMentionAutocomplete({
useKeyDown(window, (evt: KeyboardEvent) => {
onTabPress(evt, () => {
if (autoCompleteRoomIds.length === 0) {
const alias = roomAliasFromQueryText(ct.session.userId, query.text);
const alias = roomAliasFromQueryText(mx, query.text);
handleAutocomplete(alias, alias);
return;
}
const rId = autoCompleteRoomIds[0];
const name = getRoomName(rId) ?? 'Unknown';
handleAutocomplete(ct.roomToAlias.read.get(rId) ?? rId, name);
const r = mx.getRoom(rId);
const name = r?.name ?? rId;
handleAutocomplete(r?.getCanonicalAlias() ?? rId, name);
});
});
@ -152,20 +138,11 @@ export function RoomMentionAutocomplete({
<UnknownRoomMentionItem query={query} handleAutocomplete={handleAutocomplete} />
) : (
autoCompleteRoomIds.map((rId) => {
const dm = mDirects.has(rId);
const isSpace = ct.spaceRooms.read.has(rId);
const name = getRoomName(rId) ?? 'Unknown';
const alias = ct.roomToAlias.read.get(rId);
const avatarMxc = getRoomAvatar(rId);
const avatar = avatarMxc
? mxcToHttp(ct.session.baseUrl, avatarMxc, useAuthentication).thumbnail({
width: 32,
height: 32,
method: 'crop',
})
: undefined;
const room = mx.getRoom(rId);
if (!room) return null;
const dm = mDirects.has(room.roomId);
const handleSelect = () => handleAutocomplete(alias ?? rId, name);
const handleSelect = () => handleAutocomplete(room.getCanonicalAlias() ?? rId, room.name);
return (
<MenuItem
@ -178,37 +155,33 @@ export function RoomMentionAutocomplete({
onClick={handleSelect}
after={
<Text size="T200" priority="300" truncate>
{alias}
{room.getCanonicalAlias() ?? ''}
</Text>
}
before={
<Avatar size="200" radii={isSpace ? '300' : '400'}>
{showAvatar || dm || isSpace ? (
<Avatar size="200">
{dm ? (
<RoomAvatar
roomId={rId}
src={avatar}
alt={name}
roomId={room.roomId}
src={getDirectRoomAvatarUrl(mx, room)}
alt={room.name}
renderFallback={() => (
<RoomIcon
size="50"
joinRule={ct.roomToJoinRule.read.get(rId)}
roomType={ct.roomToType.read.get(rId)}
joinRule={room.getJoinRule() ?? JoinRule.Restricted}
roomType={room.getType()}
filled
/>
)}
/>
) : (
<RoomIcon
size="100"
joinRule={ct.roomToJoinRule.read.get(rId)}
roomType={ct.roomToType.read.get(rId)}
/>
<RoomIcon size="100" joinRule={room.getJoinRule()} roomType={room.getType()} />
)}
</Avatar>
}
>
<Text style={{ flexGrow: 1 }} size="B400" truncate>
{name}
{room.name}
</Text>
</MenuItem>
);

View file

@ -1,29 +1,32 @@
import React, { useEffect, KeyboardEvent as ReactKeyboardEvent, useCallback } from 'react';
import React, { useEffect, KeyboardEvent as ReactKeyboardEvent } from 'react';
import { Editor } from 'slate';
import { Avatar, Icon, Icons, MenuItem, Text } from 'folds';
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { AutocompleteQuery } from './autocompleteQuery';
import { AutocompleteMenu } from './AutocompleteMenu';
import { GetRoomMemberMembershipCallback, useRoomMembersAPI } from '../../../hooks/useRoomMembers';
import { UseAsyncSearchOptions, useAsyncSearch } from '../../../hooks/useAsyncSearch';
import { useRoomMembers } from '../../../hooks/useRoomMembers';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import {
SearchItemStrGetter,
UseAsyncSearchOptions,
useAsyncSearch,
} from '../../../hooks/useAsyncSearch';
import { onTabPress } from '../../../utils/keyboard';
import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
import { useKeyDown } from '../../../hooks/useKeyDown';
import { getMxIdServer, isUserId } from '../../../utils/matrix';
import { getMemberSearchStr } from '../../../utils/room';
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix';
import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room';
import { UserAvatar } from '../../user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useClient } from '../../../hooks/useClient';
import { JoinRoomMemberObject } from '../../../../client/storage/collections';
import { mxcToHttp } from '../../../../client/mxcToHttp';
import { nameInitials } from '../../../utils/common';
import { Membership } from '../../../../types/matrix/room';
type MentionAutoCompleteHandler = (userId: string, name: string) => void;
const userIdFromQueryText = (myUserId: string, text: string) =>
const userIdFromQueryText = (mx: MatrixClient, text: string) =>
isUserId(`@${text}`)
? `@${text}`
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(myUserId)}`;
: `@${text}${text.endsWith(':') ? '' : ':'}${getMxIdServer(mx.getUserId() ?? '')}`;
function UnknownMentionItem({
userId,
@ -59,18 +62,16 @@ function UnknownMentionItem({
}
type UserMentionAutocompleteProps = {
roomId: string;
room: Room;
editor: Editor;
query: AutocompleteQuery<string>;
requestClose: () => void;
};
const withAllowedMembership =
(getMembership: GetRoomMemberMembershipCallback) =>
(member: JoinRoomMemberObject): boolean =>
getMembership(member.userId) === 'join' ||
getMembership(member.userId) === 'invite' ||
getMembership(member.userId) === 'knock';
const withAllowedMembership = (member: RoomMember): boolean =>
member.membership === Membership.Join ||
member.membership === Membership.Invite ||
member.membership === Membership.Knock;
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000,
@ -79,28 +80,25 @@ const SEARCH_OPTIONS: UseAsyncSearchOptions = {
},
};
const EMPTY_MEMBER_LIST: JoinRoomMemberObject[] = [];
const mxIdToName = (mxId: string) => getMxIdLocalPart(mxId) ?? mxId;
const getRoomMemberStr: SearchItemStrGetter<RoomMember> = (m, query) =>
getMemberSearchStr(m, query, mxIdToName);
export function UserMentionAutocomplete({
roomId,
room,
editor,
query,
requestClose,
}: UserMentionAutocompleteProps) {
const ct = useClient();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const roomAliasOrId = ct.roomToAlias.read.get(roomId) || roomId;
const membersAPI = useRoomMembersAPI();
const members = membersAPI.memberEventsResult.value ?? EMPTY_MEMBER_LIST;
const [result, search, resetSearch] = useAsyncSearch(
members,
useCallback((m, q) => getMemberSearchStr(m, q, membersAPI.getName), [membersAPI.getName]),
SEARCH_OPTIONS
);
const roomId: string = room.roomId!;
const roomAliasOrId = room.getCanonicalAlias() || roomId;
const members = useRoomMembers(mx, roomId);
const [result, search, resetSearch] = useAsyncSearch(members, getRoomMemberStr, SEARCH_OPTIONS);
const autoCompleteMembers = (result ? result.items.slice(0, 20) : members.slice(0, 20)).filter(
withAllowedMembership(membersAPI.getMembership)
withAllowedMembership
);
useEffect(() => {
@ -112,7 +110,7 @@ export function UserMentionAutocomplete({
const mentionEl = createMentionElement(
uId,
name.startsWith('@') ? name : `@${name}`,
uId === ct.session.userId || roomAliasOrId === uId
mx.getUserId() === uId || roomAliasOrId === uId
);
replaceWithElement(editor, query.range, mentionEl);
moveCursor(editor, true);
@ -126,15 +124,18 @@ export function UserMentionAutocomplete({
return;
}
if (autoCompleteMembers.length === 0) {
const userId = userIdFromQueryText(ct.session.userId, query.text);
const userId = userIdFromQueryText(mx, query.text);
handleAutocomplete(userId, userId);
return;
}
const roomMember = autoCompleteMembers[0];
handleAutocomplete(roomMember.userId, membersAPI.getName(roomMember.userId));
handleAutocomplete(roomMember.userId, roomMember.name);
});
});
const getName = (member: RoomMember) =>
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
return (
<AutocompleteMenu headerContent={<Text size="L400">Mentions</Text>} requestClose={requestClose}>
{query.text === 'room' && (
@ -146,20 +147,15 @@ export function UserMentionAutocomplete({
)}
{autoCompleteMembers.length === 0 ? (
<UnknownMentionItem
userId={userIdFromQueryText(ct.session.userId, query.text)}
name={userIdFromQueryText(ct.session.userId, query.text)}
userId={userIdFromQueryText(mx, query.text)}
name={userIdFromQueryText(mx, query.text)}
handleAutocomplete={handleAutocomplete}
/>
) : (
autoCompleteMembers.map((roomMember) => {
const name = membersAPI.getName(roomMember.userId);
const avatarMxcUrl = membersAPI.getAvatar(roomMember.userId);
const avatarMxcUrl = roomMember.getMxcAvatarUrl();
const avatarUrl = avatarMxcUrl
? mxcToHttp(ct.session.baseUrl, avatarMxcUrl, useAuthentication).thumbnail({
width: 32,
height: 32,
method: 'crop',
})
? mx.mxcUrlToHttp(avatarMxcUrl, 32, 32, 'crop', undefined, false, useAuthentication)
: undefined;
return (
<MenuItem
@ -167,9 +163,9 @@ export function UserMentionAutocomplete({
as="button"
radii="300"
onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
onTabPress(evt, () => handleAutocomplete(roomMember.userId, name))
onTabPress(evt, () => handleAutocomplete(roomMember.userId, getName(roomMember)))
}
onClick={() => handleAutocomplete(roomMember.userId, name)}
onClick={() => handleAutocomplete(roomMember.userId, getName(roomMember))}
after={
<Text size="T200" priority="300" truncate>
{roomMember.userId}
@ -180,18 +176,14 @@ export function UserMentionAutocomplete({
<UserAvatar
userId={roomMember.userId}
src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(name)}
</Text>
)}
alt={getName(roomMember)}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
}
>
<Text style={{ flexGrow: 1 }} size="B400" truncate>
{name}
{getName(roomMember)}
</Text>
</MenuItem>
);

View file

@ -1,4 +1,5 @@
import { Descendant, Editor, Text } from 'slate';
import { MatrixClient } from 'matrix-js-sdk';
import { sanitizeText } from '../../utils/sanitize';
import { BlockType } from './types';
import { CustomElement } from './slate';
@ -11,7 +12,6 @@ import {
import { findAndReplace } from '../../utils/findAndReplace';
import { sanitizeForRegex } from '../../utils/regex';
import { isUserId } from '../../utils/matrix';
import { ClientContext } from '../../../client/context';
export type OutputOptions = {
allowTextFormatting?: boolean;
@ -201,7 +201,7 @@ export type MentionsData = {
room: boolean;
users: Set<string>;
};
export const getMentions = (ct: ClientContext, editor: Editor): MentionsData => {
export const getMentions = (mx: MatrixClient, roomId: string, editor: Editor): MentionsData => {
const mentionData: MentionsData = {
room: false,
users: new Set(),
@ -215,7 +215,8 @@ export const getMentions = (ct: ClientContext, editor: Editor): MentionsData =>
if (node.name === '@room') {
mentionData.room = true;
}
if (isUserId(node.id) && node.id !== ct.session.userId) {
if (isUserId(node.id) && node.id !== mx.getUserId()) {
mentionData.users.add(node.id);
}

View file

@ -12,6 +12,7 @@ import React, {
import { Box, config, Icons, Scroll } from 'folds';
import FocusTrap from 'focus-trap-react';
import { isKeyHotkey } from 'is-hotkey';
import { Room } from 'matrix-js-sdk';
import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai';
import { useVirtualizer } from '@tanstack/react-virtual';
import { IEmoji, emojiGroups, emojis } from '../../plugins/emoji';
@ -19,8 +20,9 @@ import { useEmojiGroupLabels } from './useEmojiGroupLabels';
import { useEmojiGroupIcons } from './useEmojiGroupIcons';
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
import { isUserId } from '../../utils/matrix';
import { isUserId, mxcUrlToHttp } from '../../utils/matrix';
import { editableActiveElement, targetFromEvent } from '../../utils/dom';
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
import { useDebounce } from '../../hooks/useDebounce';
@ -50,10 +52,6 @@ import {
} from './components';
import { EmojiBoardTab, EmojiType } from './types';
import { VirtualTile } from '../virtualizer';
import { useClient } from '../../hooks/useClient';
import { useRoomHeroes } from '../../hooks/storage/useRoomHeros';
import { GetRoomNameCallback, useGetRoomName } from '../../hooks/storage/useGetRoomName';
import { mxcToHttp } from '../../../client/mxcToHttp';
const RECENT_GROUP_ID = 'recent_group';
const SEARCH_GROUP_ID = 'search_group';
@ -71,28 +69,26 @@ type StickerGroupItem = {
const useGroups = (
tab: EmojiBoardTab,
imagePacks: ImagePack[],
getRoomName: GetRoomNameCallback
imagePacks: ImagePack[]
): [EmojiGroupItem[], StickerGroupItem[]] => {
const recentEmojisResult = useRecentEmoji(21);
const mx = useMatrixClient();
const recentEmojis = useRecentEmoji(mx, 21);
const labels = useEmojiGroupLabels();
const emojiGroupItems = useMemo(() => {
const g: EmojiGroupItem[] = [];
if (tab !== EmojiBoardTab.Emoji) return g;
const recentEmojis = recentEmojisResult.value;
if (recentEmojis && recentEmojis.length > 0) {
g.push({
id: RECENT_GROUP_ID,
name: 'Recent',
items: recentEmojis,
});
}
g.push({
id: RECENT_GROUP_ID,
name: 'Recent',
items: recentEmojis,
});
imagePacks.forEach((pack) => {
let label = pack.meta.name;
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : getRoomName(pack.id);
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
g.push({
id: pack.id,
@ -112,7 +108,7 @@ const useGroups = (
});
return g;
}, [getRoomName, recentEmojisResult, labels, imagePacks, tab]);
}, [mx, recentEmojis, labels, imagePacks, tab]);
const stickerGroupItems = useMemo(() => {
const g: StickerGroupItem[] = [];
@ -120,7 +116,7 @@ const useGroups = (
imagePacks.forEach((pack) => {
let label = pack.meta.name;
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : getRoomName(pack.id);
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
g.push({
id: pack.id,
@ -132,24 +128,37 @@ const useGroups = (
});
return g;
}, [getRoomName, imagePacks, tab]);
}, [mx, imagePacks, tab]);
return [emojiGroupItems, stickerGroupItems];
};
const useItemRenderer = (tab: EmojiBoardTab) => {
const ct = useClient();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const renderItem = (emoji: IEmoji | PackImageReader, index: number) => {
if ('unicode' in emoji) {
return <EmojiItem key={emoji.unicode + index} emoji={emoji} />;
}
const url = mxcToHttp(ct.session.baseUrl, emoji.url, useAuthentication).url();
if (tab === EmojiBoardTab.Sticker) {
return <StickerItem key={emoji.shortcode + index} url={url} image={emoji} />;
return (
<StickerItem
key={emoji.shortcode + index}
mx={mx}
useAuthentication={useAuthentication}
image={emoji}
/>
);
}
return <CustomEmojiItem key={emoji.shortcode + index} url={url} image={emoji} />;
return (
<CustomEmojiItem
key={emoji.shortcode + index}
mx={mx}
useAuthentication={useAuthentication}
image={emoji}
/>
);
};
return renderItem;
@ -159,10 +168,9 @@ type EmojiSidebarProps = {
activeGroupAtom: PrimitiveAtom<string | undefined>;
packs: ImagePack[];
onScrollToGroup: (groupId: string) => void;
getRoomName: GetRoomNameCallback;
};
function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup, getRoomName }: EmojiSidebarProps) {
const ct = useClient();
function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom);
@ -191,12 +199,10 @@ function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup, getRoomName }:
<SidebarDivider />
{packs.map((pack) => {
let label = pack.meta.name;
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : getRoomName(pack.id);
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
const avatarMxc = pack.getAvatarUrl(usage);
const url = avatarMxc
? mxcToHttp(ct.session.baseUrl, avatarMxc, useAuthentication).url()
: undefined;
const url =
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined;
return (
<ImageGroupIcon
@ -238,15 +244,9 @@ type StickerSidebarProps = {
activeGroupAtom: PrimitiveAtom<string | undefined>;
packs: ImagePack[];
onScrollToGroup: (groupId: string) => void;
getRoomName: GetRoomNameCallback;
};
function StickerSidebar({
activeGroupAtom,
packs,
onScrollToGroup,
getRoomName,
}: StickerSidebarProps) {
const ct = useClient();
function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSidebarProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom);
@ -262,12 +262,10 @@ function StickerSidebar({
<SidebarStack>
{packs.map((pack) => {
let label = pack.meta.name;
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : getRoomName(pack.id);
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
const avatarMxc = pack.getAvatarUrl(usage);
const url = avatarMxc
? mxcToHttp(ct.session.baseUrl, avatarMxc, useAuthentication).url()
: undefined;
const url =
mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) ?? undefined;
return (
<ImageGroupIcon
@ -356,7 +354,7 @@ const VIRTUAL_OVER_SCAN = 2;
type EmojiBoardProps = {
tab?: EmojiBoardTab;
onTabChange?: (tab: EmojiBoardTab) => void;
imagePackRooms: string[];
imagePackRooms: Room[];
requestClose: () => void;
returnFocusOnDeactivate?: boolean;
onEmojiSelect?: (unicode: string, shortcode: string) => void;
@ -378,9 +376,7 @@ export function EmojiBoard({
allowTextCustomEmoji,
addToRecentEmoji = true,
}: EmojiBoardProps) {
const ct = useClient();
const getRoomHeroes = useRoomHeroes();
const getRoomName = useGetRoomName(getRoomHeroes);
const mx = useMatrixClient();
const emojiTab = tab === EmojiBoardTab.Emoji;
const usage = emojiTab ? ImageUsage.Emoticon : ImageUsage.Sticker;
@ -391,8 +387,8 @@ export function EmojiBoard({
);
const activeGroupIdAtom = useMemo(() => atom<string | undefined>(undefined), []);
const setActiveGroupId = useSetAtom(activeGroupIdAtom);
const [imagePacks, loading] = useRelevantImagePacks(usage, imagePackRooms);
const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks, getRoomName);
const imagePacks = useRelevantImagePacks(usage, imagePackRooms);
const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks);
const groups = emojiTab ? emojiGroupItems : stickerGroupItems;
const renderItem = useItemRenderer(tab);
@ -441,7 +437,7 @@ export function EmojiBoard({
if (emojiInfo.type === EmojiType.Emoji) {
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
if (!evt.altKey && !evt.shiftKey && addToRecentEmoji) {
addRecentEmoji(ct, emojiInfo.data);
addRecentEmoji(mx, emojiInfo.data);
}
}
if (emojiInfo.type === EmojiType.CustomEmoji) {
@ -525,61 +521,57 @@ export function EmojiBoard({
activeGroupAtom={activeGroupIdAtom}
packs={imagePacks}
onScrollToGroup={handleScrollToGroup}
getRoomName={getRoomName}
/>
) : (
<StickerSidebar
activeGroupAtom={activeGroupIdAtom}
packs={imagePacks}
onScrollToGroup={handleScrollToGroup}
getRoomName={getRoomName}
/>
)
}
>
<Box grow="Yes">
{!loading && (
<EmojiGroupHolder
key={tab}
contentScrollRef={contentScrollRef}
previewAtom={previewAtom}
onGroupItemClick={handleGroupItemClick}
>
{searchedItems && (
<EmojiGroup
id={SEARCH_GROUP_ID}
label={searchedItems.length ? 'Search Results' : 'No Results found'}
>
{searchedItems.map(renderItem)}
</EmojiGroup>
)}
<div
ref={virtualBaseRef}
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
<EmojiGroupHolder
key={tab}
contentScrollRef={contentScrollRef}
previewAtom={previewAtom}
onGroupItemClick={handleGroupItemClick}
>
{searchedItems && (
<EmojiGroup
id={SEARCH_GROUP_ID}
label={searchedItems.length ? 'Search Results' : 'No Results found'}
>
{vItems.map((vItem) => {
const group = groups[vItem.index];
{searchedItems.map(renderItem)}
</EmojiGroup>
)}
<div
ref={virtualBaseRef}
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{vItems.map((vItem) => {
const group = groups[vItem.index];
return (
<VirtualTile
virtualItem={vItem}
style={{ paddingTop: config.space.S200 }}
ref={virtualizer.measureElement}
key={vItem.index}
>
<EmojiGroup key={group.id} id={group.id} label={group.name}>
{group.items.map(renderItem)}
</EmojiGroup>
</VirtualTile>
);
})}
</div>
{tab === EmojiBoardTab.Sticker && groups.length === 0 && <NoStickerPacks />}
</EmojiGroupHolder>
)}
return (
<VirtualTile
virtualItem={vItem}
style={{ paddingTop: config.space.S200 }}
ref={virtualizer.measureElement}
key={vItem.index}
>
<EmojiGroup key={group.id} id={group.id} label={group.name}>
{group.items.map(renderItem)}
</EmojiGroup>
</VirtualTile>
);
})}
</div>
{tab === EmojiBoardTab.Sticker && groups.length === 0 && <NoStickerPacks />}
</EmojiGroupHolder>
</Box>
<Preview previewAtom={previewAtom} />
</EmojiBoardLayout>

View file

@ -1,9 +1,11 @@
import React from 'react';
import { Box } from 'folds';
import { MatrixClient } from 'matrix-js-sdk';
import { EmojiItemInfo, EmojiType } from '../types';
import * as css from './styles.css';
import { PackImageReader } from '../../../plugins/custom-emoji';
import { IEmoji } from '../../../plugins/emoji';
import { mxcUrlToHttp } from '../../../utils/matrix';
export const getEmojiItemInfo = (element: Element): EmojiItemInfo | undefined => {
const label = element.getAttribute('title');
@ -44,10 +46,11 @@ export function EmojiItem({ emoji }: EmojiItemProps) {
}
type CustomEmojiItemProps = {
url?: string;
mx: MatrixClient;
useAuthentication?: boolean;
image: PackImageReader;
};
export function CustomEmojiItem({ url, image }: CustomEmojiItemProps) {
export function CustomEmojiItem({ mx, useAuthentication, image }: CustomEmojiItemProps) {
return (
<Box
as="button"
@ -65,18 +68,19 @@ export function CustomEmojiItem({ url, image }: CustomEmojiItemProps) {
loading="lazy"
className={css.CustomEmojiImg}
alt={image.body || image.shortcode}
src={url ?? image.url}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? ''}
/>
</Box>
);
}
type StickerItemProps = {
url?: string;
mx: MatrixClient;
useAuthentication?: boolean;
image: PackImageReader;
};
export function StickerItem({ url, image }: StickerItemProps) {
export function StickerItem({ mx, useAuthentication, image }: StickerItemProps) {
return (
<Box
as="button"
@ -94,7 +98,7 @@ export function StickerItem({ url, image }: StickerItemProps) {
loading="lazy"
className={css.StickerImg}
alt={image.body || image.shortcode}
src={url ?? image.url}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? ''}
/>
</Box>
);

View file

@ -2,9 +2,9 @@ import { Box, Text } from 'folds';
import React from 'react';
import { Atom, atom, useAtomValue } from 'jotai';
import * as css from './styles.css';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useClient } from '../../../hooks/useClient';
import { mxcToHttp } from '../../../../client/mxcToHttp';
import { mxcUrlToHttp } from '../../../utils/matrix';
export type PreviewData = {
key: string;
@ -18,7 +18,7 @@ type PreviewProps = {
previewAtom: Atom<PreviewData | undefined>;
};
export function Preview({ previewAtom }: PreviewProps) {
const ct = useClient();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const { key, shortcode } = useAtomValue(previewAtom) ?? {};
@ -37,7 +37,7 @@ export function Preview({ previewAtom }: PreviewProps) {
{key.startsWith('mxc://') ? (
<img
className={css.PreviewImg}
src={mxcToHttp(ct.session.baseUrl, key, useAuthentication).url() ?? key}
src={mxcUrlToHttp(mx, key, useAuthentication) ?? key}
alt={shortcode}
/>
) : (

View file

@ -1,117 +0,0 @@
import React, { ReactNode, useMemo } from 'react';
import { as, Box, color, config, Icon, Icons, Text } from 'folds';
import { MessageEventWithoutRoomId } from 'neurone/lib/api/types';
import {
DecryptionManager,
EventDecryptionIdle,
EventDecryptionPending,
EventDecryptionSuccess,
} from '../../../client/decryption';
import { useObservableValue } from '../../hooks/useObservableValue';
export const MessageDecryptionIdle = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" {...props} ref={ref}>
<Icon size="50" src={Icons.ShieldLock} />
<i>Decrypt message event to view.</i>
</Box>
));
export const MessageDecryptionPending = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" {...props} ref={ref}>
<Icon size="50" src={Icons.Clock} />
<i>Waiting for message to be decrypted...</i>
</Box>
));
export const MessageDecryptionError = as<'div', { message: string; children?: never }>(
({ message, ...props }, ref) => (
<Box
as="span"
alignItems="Center"
gap="100"
style={{ color: color.Critical.Main, opacity: config.opacity.P300 }}
{...props}
ref={ref}
>
<Icon size="50" src={Icons.Warning} />
<i>{message}</i>
</Box>
)
);
type EncryptedMessageEventProps = {
decryptionManager: DecryptionManager;
roomId: string;
event: MessageEventWithoutRoomId;
reply?: boolean;
children: (decryptedEvent: MessageEventWithoutRoomId) => ReactNode;
};
export function EncryptedMessageEvent({
decryptionManager,
roomId,
event,
reply,
children,
}: EncryptedMessageEventProps) {
// Memoize the result because it can return new EncryptedEvent
// on every call if event content is missing sessionId.
// It can cause infinite re-renders in pair with `decryption`
const encryptedEvent = useMemo(
() => decryptionManager.decryptableMessageEvent(roomId, event),
[roomId, event, decryptionManager]
);
if (encryptedEvent.decryption.read instanceof EventDecryptionIdle) {
encryptedEvent.decrypt();
}
const decryption = useObservableValue(encryptedEvent.decryption);
if (decryption instanceof EventDecryptionIdle) {
return (
<Text size={reply ? 'T300' : 'T400'} truncate={reply}>
<MessageDecryptionIdle />
</Text>
);
}
if (decryption instanceof EventDecryptionPending) {
return (
<Text size={reply ? 'T300' : 'T400'} truncate={reply}>
<MessageDecryptionPending />
</Text>
);
}
if (decryption instanceof EventDecryptionSuccess) {
return children(decryption.event);
}
const { cause, error } = decryption;
if (cause) {
return (
<Text size={reply ? 'T300' : 'T400'} truncate={reply}>
<MessageDecryptionError
message={
cause instanceof Error
? cause.message
: 'Unknown error occurs while decrypting message.'
}
/>
</Text>
);
}
if (!error) {
return (
<Text>
<MessageDecryptionError message="Unknown error occurs while decrypting message." />
</Text>
);
}
return (
<Text>
<MessageDecryptionError message={error.description} />
</Text>
);
}

View file

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

View file

@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React from 'react';
import classNames from 'classnames';
import {
Avatar,
@ -13,41 +13,33 @@ import {
as,
config,
} from 'folds';
import { Room } from 'matrix-js-sdk';
import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import * as css from './EventReaders.css';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { UserAvatar } from '../user-avatar';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { getMouseEventCords } from '../../utils/dom';
import { useClient } from '../../hooks/useClient';
import { useObservableValuePromise } from '../../hooks/useObservableValue';
import { useRoomMembersAPI } from '../../hooks/useRoomMembers';
import { mxcToHttp } from '../../../client/mxcToHttp';
import { useRoom } from '../../hooks/useRoom';
import { Time } from '../message';
import { useSettings } from '../../state/hooks/settings';
export type EventReadersProps = {
room: Room;
eventId: string;
requestClose: () => void;
};
export const EventReaders = as<'div', EventReadersProps>(
({ className, eventId, requestClose, ...props }, ref) => {
const ct = useClient();
const roomId = useRoom();
const membersAPI = useRoomMembersAPI();
const { hour24Clock, dateFormatString } = useSettings();
const eventReceipt = useObservableValuePromise(
useMemo(
() => ct.storage.readReceipt.observableRead.queryIndex('eventId', eventId),
[ct, eventId]
)
);
({ className, room, eventId, requestClose, ...props }, ref) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const latestEventReaders = useRoomEventReaders(room, eventId);
const openProfile = useOpenUserRoomProfile();
const spaceId = useSpaceOptionally();
const space = useSpaceOptionally();
const getName = (userId: string) =>
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
return (
<Box
@ -67,58 +59,52 @@ export const EventReaders = as<'div', EventReadersProps>(
<Box grow="Yes">
<Scroll visibility="Hover" hideTrack size="300">
<Box className={css.Content} direction="Column">
{Array.from(eventReceipt.value ?? [])
.sort((a, b) => b.ts - a.ts)
?.map((receipt) => {
const { userId, ts } = receipt;
const name = membersAPI.getName(userId);
const avatarMxcUrl = membersAPI.getAvatar(userId);
const avatarUrl = avatarMxcUrl
? mxcToHttp(ct.session.baseUrl, avatarMxcUrl, useAuthentication).thumbnail({
width: 96,
height: 96,
method: 'crop',
})
: undefined;
{latestEventReaders.map((readerId) => {
const name = getName(readerId);
const avatarMxcUrl = room.getMember(readerId)?.getMxcAvatarUrl();
const avatarUrl = avatarMxcUrl
? mx.mxcUrlToHttp(
avatarMxcUrl,
100,
100,
'crop',
undefined,
false,
useAuthentication
)
: undefined;
return (
<MenuItem
key={`${userId}${ts}`}
style={{ padding: `0 ${config.space.S200}` }}
radii="400"
onClick={(event) => {
openProfile(
roomId,
spaceId,
userId,
getMouseEventCords(event.nativeEvent),
'Bottom'
);
}}
before={
<Avatar size="200">
<UserAvatar
userId={userId}
src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
}
after={
<Time
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
ts={ts}
return (
<MenuItem
key={readerId}
style={{ padding: `0 ${config.space.S200}` }}
radii="400"
onClick={(event) => {
openProfile(
room.roomId,
space?.roomId,
readerId,
getMouseEventCords(event.nativeEvent),
'Bottom'
);
}}
before={
<Avatar size="200">
<UserAvatar
userId={readerId}
src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
}
>
<Text style={{ flexGrow: 1 }} size="T400" truncate>
{name}
</Text>
</MenuItem>
);
})}
</Avatar>
}
>
<Text size="T400" truncate>
{name}
</Text>
</MenuItem>
);
})}
</Box>
</Scroll>
</Box>

View file

@ -19,7 +19,7 @@ import * as css from './style.css';
import { useFilePicker } from '../../hooks/useFilePicker';
import { CompactUploadCardRenderer } from '../upload-card';
import { UploadSuccess } from '../../state/upload';
import { getImageInfo, UploadContent } from '../../utils/matrix';
import { getImageInfo, TUploadContent } from '../../utils/matrix';
import { getImageFileUrl, loadImageElement, renameFile } from '../../utils/dom';
import { replaceSpaceWithDash, suffixRename } from '../../utils/common';
import { getFileNameWithoutExt } from '../../utils/mimeTypes';
@ -110,7 +110,7 @@ export const ImagePackContent = as<'div', ImagePackContentProps>(
[imagePack.meta]
);
const handleUploadRemove = useCallback((file: UploadContent) => {
const handleUploadRemove = useCallback((file: TUploadContent) => {
setFiles((fs) => fs.filter((f) => f !== file));
}, []);

View file

@ -2,16 +2,17 @@ import React from 'react';
import { Box, IconButton, Text, Icon, Icons, Scroll, Chip } from 'folds';
import { PackAddress } from '../../plugins/custom-emoji';
import { Page, PageHeader, PageContent } from '../page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { RoomImagePack } from './RoomImagePack';
import { UserImagePack } from './UserImagePack';
import { useClient } from '../../hooks/useClient';
type ImagePackViewProps = {
address: PackAddress | undefined;
requestClose: () => void;
};
export function ImagePackView({ address, requestClose }: ImagePackViewProps) {
const ct = useClient();
const mx = useMatrixClient();
const room = address && mx.getRoom(address.roomId);
return (
<Page>
@ -37,8 +38,8 @@ export function ImagePackView({ address, requestClose }: ImagePackViewProps) {
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
{address && ct.rooms.join.read.includes(address.roomId) ? (
<RoomImagePack roomId={address.roomId} stateKey={address.stateKey} />
{room && address ? (
<RoomImagePack room={room} stateKey={address.stateKey} />
) : (
<UserImagePack />
)}

View file

@ -1,14 +1,14 @@
import React, { FormEventHandler, ReactNode, useMemo, useState } from 'react';
import { Badge, Box, Button, Chip, Icon, Icons, Input, Text } from 'folds';
import { UsageSwitcher, useUsageStr } from './UsageSwitcher';
import { mxcUrlToHttp } from '../../utils/matrix';
import * as css from './style.css';
import { ImageUsage, imageUsageEqual, PackImageReader } from '../../plugins/custom-emoji';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { SettingTile } from '../setting-tile';
import { useObjectURL } from '../../hooks/useObjectURL';
import { createUploadAtom, UploadAtom } from '../../state/upload';
import { createUploadAtom, TUploadAtom } from '../../state/upload';
import { replaceSpaceWithDash } from '../../utils/common';
import { useClient } from '../../hooks/useClient';
import { mxcToHttp } from '../../../client/mxcToHttp';
type ImageTileProps = {
defaultShortcode: string;
@ -30,7 +30,7 @@ export function ImageTile({
onDeleteToggle,
deleted,
}: ImageTileProps) {
const ct = useClient();
const mx = useMatrixClient();
const getUsageStr = useUsageStr();
return (
@ -38,7 +38,7 @@ export function ImageTile({
before={
<img
className={css.ImagePackImage}
src={mxcToHttp(ct.session.baseUrl, image.url, useAuthentication).url() ?? ''}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? ''}
alt={image.shortcode}
loading="lazy"
/>
@ -91,7 +91,7 @@ export function ImageTile({
type ImageTileUploadProps = {
file: File;
children: (uploadAtom: UploadAtom) => ReactNode;
children: (uploadAtom: TUploadAtom) => ReactNode;
};
export function ImageTileUpload({ file, children }: ImageTileUploadProps) {
const url = useObjectURL(file);
@ -120,7 +120,7 @@ export function ImageTileEdit({
onCancel,
onSave,
}: ImageTileEditProps) {
const ct = useClient();
const mx = useMatrixClient();
const defaultUsage = image.usage ?? packUsage;
const [unsavedUsage, setUnsavedUsages] = useState(defaultUsage);
@ -162,7 +162,7 @@ export function ImageTileEdit({
before={
<img
className={css.ImagePackImage}
src={mxcToHttp(ct.session.baseUrl, image.url, useAuthentication).url() ?? ''}
src={mxcUrlToHttp(mx, image.url, useAuthentication) ?? ''}
alt={image.shortcode}
loading="lazy"
/>

View file

@ -13,6 +13,8 @@ import {
Chip,
} from 'folds';
import Linkify from 'linkify-react';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { nameInitials } from '../../utils/common';
import { BreakWord } from '../../styles/Text.css';
import { LINKIFY_OPTS } from '../../plugins/react-custom-html-parser';
@ -23,8 +25,6 @@ import { createUploadAtom, UploadSuccess } from '../../state/upload';
import { CompactUploadCardRenderer } from '../upload-card';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { PackMetaReader } from '../../plugins/custom-emoji';
import { useClient } from '../../hooks/useClient';
import { mxcToHttp } from '../../../client/mxcToHttp';
type ImagePackAvatarProps = {
url?: string;
@ -50,10 +50,10 @@ type ImagePackProfileProps = {
onEdit?: () => void;
};
export function ImagePackProfile({ meta, canEdit, onEdit }: ImagePackProfileProps) {
const ct = useClient();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const avatarUrl = meta.avatar
? mxcToHttp(ct.session.baseUrl, meta.avatar, useAuthentication).url()
? mxcUrlToHttp(mx, meta.avatar, useAuthentication) ?? undefined
: undefined;
return (
@ -97,13 +97,11 @@ type ImagePackProfileEditProps = {
onSave: (meta: PackMetaReader) => void;
};
export function ImagePackProfileEdit({ meta, onCancel, onSave }: ImagePackProfileEditProps) {
const ct = useClient();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [avatar, setAvatar] = useState(meta.avatar);
const avatarUrl = avatar
? mxcToHttp(ct.session.baseUrl, avatar, useAuthentication).url()
: undefined;
const avatarUrl = avatar ? mxcUrlToHttp(mx, avatar, useAuthentication) ?? undefined : undefined;
const [imageFile, setImageFile] = useState<File>();
const avatarFileUrl = useObjectURL(imageFile);

View file

@ -1,30 +1,28 @@
import React, { useCallback, useMemo } from 'react';
import { putStateEvent } from 'neurone/lib/api/matrix/client/v3/rooms';
import { parseMatrixResp } from 'neurone/lib/matrix-request';
import { usePowerLevels, usePowerLevelsState } from '../../hooks/usePowerLevels';
import { Room } from 'matrix-js-sdk';
import { usePowerLevels } from '../../hooks/usePowerLevels';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { ImagePackContent } from './ImagePackContent';
import { ImagePack, PackContent } from '../../plugins/custom-emoji';
import { StateEventType } from '../../../types/matrix/room';
import { StateEvent } from '../../../types/matrix/room';
import { useRoomImagePack } from '../../hooks/useImagePacks';
import { randomStr } from '../../utils/common';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useClient } from '../../hooks/useClient';
type RoomImagePackProps = {
roomId: string;
room: Room;
stateKey: string;
};
export function RoomImagePack({ roomId, stateKey }: RoomImagePackProps) {
const ct = useClient();
const { userId } = ct.session;
const powerLevelsStateResult = usePowerLevelsState(roomId);
const powerLevels = usePowerLevels(powerLevelsStateResult.value);
const creators = useRoomCreators(roomId);
export function RoomImagePack({ room, stateKey }: RoomImagePackProps) {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canEditImagePack = permissions.stateEvent(StateEventType.PoniesRoomEmotes, userId);
const canEditImagePack = permissions.stateEvent(StateEvent.PoniesRoomEmotes, userId);
const fallbackPack = useMemo(() => {
const fakePackId = randomStr(4);
@ -32,33 +30,26 @@ export function RoomImagePack({ roomId, stateKey }: RoomImagePackProps) {
fakePackId,
{},
{
roomId,
roomId: room.roomId,
stateKey,
}
);
}, [roomId, stateKey]);
const imagePackResult = useRoomImagePack(roomId, stateKey);
const imagePack = imagePackResult.value ?? fallbackPack;
}, [room.roomId, stateKey]);
const imagePack = useRoomImagePack(room, stateKey) ?? fallbackPack;
const handleUpdate = useCallback(
async (packContent: PackContent) => {
const { address } = imagePack;
if (!address) return;
const resp = await ct.requestAuthJSON(
putStateEvent({
pathParams: {
roomId: address.roomId,
eventType: StateEventType.PoniesRoomEmotes,
stateKey: address.stateKey,
},
body: packContent,
})
await mx.sendStateEvent(
address.roomId,
StateEvent.PoniesRoomEmotes,
packContent,
address.stateKey
);
const [err] = await parseMatrixResp(resp);
if (err) throw new Error(err.data.error ?? err.data.errcode);
},
[ct, imagePack]
[mx, imagePack]
);
return (

View file

@ -1,32 +1,22 @@
import React, { useCallback, useMemo } from 'react';
import { putAccountData } from 'neurone/lib/api/matrix/client/v3/user';
import { parseMatrixResp } from 'neurone/lib/matrix-request';
import { ImagePackContent } from './ImagePackContent';
import { ImagePack, PackContent } from '../../plugins/custom-emoji';
import { AccountDataType } from '../../../types/matrix/accountData';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AccountDataEvent } from '../../../types/matrix/accountData';
import { useUserImagePack } from '../../hooks/useImagePacks';
import { useClient } from '../../hooks/useClient';
export function UserImagePack() {
const ct = useClient();
const mx = useMatrixClient();
const defaultPack = useMemo(() => new ImagePack(ct.session.userId ?? '', {}, undefined), [ct]);
const imagePackResult = useUserImagePack();
const imagePack = imagePackResult.value ?? defaultPack;
const defaultPack = useMemo(() => new ImagePack(mx.getUserId() ?? '', {}, undefined), [mx]);
const imagePack = useUserImagePack();
const handleUpdate = useCallback(
async (packContent: PackContent) => {
const resp = await ct.requestAuthJSON(
putAccountData({
pathParams: { type: AccountDataType.PoniesUserEmotes, userId: ct.session.userId },
body: packContent,
})
);
const [err] = await parseMatrixResp(resp);
if (err) throw new Error(err.data.error ?? err.data.errcode);
await mx.setAccountData(AccountDataEvent.PoniesUserEmotes, packContent);
},
[ct]
[mx]
);
return <ImagePackContent imagePack={imagePack} canEdit onUpdate={handleUpdate} />;
return <ImagePackContent imagePack={imagePack ?? defaultPack} canEdit onUpdate={handleUpdate} />;
}

View file

@ -3,6 +3,7 @@ import React, {
FormEventHandler,
KeyboardEventHandler,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
@ -28,19 +29,19 @@ import {
Scroll,
MenuItem,
} from 'folds';
import { Room } from 'matrix-js-sdk';
import { isKeyHotkey } from 'is-hotkey';
import FocusTrap from 'focus-trap-react';
import { postInvite } from 'neurone/lib/api/matrix/client/v3/rooms';
import { parseMatrixResp } from 'neurone/lib/matrix-request';
import { stopPropagation } from '../../utils/keyboard';
import { useDirectUsers } from '../../hooks/useDirectUsers';
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../utils/matrix';
import { Membership } from '../../../types/matrix/room';
import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch';
import { highlightText, makeHighlightRegex } from '../../plugins/react-custom-html-parser';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { BreakWord } from '../../styles/Text.css';
import { useAlive } from '../../hooks/useAlive';
import { useClient } from '../../hooks/useClient';
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000,
@ -51,19 +52,27 @@ const SEARCH_OPTIONS: UseAsyncSearchOptions = {
const getUserIdString = (userId: string) => getMxIdLocalPart(userId) ?? userId;
type InviteUserProps = {
roomId: string;
room: Room;
requestClose: () => void;
};
export function InviteUserPrompt({ roomId, requestClose }: InviteUserProps) {
const ct = useClient();
export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
const mx = useMatrixClient();
const alive = useAlive();
const inputRef = useRef<HTMLInputElement>(null);
const directUsers = useDirectUsers();
const [validUserId, setValidUserId] = useState<string>();
const filteredUsers = useMemo(
() =>
directUsers.filter((userId) => {
const membership = room.getMember(userId)?.membership;
return membership !== Membership.Join;
}),
[directUsers, room]
);
const [result, search, resetSearch] = useAsyncSearch(
directUsers,
filteredUsers,
getUserIdString,
SEARCH_OPTIONS
);
@ -74,19 +83,9 @@ export function InviteUserPrompt({ roomId, requestClose }: InviteUserProps) {
const [inviteState, invite] = useAsyncCallback<void, Error, [string, string | undefined]>(
useCallback(
async (userId, reason) => {
const resp = await ct.requestAuthJSON(
postInvite({
pathParams: { roomId },
body: {
user_id: userId,
reason,
},
})
);
const [err] = await parseMatrixResp(resp);
if (err) throw new Error(err.data.error ?? err.data.errcode);
await mx.invite(room.roomId, userId, reason);
},
[ct, roomId]
[mx, room]
)
);

View file

@ -1,4 +1,4 @@
import React, { useCallback } from 'react';
import React, { useCallback, useEffect } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Dialog,
@ -16,13 +16,10 @@ import {
Button,
Spinner,
} from 'folds';
import { postLeave } from 'neurone/lib/api/matrix/client/v3/rooms';
import { parseMatrixResp } from 'neurone/lib/matrix-request';
import { MatrixError } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { stopPropagation } from '../../utils/keyboard';
import { useClient } from '../../hooks/useClient';
import { deleteJoinRooms } from '../../../client/syncWriter/utils';
import { useAlive } from '../../hooks/useAlive';
type LeaveRoomPromptProps = {
roomId: string;
@ -30,26 +27,24 @@ type LeaveRoomPromptProps = {
onCancel: () => void;
};
export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptProps) {
const ct = useClient();
const alive = useAlive();
const mx = useMatrixClient();
const [leaveState, leaveRoom] = useAsyncCallback<void, Error, []>(
const [leaveState, leaveRoom] = useAsyncCallback<undefined, MatrixError, []>(
useCallback(async () => {
const response = await ct.requestAuthJSON(postLeave({ pathParams: { roomId } }));
deleteJoinRooms(ct, [roomId]);
const [err] = await parseMatrixResp(response);
if (err) throw new Error(err.data.error ?? err.data.errcode);
}, [ct, roomId])
mx.leave(roomId);
}, [mx, roomId])
);
const handleLeave = () => {
leaveRoom().then(() => {
if (alive()) {
onDone();
}
});
leaveRoom();
};
useEffect(() => {
if (leaveState.status === AsyncStatus.Success) {
onDone();
}
}, [leaveState, onDone]);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>

View file

@ -1,4 +1,4 @@
import React, { useCallback } from 'react';
import React, { useCallback, useEffect } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Dialog,
@ -16,13 +16,10 @@ import {
Button,
Spinner,
} from 'folds';
import { postLeave } from 'neurone/lib/api/matrix/client/v3/rooms';
import { parseMatrixResp } from 'neurone/lib/matrix-request';
import { MatrixError } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { stopPropagation } from '../../utils/keyboard';
import { useClient } from '../../hooks/useClient';
import { deleteJoinRooms } from '../../../client/syncWriter/utils';
import { useAlive } from '../../hooks/useAlive';
type LeaveSpacePromptProps = {
roomId: string;
@ -30,26 +27,24 @@ type LeaveSpacePromptProps = {
onCancel: () => void;
};
export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptProps) {
const ct = useClient();
const alive = useAlive();
const mx = useMatrixClient();
const [leaveState, leaveRoom] = useAsyncCallback<void, Error, []>(
const [leaveState, leaveRoom] = useAsyncCallback<undefined, MatrixError, []>(
useCallback(async () => {
const response = await ct.requestAuthJSON(postLeave({ pathParams: { roomId } }));
deleteJoinRooms(ct, [roomId]);
const [err] = await parseMatrixResp(response);
if (err) throw new Error(err.data.error ?? err.data.errcode);
}, [ct, roomId])
mx.leave(roomId);
}, [mx, roomId])
);
const handleLeave = () => {
leaveRoom().then(() => {
if (alive()) {
onDone();
}
});
leaveRoom();
};
useEffect(() => {
if (leaveState.status === AsyncStatus.Success) {
onDone();
}
}, [leaveState, onDone]);
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>

View file

@ -1,73 +1,48 @@
import React, { ReactNode } from 'react';
import { as, Avatar, Box, Icon, Icons, Text } from 'folds';
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import { UserAvatar } from '../user-avatar';
import * as css from './style.css';
import { ClientContext } from '../../../client/context';
import { JoinRoomMemberObject } from '../../../client/storage/collections';
import { GetRoomMemberNameCallback, GetRoomMemberAvatarCallback } from '../../hooks/useRoomMembers';
import { useUserPresence } from '../../hooks/useUserPresence';
import { mxcToHttp } from '../../../client/mxcToHttp';
import { getMxIdLocalPart } from '../../utils/matrix';
import { PresenceBadge, AvatarPresence } from '../presence';
const getName = (room: Room, member: RoomMember) =>
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
type MemberTileProps = {
ct: ClientContext;
member: JoinRoomMemberObject;
getName: GetRoomMemberNameCallback;
getAvatar: GetRoomMemberAvatarCallback;
mx: MatrixClient;
room: Room;
member: RoomMember;
useAuthentication: boolean;
after?: ReactNode;
};
export const MemberTile = as<'button', MemberTileProps>(
(
{
as: AsMemberTile = 'button',
ct,
member,
getName,
getAvatar,
useAuthentication,
after,
...props
},
ref
) => {
const presence = useUserPresence(member.userId);
({ as: AsMemberTile = 'button', mx, room, member, useAuthentication, after, ...props }, ref) => {
const name = getName(room, member);
const username = getMxIdLocalPart(member.userId);
const name = getName(member.userId);
const avatarMxcUrl = getAvatar(member.userId);
const avatarMxcUrl = member.getMxcAvatarUrl();
const avatarUrl = avatarMxcUrl
? mxcToHttp(ct.session.baseUrl, avatarMxcUrl, useAuthentication).thumbnail({
width: 96,
height: 96,
method: 'crop',
})
? mx.mxcUrlToHttp(avatarMxcUrl, 100, 100, 'crop', undefined, false, useAuthentication)
: undefined;
return (
<AsMemberTile className={css.MemberTile} {...props} ref={ref}>
<AvatarPresence
variant="Surface"
badge={presence && <PresenceBadge size="300" presence={presence.presence} />}
>
<Avatar size="300" radii="400">
<UserAvatar
userId={member.userId}
src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => <Icon size="300" src={Icons.User} filled />}
/>
</Avatar>
</AvatarPresence>
<Avatar size="300" radii="400">
<UserAvatar
userId={member.userId}
src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => <Icon size="300" src={Icons.User} filled />}
/>
</Avatar>
<Box grow="Yes" as="span" direction="Column">
<Text as="span" size="T300" truncate>
<b>{name}</b>
</Text>
<Box alignItems="Center" justifyContent="SpaceBetween" gap="100">
<Text as="span" size="T200" priority="300" truncate>
@{username}
{username}
</Text>
</Box>
</Box>

View file

@ -3,11 +3,15 @@ import React, { ReactNode, useCallback } from 'react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import FileSaver from 'file-saver';
import { mimeTypeToExt } from '../../utils/mimeTypes';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { decryptFile, downloadEncryptedMedia, downloadMedia } from '../../utils/matrix';
import { useClient } from '../../hooks/useClient';
import { mxcToHttp } from '../../../client/mxcToHttp';
import {
decryptFile,
downloadEncryptedMedia,
downloadMedia,
mxcUrlToHttp,
} from '../../utils/matrix';
const badgeStyles = { maxWidth: toRem(100) };
@ -15,25 +19,24 @@ type FileDownloadButtonProps = {
filename: string;
url: string;
mimeType: string;
encInfo?: object;
encInfo?: EncryptedAttachmentInfo;
};
export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
const ct = useClient();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [downloadState, download] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcToHttp(ct.session.baseUrl, url, useAuthentication).url();
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType, encInfo as EncryptedAttachmentInfo)
)
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl);
const fileURL = URL.createObjectURL(fileContent);
FileSaver.saveAs(fileURL, filename);
return fileURL;
}, [ct, url, useAuthentication, mimeType, encInfo, filename])
}, [mx, url, useAuthentication, mimeType, encInfo, filename])
);
const downloading = downloadState.status === AsyncStatus.Loading;

View file

@ -1,131 +1,88 @@
import React, { CSSProperties } from 'react';
import React, { CSSProperties, ReactNode } from 'react';
import { Box, Chip, Icon, Icons, Text, toRem } from 'folds';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs';
import { IContent } from 'matrix-js-sdk';
import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex';
import { trimReplyFromBody } from '../../utils/room';
import { MessageTextBody } from './layout';
import {
AudioContent,
FileContent,
ImageContent,
MessageBadEncryptedContent,
MessageBrokenContent,
MessageDeletedContent,
MessageEditedContent,
UnsupportedMessageEventType,
MessageUnsupportedContent,
VideoContent,
MessageEmptyContent,
} from './content';
import {
IAudioContent,
IAudioInfo,
IEncryptedFile,
IFileContent,
IFileInfo,
IImageContent,
IImageInfo,
IThumbnailContent,
IVideoContent,
IVideoInfo,
MATRIX_SPOILER_PROPERTY_NAME,
MATRIX_SPOILER_REASON_PROPERTY_NAME,
} from '../../../types/matrix/common';
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
import { parseGeoUri, scaleYDimension } from '../../utils/common';
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
import { FileHeader, FileDownloadButton } from './FileHeader';
import { RenderBody } from './RenderBody';
import { rContent, rEncryptedFile, rInfo } from '../../../client/reader';
import { UrlPreviewCard, UrlPreviewHolder } from '../url-preview';
import { testMatrixTo } from '../../plugins/matrix-to';
type TextProps = {
size?: 'T400' | 'T300';
};
export function MBadEncrypted({ size }: TextProps) {
export function MBadEncrypted() {
return (
<Text size={size}>
<Text>
<MessageBadEncryptedContent />
</Text>
);
}
type RedactedContentProps = TextProps & {
type RedactedContentProps = {
reason?: string;
};
export function RedactedContent({ size, reason }: RedactedContentProps) {
export function RedactedContent({ reason }: RedactedContentProps) {
return (
<Text size={size}>
<Text>
<MessageDeletedContent reason={reason} />
</Text>
);
}
export function UnsupportedContent({ size, eventType }: TextProps & { eventType?: string }) {
export function UnsupportedContent() {
return (
<Text size={size}>
{eventType ? (
<UnsupportedMessageEventType eventType={eventType} />
) : (
<MessageUnsupportedContent />
)}
<Text>
<MessageUnsupportedContent />
</Text>
);
}
export function BrokenContent({ size }: TextProps) {
export function BrokenContent() {
return (
<Text size={size}>
<Text>
<MessageBrokenContent />
</Text>
);
}
export function EmptyContent({ size }: TextProps) {
return (
<Text size={size}>
<MessageEmptyContent />
</Text>
);
}
type UrlPreviewProps = {
type RenderBodyProps = {
body: string;
ts: number;
customBody?: string;
};
function UrlPreview({ body, ts }: UrlPreviewProps) {
const urlsMatch = body.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
if (!urls) return null;
const filteredUrls = urls.filter((url) => !testMatrixTo(url));
if (filteredUrls.length === 0) return null;
return (
<UrlPreviewHolder>
{filteredUrls.map((url) => (
<UrlPreviewCard key={url} url={url} ts={ts} />
))}
</UrlPreviewHolder>
);
}
type MTextProps = {
edited?: boolean;
content: object;
urlPreview?: boolean;
ts: number;
content: Record<string, unknown>;
renderBody: (props: RenderBodyProps) => ReactNode;
renderUrlsPreview?: (urls: string[]) => ReactNode;
style?: CSSProperties;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
};
export function MText({
edited,
content,
urlPreview,
ts,
style,
highlightRegex,
linkifyOpts,
htmlReactParserOptions,
}: MTextProps) {
const body = rContent.body(content);
const customBody = rContent.formattedBody(content);
const trimmedBody = trimReplyFromBody(body ?? '');
export function MText({ edited, content, renderBody, renderUrlsPreview, style }: MTextProps) {
const { body, formatted_body: customBody } = content;
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
return (
<>
@ -134,16 +91,13 @@ export function MText({
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
style={style}
>
<RenderBody
body={trimmedBody}
customBody={customBody}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})}
{edited && <MessageEditedContent />}
</MessageTextBody>
{urlPreview && <UrlPreview body={trimmedBody} ts={ts} />}
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
</>
);
}
@ -151,28 +105,23 @@ export function MText({
type MEmoteProps = {
displayName: string;
edited?: boolean;
content: object;
urlPreview?: boolean;
ts: number;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
content: Record<string, unknown>;
renderBody: (props: RenderBodyProps) => ReactNode;
renderUrlsPreview?: (urls: string[]) => ReactNode;
};
export function MEmote({
displayName,
edited,
content,
urlPreview,
ts,
highlightRegex,
linkifyOpts,
htmlReactParserOptions,
renderBody,
renderUrlsPreview,
}: MEmoteProps) {
const body = rContent.body(content);
const customBody = rContent.formattedBody(content);
const { body, formatted_body: customBody } = content;
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
return (
<>
@ -182,43 +131,30 @@ export function MEmote({
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
>
<b>{`${displayName} `}</b>
<RenderBody
body={trimmedBody}
customBody={customBody}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})}
{edited && <MessageEditedContent />}
</MessageTextBody>
{urlPreview && <UrlPreview body={trimmedBody} ts={ts} />}
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
</>
);
}
type MNoticeProps = {
edited?: boolean;
content: object;
urlPreview?: boolean;
ts: number;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
content: Record<string, unknown>;
renderBody: (props: RenderBodyProps) => ReactNode;
renderUrlsPreview?: (urls: string[]) => ReactNode;
};
export function MNotice({
edited,
content,
urlPreview,
ts,
highlightRegex,
linkifyOpts,
htmlReactParserOptions,
}: MNoticeProps) {
const body = rContent.body(content);
const customBody = rContent.formattedBody(content);
export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNoticeProps) {
const { body, formatted_body: customBody } = content;
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
return (
<>
@ -227,34 +163,39 @@ export function MNotice({
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
>
<RenderBody
body={trimmedBody}
customBody={customBody}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})}
{edited && <MessageEditedContent />}
</MessageTextBody>
{urlPreview && <UrlPreview body={trimmedBody} ts={ts} />}
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
</>
);
}
type RenderImageContentProps = {
body: string;
filename?: string;
info?: IImageInfo & IThumbnailContent;
mimeType?: string;
url: string;
encInfo?: IEncryptedFile;
markedAsSpoiler?: boolean;
spoilerReason?: string;
};
type MImageProps = {
content: object;
mediaAutoLoad?: boolean;
content: IImageContent;
renderImageContent: (props: RenderImageContentProps) => ReactNode;
outlined?: boolean;
};
export function MImage({ content, mediaAutoLoad, outlined }: MImageProps) {
const info = rContent.info(content);
const mxcUrl = rEncryptedFile.url(rContent.file(content)) ?? rContent.url(content);
export function MImage({ content, renderImageContent, outlined }: MImageProps) {
const imgInfo = content?.info;
const mxcUrl = content.file?.url ?? content.url;
if (typeof mxcUrl !== 'string') {
return <BrokenContent />;
}
const height = scaleYDimension(rInfo.w(info) || 400, 400, rInfo.h(info) || 400);
const height = scaleYDimension(imgInfo?.w || 400, 400, imgInfo?.h || 400);
return (
<Attachment outlined={outlined}>
@ -263,154 +204,178 @@ export function MImage({ content, mediaAutoLoad, outlined }: MImageProps) {
height: toRem(height < 48 ? 48 : height),
}}
>
<ImageContent
body={rContent.body(content) ?? 'Image'}
info={info}
mimeType={rInfo.mimetype(info)}
url={mxcUrl}
encInfo={rContent.file(content)}
autoPlay={mediaAutoLoad}
markedAsSpoiler={rContent.spoiler(content)}
spoilerReason={rContent.spoilerReason(content)}
/>
{renderImageContent({
body: content.body || 'Image',
info: imgInfo,
mimeType: imgInfo?.mimetype,
url: mxcUrl,
encInfo: content.file,
markedAsSpoiler: content[MATRIX_SPOILER_PROPERTY_NAME],
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
})}
</AttachmentBox>
</Attachment>
);
}
type MFileProps = {
content: object;
type RenderVideoContentProps = {
body: string;
info: IVideoInfo & IThumbnailContent;
mimeType: string;
url: string;
encInfo?: IEncryptedFile;
markedAsSpoiler?: boolean;
spoilerReason?: string;
};
type MVideoProps = {
content: IVideoContent;
renderAsFile: () => ReactNode;
renderVideoContent: (props: RenderVideoContentProps) => ReactNode;
outlined?: boolean;
};
export function MFile({ content, outlined }: MFileProps) {
const fileInfo = rContent.info(content);
const mxcUrl = rEncryptedFile.url(rContent.file(content)) ?? rContent.url(content);
export function MVideo({ content, renderAsFile, renderVideoContent, outlined }: MVideoProps) {
const videoInfo = content?.info;
const mxcUrl = content.file?.url ?? content.url;
const safeMimeType = getBlobSafeMimeType(videoInfo?.mimetype ?? '');
if (typeof mxcUrl !== 'string') {
if (!videoInfo || !safeMimeType.startsWith('video') || typeof mxcUrl !== 'string') {
if (mxcUrl) {
return renderAsFile();
}
return <BrokenContent />;
}
const body = rContent.filename(content) ?? rContent.body(content) ?? 'Unnamed File';
const mimeType = rInfo.mimetype(fileInfo) ?? FALLBACK_MIMETYPE;
const height = scaleYDimension(videoInfo.w || 400, 400, videoInfo.h || 400);
const filename = content.filename ?? content.body ?? 'Video';
return (
<Attachment outlined={outlined}>
<AttachmentHeader>
<FileHeader body={body} mimeType={mimeType} />
<FileHeader
body={filename}
mimeType={safeMimeType}
after={
<FileDownloadButton
filename={filename}
url={mxcUrl}
mimeType={safeMimeType}
encInfo={content.file}
/>
}
/>
</AttachmentHeader>
<AttachmentBox
style={{
height: toRem(height < 48 ? 48 : height),
}}
>
{renderVideoContent({
body: content.body || 'Video',
info: videoInfo,
mimeType: safeMimeType,
url: mxcUrl,
encInfo: content.file,
markedAsSpoiler: content[MATRIX_SPOILER_PROPERTY_NAME],
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
})}
</AttachmentBox>
</Attachment>
);
}
type RenderAudioContentProps = {
info: IAudioInfo;
mimeType: string;
url: string;
encInfo?: IEncryptedFile;
};
type MAudioProps = {
content: IAudioContent;
renderAsFile: () => ReactNode;
renderAudioContent: (props: RenderAudioContentProps) => ReactNode;
outlined?: boolean;
};
export function MAudio({ content, renderAsFile, renderAudioContent, outlined }: MAudioProps) {
const audioInfo = content?.info;
const mxcUrl = content.file?.url ?? content.url;
const safeMimeType = getBlobSafeMimeType(audioInfo?.mimetype ?? '');
if (!audioInfo || !safeMimeType.startsWith('audio') || typeof mxcUrl !== 'string') {
if (mxcUrl) {
return renderAsFile();
}
return <BrokenContent />;
}
const filename = content.filename ?? content.body ?? 'Audio';
return (
<Attachment outlined={outlined}>
<AttachmentHeader>
<FileHeader
body={filename}
mimeType={safeMimeType}
after={
<FileDownloadButton
filename={filename}
url={mxcUrl}
mimeType={safeMimeType}
encInfo={content.file}
/>
}
/>
</AttachmentHeader>
<AttachmentBox>
<AttachmentContent>
<FileContent
body={body}
mimeType={mimeType}
info={fileInfo ?? {}}
url={mxcUrl}
encInfo={rContent.file(content)}
/>
{renderAudioContent({
info: audioInfo,
mimeType: safeMimeType,
url: mxcUrl,
encInfo: content.file,
})}
</AttachmentContent>
</AttachmentBox>
</Attachment>
);
}
type MVideoProps = {
content: object;
mediaAutoLoad?: boolean;
type RenderFileContentProps = {
body: string;
info: IFileInfo & IThumbnailContent;
mimeType: string;
url: string;
encInfo?: IEncryptedFile;
};
type MFileProps = {
content: IFileContent;
renderFileContent: (props: RenderFileContentProps) => ReactNode;
outlined?: boolean;
};
export function MVideo({ content, mediaAutoLoad, outlined }: MVideoProps) {
const videoInfo = rContent.info(content);
const mxcUrl = rEncryptedFile.url(rContent.file(content)) ?? rContent.url(content);
const safeMimeType = getBlobSafeMimeType(rInfo.mimetype(videoInfo) ?? '');
export function MFile({ content, renderFileContent, outlined }: MFileProps) {
const fileInfo = content?.info;
const mxcUrl = content.file?.url ?? content.url;
if (!videoInfo || !safeMimeType.startsWith('video') || typeof mxcUrl !== 'string') {
if (mxcUrl) {
return <MFile content={content} outlined={outlined} />;
}
if (typeof mxcUrl !== 'string') {
return <BrokenContent />;
}
const height = scaleYDimension(rInfo.w(videoInfo) || 400, 400, rInfo.h(videoInfo) || 400);
const filename = rContent.filename(content) ?? rContent.body(content) ?? 'Video';
return (
<Attachment outlined={outlined}>
<AttachmentHeader>
<FileHeader
body={filename}
mimeType={safeMimeType}
after={
<FileDownloadButton
filename={filename}
url={mxcUrl}
mimeType={safeMimeType}
encInfo={rContent.file(content)}
/>
}
/>
</AttachmentHeader>
<AttachmentBox
style={{
height: toRem(height < 48 ? 48 : height),
}}
>
<VideoContent
body={rContent.body(content) || 'Video'}
info={videoInfo}
mimeType={safeMimeType}
url={mxcUrl}
encInfo={rContent.file(content)}
mediaAutoLoad={mediaAutoLoad}
markedAsSpoiler={rContent.spoiler(content)}
spoilerReason={rContent.spoilerReason(content)}
/>
</AttachmentBox>
</Attachment>
);
}
type MAudioProps = {
content: object;
outlined?: boolean;
};
export function MAudio({ content, outlined }: MAudioProps) {
const audioInfo = rContent.info(content);
const mxcUrl = rEncryptedFile.url(rContent.file(content)) ?? rContent.url(content);
const safeMimeType = getBlobSafeMimeType(rInfo.mimetype(audioInfo) ?? '');
if (!audioInfo || !safeMimeType.startsWith('audio') || typeof mxcUrl !== 'string') {
if (mxcUrl) {
return <MFile content={content} outlined={outlined} />;
}
return <BrokenContent />;
}
const filename = rContent.filename(content) ?? rContent.body(content) ?? 'Audio';
return (
<Attachment outlined={outlined}>
<AttachmentHeader>
<FileHeader
body={filename}
mimeType={safeMimeType}
after={
<FileDownloadButton
filename={filename}
url={mxcUrl}
mimeType={safeMimeType}
encInfo={rContent.file(content)}
/>
}
body={content.filename ?? content.body ?? 'Unnamed File'}
mimeType={fileInfo?.mimetype ?? FALLBACK_MIMETYPE}
/>
</AttachmentHeader>
<AttachmentBox>
<AttachmentContent>
<AudioContent
info={audioInfo}
mimeType={safeMimeType}
url={mxcUrl}
encInfo={rContent.file(content)}
/>
{renderFileContent({
body: content.filename ?? content.body ?? 'File',
info: fileInfo ?? {},
mimeType: fileInfo?.mimetype ?? FALLBACK_MIMETYPE,
url: mxcUrl,
encInfo: content.file,
})}
</AttachmentContent>
</AttachmentBox>
</Attachment>
@ -418,11 +383,11 @@ export function MAudio({ content, outlined }: MAudioProps) {
}
type MLocationProps = {
content: object;
content: IContent;
};
export function MLocation({ content }: MLocationProps) {
const geoUri = rContent.geoUri(content);
if (!geoUri) return <BrokenContent />;
const geoUri = content.geo_uri;
if (typeof geoUri !== 'string') return <BrokenContent />;
const location = parseGeoUri(geoUri);
if (!location) return <BrokenContent />;
@ -446,18 +411,16 @@ export function MLocation({ content }: MLocationProps) {
}
type MStickerProps = {
content: object;
mediaAutoLoad?: boolean;
outlineAttachment?: boolean;
content: IImageContent;
renderImageContent: (props: RenderImageContentProps) => ReactNode;
};
export function MSticker({ content, mediaAutoLoad, outlineAttachment }: MStickerProps) {
const imgInfo = rContent.info(content);
const mxcUrl = rEncryptedFile.url(rContent.file(content)) ?? rContent.url(content);
export function MSticker({ content, renderImageContent }: MStickerProps) {
const imgInfo = content?.info;
const mxcUrl = content.file?.url ?? content.url;
if (typeof mxcUrl !== 'string') {
return <MessageBrokenContent />;
}
const height = scaleYDimension(rInfo.w(imgInfo) || 152, 152, rInfo.h(imgInfo) || 152);
const height = scaleYDimension(imgInfo?.w || 152, 152, imgInfo?.h || 152);
return (
<AttachmentBox
@ -466,7 +429,13 @@ export function MSticker({ content, mediaAutoLoad, outlineAttachment }: MSticker
width: toRem(152),
}}
>
<MImage content={content} mediaAutoLoad={mediaAutoLoad} outlined={outlineAttachment} />
{renderImageContent({
body: content.body || 'Sticker',
info: imgInfo,
mimeType: imgInfo?.mimetype,
url: mxcUrl,
encInfo: content.file,
})}
</AttachmentBox>
);
}

View file

@ -1,130 +0,0 @@
import React, { CSSProperties } from 'react';
import { Text } from 'folds';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs';
import { trimReplyFromBody } from '../../utils/room';
import { MessageTextBody } from './layout';
import { MessageEditedContent } from './content';
import { RenderBody } from './RenderBody';
import { rContent } from '../../../client/reader';
import { parseGeoUri } from '../../utils/common';
import { BrokenContent } from './MsgTypeRenderers';
type ReplyMTextProps = {
edited?: boolean;
content: object;
style?: CSSProperties;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
};
export function ReplyMText({
edited,
content,
style,
highlightRegex,
linkifyOpts,
htmlReactParserOptions,
}: ReplyMTextProps) {
const body = rContent.body(content);
const customBody = rContent.formattedBody(content);
const trimmedBody = trimReplyFromBody(body ?? '');
if (typeof body !== 'string') return <BrokenContent />;
return (
<MessageTextBody size="T300" style={style} truncate>
<RenderBody
body={trimmedBody}
customBody={customBody}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
{edited && <MessageEditedContent />}
</MessageTextBody>
);
}
type ReplyMEmoteProps = {
displayName: string;
edited?: boolean;
content: object;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
};
export function ReplyMEmote({
displayName,
edited,
content,
highlightRegex,
linkifyOpts,
htmlReactParserOptions,
}: ReplyMEmoteProps) {
const body = rContent.body(content);
const customBody = rContent.formattedBody(content);
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
return (
<MessageTextBody size="T300" emote preWrap={typeof customBody !== 'string'} truncate>
<b>{`${displayName} `}</b>
<RenderBody
body={trimmedBody}
customBody={customBody}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
{edited && <MessageEditedContent />}
</MessageTextBody>
);
}
type ReplyMNoticeProps = {
edited?: boolean;
content: object;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
};
export function ReplyMNotice({
edited,
content,
highlightRegex,
linkifyOpts,
htmlReactParserOptions,
}: ReplyMNoticeProps) {
const body = rContent.body(content);
const customBody = rContent.formattedBody(content);
if (typeof body !== 'string') return <BrokenContent />;
const trimmedBody = trimReplyFromBody(body);
return (
<MessageTextBody size="T300" notice preWrap={typeof customBody !== 'string'} truncate>
<RenderBody
body={trimmedBody}
customBody={customBody}
highlightRegex={highlightRegex}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
{edited && <MessageEditedContent />}
</MessageTextBody>
);
}
type ReplyMLocationProps = {
content: object;
};
export function ReplyMLocation({ content }: ReplyMLocationProps) {
const geoUri = rContent.geoUri(content);
if (!geoUri) return <BrokenContent />;
const location = parseGeoUri(geoUri);
if (!location) return <BrokenContent />;
return <Text size="T300">{geoUri}</Text>;
}

View file

@ -1,4 +1,4 @@
import { createVar, keyframes, style } from '@vanilla-extract/css';
import { createVar, style } from '@vanilla-extract/css';
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
const Container = createVar();
@ -48,7 +48,6 @@ export const Reaction = style([
},
'&[aria-disabled=true], &:disabled': {
cursor: 'not-allowed',
backgroundColor: `${Container} !important`,
},
},
},
@ -65,36 +64,6 @@ export const ReactionText = style([
},
]);
const scaleAnimation = keyframes({
'0%': {
transform: 'scale(1)',
},
'50%': {
transform: 'scale(1.2)',
},
'100%': {
transform: 'scale(1)',
},
});
export const ReactionChangeAnimation = style({
animation: `${scaleAnimation} 700ms ease-in-out infinite`,
});
const dropAnimation = keyframes({
'0%': {
transform: 'translateY(0)',
},
'50%': {
transform: 'translateY(0.1em)',
},
'100%': {
transform: 'translateY(0)',
},
});
export const ReactionDropAnimation = style({
animation: `${dropAnimation} 700ms ease-in-out infinite`,
});
export const ReactionImg = style([
DefaultReset,
{

View file

@ -1,69 +1,68 @@
import React from 'react';
import { Box, Text, as } from 'folds';
import classNames from 'classnames';
import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
import * as css from './Reaction.css';
import { getHexcodeForEmoji, getShortcodeFor } from '../../plugins/emoji';
import { useRoomMembersAPI } from '../../hooks/useRoomMembers';
import { mxcToHttp } from '../../../client/mxcToHttp';
import { useClient } from '../../hooks/useClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { getMemberDisplayName } from '../../utils/room';
import { eventWithShortcode, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
export const Reaction = as<
'button',
{
mx: MatrixClient;
count: number;
adding?: boolean;
removing?: boolean;
reaction: string;
useAuthentication?: boolean;
}
>(({ className, count, adding, removing, reaction, ...props }, ref) => {
const ct = useClient();
const useAuthentication = useMediaAuthentication();
return (
<Box
as="button"
className={classNames(css.Reaction, className)}
alignItems="Center"
shrink="No"
gap="200"
{...props}
ref={ref}
>
<Text
className={classNames(css.ReactionText, { [css.ReactionChangeAnimation]: adding })}
as="span"
size="T400"
>
{reaction.startsWith('mxc://') ? (
<img
className={css.ReactionImg}
src={mxcToHttp(ct.session.baseUrl, reaction, useAuthentication).url() ?? reaction}
alt={reaction}
/>
) : (
<Text as="span" size="Inherit" truncate>
{reaction}
</Text>
)}
</Text>
<Text className={classNames({ [css.ReactionDropAnimation]: removing })} as="span" size="T300">
{count}
</Text>
</Box>
);
});
>(({ className, mx, count, reaction, useAuthentication, ...props }, ref) => (
<Box
as="button"
className={classNames(css.Reaction, className)}
alignItems="Center"
shrink="No"
gap="200"
{...props}
ref={ref}
>
<Text className={css.ReactionText} as="span" size="T400">
{reaction.startsWith('mxc://') ? (
<img
className={css.ReactionImg}
src={mxcUrlToHttp(mx, reaction, useAuthentication) ?? reaction
}
alt={reaction}
/>
) : (
<Text as="span" size="Inherit" truncate>
{reaction}
</Text>
)}
</Text>
<Text as="span" size="T300">
{count}
</Text>
</Box>
));
type ReactionTooltipMsgProps = {
room: Room;
reaction: string;
shortcode?: string;
users: string[];
events: MatrixEvent[];
};
export function ReactionTooltipMsg({ reaction, shortcode, users }: ReactionTooltipMsgProps) {
const memberAPI = useRoomMembersAPI();
const label = shortcode ?? getShortcodeFor(getHexcodeForEmoji(reaction)) ?? reaction;
const names = users.map((userId) => memberAPI.getName(userId));
export function ReactionTooltipMsg({ room, reaction, events }: ReactionTooltipMsgProps) {
const shortCodeEvt = events.find(eventWithShortcode);
const shortcode =
shortCodeEvt?.getContent().shortcode ??
getShortcodeFor(getHexcodeForEmoji(reaction)) ??
reaction;
const names = events.map(
(ev: MatrixEvent) =>
getMemberDisplayName(room, ev.getSender() ?? 'Unknown') ??
getMxIdLocalPart(ev.getSender() ?? 'Unknown') ??
'Unknown'
);
return (
<>
@ -108,9 +107,9 @@ export function ReactionTooltipMsg({ reaction, shortcode, users }: ReactionToolt
</>
)}
<Text as="span" size="Inherit" priority="300">
{names.length > 0 ? ' reacted with ' : 'Reacting with '}
{' reacted with '}
</Text>
:<b>{label}</b>:
:<b>{shortcode}</b>:
</>
);
}

View file

@ -6,11 +6,10 @@ import { MessageEmptyContent } from './content';
import { sanitizeCustomHtml } from '../../utils/sanitize';
import { highlightText, scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
const NEXT_LINE_ONLY_REGEX = /^\n+$/;
type RenderBodyProps = {
body: string;
customBody?: string;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
@ -22,9 +21,9 @@ export function RenderBody({
htmlReactParserOptions,
linkifyOpts,
}: RenderBodyProps) {
if (body === '' || NEXT_LINE_ONLY_REGEX.test(body)) return <MessageEmptyContent />;
if (typeof customBody === 'string') {
if (customBody === '') return <MessageEmptyContent />;
if (body === '') <MessageEmptyContent />;
if (customBody) {
if (customBody === '') <MessageEmptyContent />;
return parse(sanitizeCustomHtml(customBody), htmlReactParserOptions);
}
return (

View file

@ -0,0 +1,41 @@
import { style } from '@vanilla-extract/css';
import { config, toRem } from 'folds';
export const ReplyBend = style({
flexShrink: 0,
});
export const ThreadIndicator = style({
opacity: config.opacity.P300,
selectors: {
'button&': {
cursor: 'pointer',
},
':hover&': {
opacity: config.opacity.P500,
},
},
});
export const Reply = style({
marginBottom: toRem(1),
minWidth: 0,
maxWidth: '100%',
minHeight: config.lineHeight.T300,
selectors: {
'button&': {
cursor: 'pointer',
},
},
});
export const ReplyContent = style({
opacity: config.opacity.P300,
selectors: {
[`${Reply}:hover &`]: {
opacity: config.opacity.P500,
},
},
});

View file

@ -1,196 +1,138 @@
import { Box, Icon, Icons, Text, color, config, toRem } from 'folds';
import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
import { EventTimelineSet, Room } from 'matrix-js-sdk';
import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react';
import { ClientEventWithoutRoomId } from 'neurone/lib/api/types';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs';
import classNames from 'classnames';
import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import { LinePlaceholder } from './placeholder';
import { randomNumberBetween } from '../../utils/common';
import { MessageFailedContent } from './content';
import * as css from './Reply.css';
import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content';
import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
import { useRoomEvent } from '../../hooks/useRoomEvent';
import colorMXID from '../../../util/colorMXID';
import { GetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { GetRoomMemberNameCallback } from '../../hooks/useRoomMembers';
import { rContent, rUnsigned } from '../../../client/reader';
import { TimelineView } from '../../../client/room';
import { RenderReplyContent } from '../RenderReplyContent';
import { RedactedContent } from './MsgTypeRenderers';
import { HoverUnderline } from '../../styles/Text.css';
import { useObservableValueOptionally } from '../../hooks/useObservableValue';
import { getLatestEditEvent } from '../../utils/room';
import { MessageContent } from '../MessageContent';
import { useClient } from '../../hooks/useClient';
type ReplyMessageProps = {
type ReplyLayoutProps = {
userColor?: string;
username?: ReactNode;
usernameColor?: string;
children: ReactNode;
onClick?: () => void;
};
export function ReplyMessage({ username, usernameColor, onClick, children }: ReplyMessageProps) {
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!onClick) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onClick();
}
};
const handleClick: MouseEventHandler = (evt) => {
const target = evt.target as HTMLElement;
if (target.closest('a')) {
evt.stopPropagation();
return;
}
onClick?.();
};
const commonProps = onClick
? {
role: 'button' as const,
tabIndex: 0,
onClick: handleClick,
onKeyDown: handleKeyDown,
className: HoverUnderline,
style: { cursor: 'pointer' },
}
: {};
return (
<Box alignItems="Center" gap="100">
<Box shrink="No" gap="0" alignItems="Center" style={{ maxWidth: toRem(120) }}>
<Icon style={{ color: usernameColor }} size="100" src={Icons.ReplyArrow} filled />
export const ReplyLayout = as<'div', ReplyLayoutProps>(
({ username, userColor, className, children, ...props }, ref) => (
<Box
className={classNames(css.Reply, className)}
alignItems="Center"
gap="100"
{...props}
ref={ref}
>
<Box style={{ color: userColor, maxWidth: toRem(200) }} alignItems="Center" shrink="No">
<Icon size="100" src={Icons.ReplyArrow} />
{username}
</Box>
<Box {...commonProps}>{children}</Box>
<Box grow="Yes" className={css.ReplyContent}>
{children}
</Box>
</Box>
);
}
)
);
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
<Box
shrink="No"
className={css.ThreadIndicator}
alignItems="Center"
gap="100"
{...props}
ref={ref}
>
<Icon size="50" src={Icons.Thread} />
<Text size="L400">Thread</Text>
</Box>
));
type ReplyProps = {
roomId: string;
eventId: string;
onClick?: (replyId: string) => void;
onUserClick?: MouseEventHandler<HTMLButtonElement>;
room: Room;
timelineSet?: EventTimelineSet | undefined;
replyEventId: string;
threadRootId?: string | undefined;
onClick?: MouseEventHandler | undefined;
getMemberPowerTag?: GetMemberPowerTag;
accessibleTagColors?: Map<string, string>;
legacyUsernameColor?: boolean;
getMemberName: GetRoomMemberNameCallback;
timeline?: TimelineView;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
};
export function Reply({
roomId,
eventId,
onClick,
onUserClick,
getMemberPowerTag,
accessibleTagColors,
legacyUsernameColor,
getMemberName,
timeline,
htmlReactParserOptions,
linkifyOpts,
}: ReplyProps) {
const ct = useClient();
const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []);
export const Reply = as<'div', ReplyProps>(
(
{
room,
timelineSet,
replyEventId,
threadRootId,
onClick,
getMemberPowerTag,
accessibleTagColors,
legacyUsernameColor,
...props
},
ref
) => {
const placeholderWidth = useMemo(() => randomNumberBetween(40, 400), []);
const getFromLocalTimeline = useCallback(
() => timelineSet?.findEventById(replyEventId),
[timelineSet, replyEventId]
);
const replyEvent = useRoomEvent(room, replyEventId, getFromLocalTimeline);
const redactedEvents = useObservableValueOptionally(timeline?.relations.redacted);
const edits = useObservableValueOptionally(timeline?.relations.edits.eventToRelations);
const { body } = replyEvent?.getContent() ?? {};
const sender = replyEvent?.getSender();
const powerTag = sender ? getMemberPowerTag?.(sender) : undefined;
const tagColor = powerTag?.color ? accessibleTagColors?.get(powerTag.color) : undefined;
const replyEvent = useRoomEvent(
roomId,
eventId,
useCallback(() => timeline?.findEvent(eventId), [timeline, eventId])
);
const usernameColor = legacyUsernameColor ? colorMXID(sender ?? replyEventId) : tagColor;
const sender = replyEvent?.sender;
const powerTag = sender ? getMemberPowerTag?.(sender) : undefined;
const tagColor = powerTag?.color ? accessibleTagColors?.get(powerTag.color) : undefined;
const fallbackBody = replyEvent?.isRedacted() ? (
<MessageDeletedContent />
) : (
<MessageFailedContent />
);
const usernameColor = legacyUsernameColor ? colorMXID(sender ?? eventId) : tagColor;
const redacted =
redactedEvents?.has(eventId) || !!rUnsigned.redactedBecause(replyEvent?.unsigned);
const renderEventContent = (event: ClientEventWithoutRoomId) => {
const editEvents = edits?.get(eventId);
const editedEvent = getLatestEditEvent(event, editEvents, redactedEvents);
const badEncryption = replyEvent?.getContent().msgtype === 'm.bad.encrypted';
const bodyJSX = body ? scaleSystemEmoji(trimReplyFromBody(body)) : fallbackBody;
return (
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }} as="div">
{redacted ? (
<RedactedContent size="T300" />
) : (
<MessageContent
event={event}
editedEvent={editedEvent}
roomId={roomId}
decryptionManager={ct.decryption}
reply
>
{(contentEvent) => (
<RenderReplyContent
displayName={getMemberName(event.sender)}
eventType={contentEvent.type}
edited={!!editedEvent}
content={rContent.newContent(contentEvent.content) ?? contentEvent.content}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
/>
)}
</MessageContent>
<Box direction="Row" gap="200" alignItems="Center" {...props} ref={ref}>
{threadRootId && (
<ThreadIndicator as="button" data-event-id={threadRootId} onClick={onClick} />
)}
<ReplyLayout
as="button"
userColor={usernameColor}
username={
sender && (
<Text size="T300" truncate>
<b>{getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender)}</b>
</Text>
)
}
data-event-id={replyEventId}
onClick={onClick}
>
{replyEvent !== undefined ? (
<Text size="T300" truncate>
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
</Text>
) : (
<LinePlaceholder
style={{
backgroundColor: color.SurfaceVariant.ContainerActive,
width: toRem(placeholderWidth),
maxWidth: '100%',
}}
/>
)}
</ReplyLayout>
</Box>
);
};
const replyClick = onClick ? () => onClick(eventId) : undefined;
return (
<ReplyMessage
onClick={replyClick}
usernameColor={usernameColor}
username={
sender && (
<Text
as="button"
onClick={replyClick}
onContextMenu={onUserClick}
className={HoverUnderline}
style={{ color: usernameColor, cursor: 'pointer' }}
data-user-id={sender}
size="T300"
truncate
>
<b>{getMemberName(sender)}</b>
</Text>
)
}
data-event-id={eventId}
>
{replyEvent && renderEventContent(replyEvent)}
{replyEvent === null && (
<Text size="T300">
<MessageFailedContent />
</Text>
)}
{replyEvent === undefined && (
<LinePlaceholder
style={{
backgroundColor: color.SurfaceVariant.ContainerActive,
width: toRem(placeholderWidth),
maxWidth: '100%',
margin: `${toRem(2)} 0`,
height: `calc(${config.lineHeight.T300} - ${toRem(4)})`,
}}
/>
)}
</ReplyMessage>
);
}
}
);

View file

@ -1,12 +1,6 @@
import React, { ComponentProps } from 'react';
import { Text, as } from 'folds';
import {
timeDayMonthYear,
timeDayMonYear,
timeHourMinute,
today,
yesterday,
} from '../../utils/time';
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
export type TimeProps = {
compact?: boolean;
@ -49,9 +43,3 @@ export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>(
);
}
);
export function DividerTime({ ts }: { ts: number }) {
if (today(ts)) return 'Today';
if (yesterday(ts)) return 'Yesterday';
return timeDayMonthYear(ts);
}

View file

@ -1,9 +1,11 @@
/* eslint-disable jsx-a11y/media-has-caption */
import React, { useCallback, useRef, useState } from 'react';
import React, { ReactNode, useCallback, useRef, useState } from 'react';
import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { Range } from 'react-range';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { IAudioInfo } from '../../../../types/matrix/common';
import {
PlayTimeCallback,
useMediaLoading,
@ -14,45 +16,58 @@ import {
} from '../../../hooks/media';
import { useThrottle } from '../../../hooks/useThrottle';
import { secondsToMinutesAndSeconds } from '../../../utils/common';
import { decryptFile, downloadEncryptedMedia, downloadMedia } from '../../../utils/matrix';
import {
decryptFile,
downloadEncryptedMedia,
downloadMedia,
mxcUrlToHttp,
} from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useClient } from '../../../hooks/useClient';
import { mxcToHttp } from '../../../../client/mxcToHttp';
import { rInfo } from '../../../../client/reader';
import { MediaControl } from '../../media';
const PLAY_TIME_THROTTLE_OPS = {
wait: 500,
immediate: true,
};
type RenderMediaControlProps = {
after: ReactNode;
leftControl: ReactNode;
rightControl: ReactNode;
children: ReactNode;
};
export type AudioContentProps = {
mimeType: string;
url: string;
info: object;
encInfo?: object;
info: IAudioInfo;
encInfo?: EncryptedAttachmentInfo;
renderMediaControl: (props: RenderMediaControlProps) => ReactNode;
};
export function AudioContent({ mimeType, url, info, encInfo }: AudioContentProps) {
const ct = useClient();
export function AudioContent({
mimeType,
url,
info,
encInfo,
renderMediaControl,
}: AudioContentProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcToHttp(ct.session.baseUrl, url, useAuthentication).url();
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType, encInfo as EncryptedAttachmentInfo)
)
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl);
return URL.createObjectURL(fileContent);
}, [ct, url, useAuthentication, mimeType, encInfo])
}, [mx, url, useAuthentication, mimeType, encInfo])
);
const audioRef = useRef<HTMLAudioElement | null>(null);
const [currentTime, setCurrentTime] = useState(0);
// duration in seconds. (NOTE: info.duration is in milliseconds)
const infoDuration = rInfo.duration(info) ?? 0;
const infoDuration = info.duration ?? 0;
const [duration, setDuration] = useState((infoDuration >= 0 ? infoDuration : 0) / 1000);
const getAudioRef = useCallback(() => audioRef.current, []);
@ -60,9 +75,9 @@ export function AudioContent({ mimeType, url, info, encInfo }: AudioContentProps
const { playing, setPlaying } = useMediaPlay(getAudioRef);
const { seek } = useMediaSeek(getAudioRef);
const { volume, mute, setMute, setVolume } = useMediaVolume(getAudioRef);
const handlePlayTimeCallback: PlayTimeCallback = useCallback((d, cTime) => {
const handlePlayTimeCallback: PlayTimeCallback = useCallback((d, ct) => {
setDuration(d);
setCurrentTime(cTime);
setCurrentTime(ct);
}, []);
useMediaPlayTimeCallback(
getAudioRef,
@ -76,25 +91,95 @@ export function AudioContent({ mimeType, url, info, encInfo }: AudioContentProps
loadSrc();
}
};
return (
<MediaControl
after={
return renderMediaControl({
after: (
<Range
step={1}
min={0}
max={duration || 1}
values={[currentTime]}
onChange={(values) => seek(values[0])}
renderTrack={(params) => (
<div {...params.props}>
{params.children}
<ProgressBar
as="div"
variant="Secondary"
size="300"
min={0}
max={duration}
value={currentTime}
radii="300"
/>
</div>
)}
renderThumb={(params) => (
<Badge
size="300"
variant="Secondary"
fill="Solid"
radii="Pill"
outlined
{...params.props}
style={{
...params.props.style,
zIndex: 0,
}}
/>
)}
/>
),
leftControl: (
<>
<Chip
onClick={handlePlay}
variant="Secondary"
radii="300"
disabled={srcState.status === AsyncStatus.Loading}
before={
srcState.status === AsyncStatus.Loading || loading ? (
<Spinner variant="Secondary" size="50" />
) : (
<Icon src={playing ? Icons.Pause : Icons.Play} size="50" filled={playing} />
)
}
>
<Text size="B300">{playing ? 'Pause' : 'Play'}</Text>
</Chip>
<Text size="T200">{`${secondsToMinutesAndSeconds(
currentTime
)} / ${secondsToMinutesAndSeconds(duration)}`}</Text>
</>
),
rightControl: (
<>
<IconButton
variant="SurfaceVariant"
size="300"
radii="Pill"
onClick={() => setMute(!mute)}
aria-pressed={mute}
>
<Icon src={mute ? Icons.VolumeMute : Icons.VolumeHigh} size="50" />
</IconButton>
<Range
step={1}
step={0.1}
min={0}
max={duration || 1}
values={[currentTime]}
onChange={(values) => seek(values[0])}
max={1}
values={[volume]}
onChange={(values) => setVolume(values[0])}
renderTrack={(params) => (
<div {...params.props}>
{params.children}
<ProgressBar
as="div"
style={{ width: toRem(48) }}
variant="Secondary"
size="300"
min={0}
max={duration}
value={currentTime}
max={1}
value={volume}
radii="300"
/>
</div>
@ -114,82 +199,12 @@ export function AudioContent({ mimeType, url, info, encInfo }: AudioContentProps
/>
)}
/>
}
leftControl={
<>
<Chip
onClick={handlePlay}
variant="Secondary"
radii="300"
disabled={srcState.status === AsyncStatus.Loading}
before={
srcState.status === AsyncStatus.Loading || loading ? (
<Spinner variant="Secondary" size="50" />
) : (
<Icon src={playing ? Icons.Pause : Icons.Play} size="50" filled={playing} />
)
}
>
<Text size="B300">{playing ? 'Pause' : 'Play'}</Text>
</Chip>
<Text size="T200">{`${secondsToMinutesAndSeconds(
currentTime
)} / ${secondsToMinutesAndSeconds(duration)}`}</Text>
</>
}
rightControl={
<>
<IconButton
variant="SurfaceVariant"
size="300"
radii="Pill"
onClick={() => setMute(!mute)}
aria-pressed={mute}
>
<Icon src={mute ? Icons.VolumeMute : Icons.VolumeHigh} size="50" />
</IconButton>
<Range
step={0.1}
min={0}
max={1}
values={[volume]}
onChange={(values) => setVolume(values[0])}
renderTrack={(params) => (
<div {...params.props}>
{params.children}
<ProgressBar
style={{ width: toRem(48) }}
variant="Secondary"
size="300"
min={0}
max={1}
value={volume}
radii="300"
/>
</div>
)}
renderThumb={(params) => (
<Badge
size="300"
variant="Secondary"
fill="Solid"
radii="Pill"
outlined
{...params.props}
style={{
...params.props.style,
zIndex: 0,
}}
/>
)}
/>
</>
}
>
</>
),
children: (
<audio controls={false} autoPlay ref={audioRef}>
{srcState.status === AsyncStatus.Success && <source src={srcState.data} type={mimeType} />}
</audio>
</MediaControl>
);
),
});
}

View file

@ -18,7 +18,7 @@ export function EventContent({ messageLayout, time, iconSrc, content }: EventCon
alignItems="Center"
justifyContent="Center"
>
<Icon size="50" src={iconSrc} />
<Icon style={{ opacity: 0.6 }} size="50" src={iconSrc} />
</Box>
</Box>
);

View file

@ -1,6 +1,5 @@
import { Box, Icon, Icons, Text, as, color, config } from 'folds';
import React from 'react';
import { Code } from '../../../styles/CustomHtml.css';
const warningStyle = { color: color.Warning.Main, opacity: config.opacity.P300 };
const criticalStyle = { color: color.Critical.Main, opacity: config.opacity.P300 };
@ -25,15 +24,6 @@ export const MessageUnsupportedContent = as<'div', { children?: never }>(({ ...p
</Box>
));
export const UnsupportedMessageEventType = as<'div', { eventType: string; children?: never }>(
({ eventType, ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" {...props} ref={ref}>
<Icon size="50" src={Icons.Code} />
Sent <code className={Code}>{eventType}</code> event
</Box>
)
);
export const MessageFailedContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" style={criticalStyle} {...props} ref={ref}>
<Icon size="50" src={Icons.Warning} />
@ -74,38 +64,3 @@ export const MessageEditedContent = as<'span', { children?: never }>(({ ...props
{' (edited)'}
</Text>
));
export const MessageStickerContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" {...props} ref={ref}>
<Icon size="50" src={Icons.Sticker} filled />
<i>Sticker</i>
</Box>
));
export const MessageImageContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" {...props} ref={ref}>
<Icon size="50" src={Icons.Photo} filled />
<i>Image</i>
</Box>
));
export const MessageVideoContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" {...props} ref={ref}>
<Icon size="50" src={Icons.Vlc} filled />
<i>Video</i>
</Box>
));
export const MessageAudioContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" {...props} ref={ref}>
<Icon size="50" src={Icons.Play} filled />
<i>Audio</i>
</Box>
));
export const MessageFileContent = as<'div', { children?: never }>(({ ...props }, ref) => (
<Box as="span" alignItems="Center" gap="100" {...props} ref={ref}>
<Icon size="50" src={Icons.File} filled />
<i>File</i>
</Box>
));

View file

@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react';
import React, { ReactNode, useCallback, useState } from 'react';
import {
Box,
Button,
@ -12,12 +12,14 @@ import {
Text,
Tooltip,
TooltipProvider,
as,
} from 'folds';
import FileSaver from 'file-saver';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import FocusTrap from 'focus-trap-react';
import { IFileInfo } from '../../../../types/matrix/common';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { bytesToSize } from '../../../utils/common';
import {
READABLE_EXT_TO_MIME_TYPE,
@ -26,13 +28,14 @@ import {
mimeTypeToExt,
} from '../../../utils/mimeTypes';
import { stopPropagation } from '../../../utils/keyboard';
import { decryptFile, downloadEncryptedMedia, downloadMedia } from '../../../utils/matrix';
import {
decryptFile,
downloadEncryptedMedia,
downloadMedia,
mxcUrlToHttp,
} from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css';
import { useClient } from '../../../hooks/useClient';
import { mxcToHttp } from '../../../../client/mxcToHttp';
import { TextViewer } from '../../text-viewer';
import { PdfViewer } from '../../Pdf-viewer';
const renderErrorButton = (retry: () => void, text: string) => (
<TooltipProvider
@ -63,30 +66,36 @@ const renderErrorButton = (retry: () => void, text: string) => (
</TooltipProvider>
);
type RenderTextViewerProps = {
name: string;
text: string;
langName: string;
requestClose: () => void;
};
type ReadTextFileProps = {
body: string;
mimeType: string;
url: string;
encInfo?: object;
encInfo?: EncryptedAttachmentInfo;
renderViewer: (props: RenderTextViewerProps) => ReactNode;
};
export function ReadTextFile({ body, mimeType, url, encInfo }: ReadTextFileProps) {
const ct = useClient();
export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: ReadTextFileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [textViewer, setTextViewer] = useState(false);
const [textState, loadText] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcToHttp(ct.session.baseUrl, url, useAuthentication).url();
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType, encInfo as EncryptedAttachmentInfo)
)
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl);
const text = fileContent.text();
setTextViewer(true);
return text;
}, [ct, useAuthentication, mimeType, encInfo, url])
}, [mx, useAuthentication, mimeType, encInfo, url])
);
return (
@ -107,16 +116,14 @@ export function ReadTextFile({ body, mimeType, url, encInfo }: ReadTextFileProps
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
>
<TextViewer
name={body}
text={textState.data}
langName={
READABLE_TEXT_MIME_TYPES.includes(mimeType)
? mimeTypeToExt(mimeType)
: mimeTypeToExt(READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)] ?? mimeType)
}
requestClose={() => setTextViewer(false)}
/>
{renderViewer({
name: body,
text: textState.data,
langName: READABLE_TEXT_MIME_TYPES.includes(mimeType)
? mimeTypeToExt(mimeType)
: mimeTypeToExt(READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)] ?? mimeType),
requestClose: () => setTextViewer(false),
})}
</Modal>
</FocusTrap>
</OverlayCenter>
@ -151,28 +158,33 @@ export function ReadTextFile({ body, mimeType, url, encInfo }: ReadTextFileProps
);
}
type RenderPdfViewerProps = {
name: string;
src: string;
requestClose: () => void;
};
export type ReadPdfFileProps = {
body: string;
mimeType: string;
url: string;
encInfo?: object;
encInfo?: EncryptedAttachmentInfo;
renderViewer: (props: RenderPdfViewerProps) => ReactNode;
};
export function ReadPdfFile({ body, mimeType, url, encInfo }: ReadPdfFileProps) {
const ct = useClient();
export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: ReadPdfFileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [pdfViewer, setPdfViewer] = useState(false);
const [pdfState, loadPdf] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcToHttp(ct.session.baseUrl, url, useAuthentication).url();
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType, encInfo as EncryptedAttachmentInfo)
)
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl);
setPdfViewer(true);
return URL.createObjectURL(fileContent);
}, [ct, url, useAuthentication, mimeType, encInfo])
}, [mx, url, useAuthentication, mimeType, encInfo])
);
return (
@ -193,11 +205,11 @@ export function ReadPdfFile({ body, mimeType, url, encInfo }: ReadPdfFileProps)
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
>
<PdfViewer
name={body}
src={pdfState.data}
requestClose={() => setPdfViewer(false)}
/>
{renderViewer({
name: body,
src: pdfState.data,
requestClose: () => setPdfViewer(false),
})}
</Modal>
</FocusTrap>
</OverlayCenter>
@ -235,25 +247,24 @@ export type DownloadFileProps = {
mimeType: string;
url: string;
info: IFileInfo;
encInfo?: object;
encInfo?: EncryptedAttachmentInfo;
};
export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) {
const ct = useClient();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [downloadState, download] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcToHttp(ct.session.baseUrl, url, useAuthentication).url();
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType, encInfo as EncryptedAttachmentInfo)
)
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl);
const fileURL = URL.createObjectURL(fileContent);
FileSaver.saveAs(fileURL, body);
return fileURL;
}, [ct, url, useAuthentication, mimeType, encInfo, body])
}, [mx, url, useAuthentication, mimeType, encInfo, body])
);
return downloadState.status === AsyncStatus.Error ? (
@ -286,21 +297,17 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
type FileContentProps = {
body: string;
mimeType: string;
info: object;
url: string;
encInfo?: object;
renderAsTextFile: () => ReactNode;
renderAsPdfFile: () => ReactNode;
};
export function FileContent({ body, mimeType, info, url, encInfo }: FileContentProps) {
return (
<Box direction="Column" gap="300">
export const FileContent = as<'div', FileContentProps>(
({ body, mimeType, renderAsTextFile, renderAsPdfFile, children, ...props }, ref) => (
<Box direction="Column" gap="300" {...props} ref={ref}>
{(READABLE_TEXT_MIME_TYPES.includes(mimeType) ||
READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)]) && (
<ReadTextFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} />
)}
{mimeType === 'application/pdf' && (
<ReadPdfFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} />
)}
<DownloadFile body={body} mimeType={mimeType} url={url} encInfo={encInfo} info={info} />
READABLE_EXT_TO_MIME_TYPE[getFileNameExt(body)]) &&
renderAsTextFile()}
{mimeType === 'application/pdf' && renderAsPdfFile()}
{children}
</Box>
);
}
)
);

View file

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
import {
Badge,
Box,
@ -22,28 +22,41 @@ import FocusTrap from 'focus-trap-react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { IImageInfo, MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import * as css from './style.css';
import { bytesToSize } from '../../../utils/common';
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
import { stopPropagation } from '../../../utils/keyboard';
import { decryptFile, downloadEncryptedMedia } from '../../../utils/matrix';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css';
import { validBlurHash } from '../../../utils/blurHash';
import { useClient } from '../../../hooks/useClient';
import { mxcToHttp } from '../../../../client/mxcToHttp';
import { Image } from '../../media';
import { ImageViewer } from '../../image-viewer';
type RenderViewerProps = {
src: string;
alt: string;
requestClose: () => void;
};
type RenderImageProps = {
alt: string;
title: string;
src: string;
onLoad: () => void;
onError: () => void;
onClick: () => void;
tabIndex: number;
};
export type ImageContentProps = {
body: string;
mimeType?: string;
url: string;
info?: IImageInfo;
encInfo?: object;
encInfo?: EncryptedAttachmentInfo;
autoPlay?: boolean;
markedAsSpoiler?: boolean;
spoilerReason?: string;
renderViewer: (props: RenderViewerProps) => ReactNode;
renderImage: (props: RenderImageProps) => ReactNode;
};
export const ImageContent = as<'div', ImageContentProps>(
(
@ -57,11 +70,13 @@ export const ImageContent = as<'div', ImageContentProps>(
autoPlay,
markedAsSpoiler,
spoilerReason,
renderViewer,
renderImage,
...props
},
ref
) => {
const ct = useClient();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
@ -72,15 +87,16 @@ export const ImageContent = as<'div', ImageContentProps>(
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcToHttp(ct.session.baseUrl, url, useAuthentication).url();
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
if (encInfo) {
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo as EncryptedAttachmentInfo)
decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo)
);
return URL.createObjectURL(fileContent);
}
return mediaUrl;
}, [ct, url, useAuthentication, mimeType, encInfo])
}, [mx, url, useAuthentication, mimeType, encInfo])
);
const handleLoad = () => {
@ -118,11 +134,11 @@ export const ImageContent = as<'div', ImageContentProps>(
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
>
<ImageViewer
src={srcState.data}
alt={body}
requestClose={() => setViewer(false)}
/>
{renderViewer({
src: srcState.data,
alt: body,
requestClose: () => setViewer(false),
})}
</Modal>
</FocusTrap>
</OverlayCenter>
@ -153,16 +169,15 @@ export const ImageContent = as<'div', ImageContentProps>(
)}
{srcState.status === AsyncStatus.Success && (
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
<Image
alt={body}
title={body}
src={srcState.data}
onLoad={handleLoad}
onError={handleError}
onClick={() => setViewer(true)}
tabIndex={0}
loading="lazy"
/>
{renderImage({
alt: body,
title: body,
src: srcState.data,
onLoad: handleLoad,
onError: handleError,
onClick: () => setViewer(true),
tabIndex: 0,
})}
</Box>
)}
{blurred && !error && srcState.status !== AsyncStatus.Error && (

View file

@ -1,49 +1,44 @@
import React, { useCallback, useEffect } from 'react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { ReactNode, useCallback, useEffect } from 'react';
import { IThumbnailContent } from '../../../../types/matrix/common';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { decryptFile, downloadEncryptedMedia } from '../../../utils/matrix';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
import { rEncryptedFile, rInfo, rThumbnailInfo } from '../../../../client/reader';
import { Image } from '../../media';
import { useClient } from '../../../hooks/useClient';
import { mxcToHttp } from '../../../../client/mxcToHttp';
export type ThumbnailContentProps = {
info: object;
info: IThumbnailContent;
renderImage: (src: string) => ReactNode;
};
export function ThumbnailContent({ info }: ThumbnailContentProps) {
const ct = useClient();
export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [thumbSrcState, loadThumbSrc] = useAsyncCallback(
useCallback(async () => {
const thumbInfo = rInfo.thumbnailInfo(info);
const encInfo = rInfo.thumbnailFile(info);
const thumbMxcUrl = rEncryptedFile.url(encInfo) ?? rInfo.thumbnailUrl(info);
const mimeType = rThumbnailInfo.mimetype(thumbInfo);
if (typeof thumbMxcUrl !== 'string' || typeof mimeType !== 'string') {
const thumbInfo = info.thumbnail_info;
const thumbMxcUrl = info.thumbnail_file?.url ?? info.thumbnail_url;
const encInfo = info.thumbnail_file;
if (typeof thumbMxcUrl !== 'string' || typeof thumbInfo?.mimetype !== 'string') {
throw new Error('Failed to load thumbnail');
}
const mediaUrl = mxcToHttp(ct.session.baseUrl, thumbMxcUrl, useAuthentication).url();
const mediaUrl = mxcUrlToHttp(mx, thumbMxcUrl, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
if (encInfo) {
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo as EncryptedAttachmentInfo)
decryptFile(encBuf, thumbInfo.mimetype ?? FALLBACK_MIMETYPE, encInfo)
);
return URL.createObjectURL(fileContent);
}
return mediaUrl;
}, [ct, info, useAuthentication])
}, [mx, info, useAuthentication])
);
useEffect(() => {
loadThumbSrc();
}, [loadThumbSrc]);
return thumbSrcState.status === AsyncStatus.Success ? (
<Image alt="Thumbnail" title="Thumbnail" src={thumbSrcState.data} loading="lazy" />
) : null;
return thumbSrcState.status === AsyncStatus.Success ? renderImage(thumbSrcState.data) : null;
}

View file

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
import {
Badge,
Box,
@ -15,28 +15,43 @@ import {
import classNames from 'classnames';
import { BlurhashCanvas } from 'react-blurhash';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import {
IThumbnailContent,
IVideoInfo,
MATRIX_BLUR_HASH_PROPERTY_NAME,
} from '../../../../types/matrix/common';
import * as css from './style.css';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { bytesToSize, millisecondsToMinutesAndSeconds } from '../../../utils/common';
import { decryptFile, downloadEncryptedMedia, downloadMedia } from '../../../utils/matrix';
import {
decryptFile,
downloadEncryptedMedia,
downloadMedia,
mxcUrlToHttp,
} from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { validBlurHash } from '../../../utils/blurHash';
import { useClient } from '../../../hooks/useClient';
import { mxcToHttp } from '../../../../client/mxcToHttp';
import { Video } from '../../media';
import { rInfo, rThumbnailInfo } from '../../../../client/reader';
import { ThumbnailContent } from './ThumbnailContent';
type RenderVideoProps = {
title: string;
src: string;
onLoadedMetadata: () => void;
onError: () => void;
autoPlay: boolean;
controls: boolean;
};
type VideoContentProps = {
body: string;
mimeType: string;
url: string;
info: object;
encInfo?: object;
info: IVideoInfo & IThumbnailContent;
encInfo?: EncryptedAttachmentInfo;
autoPlay?: boolean;
mediaAutoLoad?: boolean;
markedAsSpoiler?: boolean;
spoilerReason?: string;
renderThumbnail?: () => ReactNode;
renderVideo: (props: RenderVideoProps) => ReactNode;
};
export const VideoContent = as<'div', VideoContentProps>(
(
@ -48,20 +63,17 @@ export const VideoContent = as<'div', VideoContentProps>(
info,
encInfo,
autoPlay,
mediaAutoLoad,
markedAsSpoiler,
spoilerReason,
renderThumbnail,
renderVideo,
...props
},
ref
) => {
const ct = useClient();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const thumbnailInfo = rInfo.thumbnailInfo(info);
const blurHash = validBlurHash(rThumbnailInfo.blurhash(thumbnailInfo));
const size = rInfo.size(info);
const duration = rInfo.duration(info);
const blurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
const [load, setLoad] = useState(false);
const [error, setError] = useState(false);
@ -69,14 +81,15 @@ export const VideoContent = as<'div', VideoContentProps>(
const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcToHttp(ct.session.baseUrl, url, useAuthentication).url();
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType, encInfo as EncryptedAttachmentInfo)
decryptFile(encBuf, mimeType, encInfo)
)
: await downloadMedia(mediaUrl);
return URL.createObjectURL(fileContent);
}, [ct, url, useAuthentication, mimeType, encInfo])
}, [mx, url, useAuthentication, mimeType, encInfo])
);
const handleLoad = () => {
@ -107,13 +120,13 @@ export const VideoContent = as<'div', VideoContentProps>(
punch={1}
/>
)}
{mediaAutoLoad && !load && (
{renderThumbnail && !load && (
<Box
className={classNames(css.AbsoluteContainer, blurred && css.Blur)}
alignItems="Center"
justifyContent="Center"
>
<ThumbnailContent info={info} />
{renderThumbnail()}
</Box>
)}
{!autoPlay && !blurred && srcState.status === AsyncStatus.Idle && (
@ -132,14 +145,14 @@ export const VideoContent = as<'div', VideoContentProps>(
)}
{srcState.status === AsyncStatus.Success && (
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
<Video
title={body}
src={srcState.data}
onLoadedMetadata={handleLoad}
onError={handleError}
autoPlay
controls
/>
{renderVideo({
title: body,
src: srcState.data,
onLoadedMetadata: handleLoad,
onError: handleError,
autoPlay: true,
controls: true,
})}
</Box>
)}
{blurred && !error && srcState.status !== AsyncStatus.Error && (
@ -207,7 +220,7 @@ export const VideoContent = as<'div', VideoContentProps>(
</TooltipProvider>
</Box>
)}
{!load && typeof size === 'number' && (
{!load && typeof info.size === 'number' && (
<Box
className={css.AbsoluteFooter}
justifyContent="SpaceBetween"
@ -215,10 +228,10 @@ export const VideoContent = as<'div', VideoContentProps>(
gap="200"
>
<Badge variant="Secondary" fill="Soft">
<Text size="L400">{millisecondsToMinutesAndSeconds(duration ?? 0)}</Text>
<Text size="L400">{millisecondsToMinutesAndSeconds(info.duration ?? 0)}</Text>
</Badge>
<Badge variant="Secondary" fill="Soft">
<Text size="L400">{bytesToSize(size)}</Text>
<Text size="L400">{bytesToSize(info.size)}</Text>
</Badge>
</Box>
)}

View file

@ -6,6 +6,5 @@ export * from './Reply';
export * from './content';
export * from './Time';
export * from './MsgTypeRenderers';
export * from './MsgTypeReplyRenderers';
export * from './FileHeader';
export * from './RenderBody';

View file

@ -28,20 +28,13 @@ export const UsernameBold = as<'b'>(({ as: AsUsernameBold = 'b', className, ...p
<AsUsernameBold className={classNames(css.UsernameBold, className)} {...props} ref={ref} />
));
export const MessageTextBody = as<
'div',
css.MessageTextBodyVariants & { size?: 'T400' | 'T300'; notice?: boolean; truncate?: boolean }
>(
(
{ as: asComp = 'div', className, size, preWrap, jumboEmoji, emote, notice, truncate, ...props },
ref
) => (
export const MessageTextBody = as<'div', css.MessageTextBodyVariants & { notice?: boolean }>(
({ as: asComp = 'div', className, preWrap, jumboEmoji, emote, notice, ...props }, ref) => (
<Text
as={asComp}
size={size ?? 'T400'}
size="T400"
priority={notice ? '300' : '400'}
className={classNames(css.MessageTextBody({ preWrap, jumboEmoji, emote }), className)}
truncate={truncate}
{...props}
ref={ref}
/>

View file

@ -112,7 +112,8 @@ export const CompactHeader = style([
DefaultReset,
StickySection,
{
width: toRem(170),
maxWidth: toRem(170),
width: '100%',
},
]);
@ -181,7 +182,6 @@ export const UsernameBold = style({
export const MessageTextBody = recipe({
base: {
wordBreak: 'break-word',
position: 'relative',
},
variants: {
preWrap: {

View file

@ -11,21 +11,21 @@ import {
toRem,
} from 'folds';
import React, { ReactNode, useId } from 'react';
import { Presence } from 'neurone/lib/api/types';
import * as css from './styles.css';
import { usePresenceLabel } from '../../hooks/useUserPresence';
import { Presence, usePresenceLabel } from '../../hooks/useUserPresence';
const PresenceToColor: Record<Presence, MainColor> = {
online: 'Success',
unavailable: 'Critical',
offline: 'Secondary',
[Presence.Online]: 'Success',
[Presence.Unavailable]: 'Warning',
[Presence.Offline]: 'Secondary',
};
type PresenceBadgeProps = {
presence: Presence;
status?: string;
size?: '200' | '300' | '400' | '500';
};
export function PresenceBadge({ presence, size }: PresenceBadgeProps) {
export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
const label = usePresenceLabel();
const badgeLabelId = useId();
@ -39,6 +39,8 @@ export function PresenceBadge({ presence, size }: PresenceBadgeProps) {
<Tooltip id={badgeLabelId}>
<Box style={{ maxWidth: toRem(250) }} alignItems="Baseline" gap="100">
<Text size="L400">{label[presence]}</Text>
{status && <Text size="T200">•</Text>}
{status && <Text size="T200">{status}</Text>}
</Box>
</Tooltip>
}
@ -49,7 +51,7 @@ export function PresenceBadge({ presence, size }: PresenceBadgeProps) {
ref={triggerRef}
size={size}
variant={PresenceToColor[presence]}
fill={presence === 'offline' ? 'Soft' : 'Solid'}
fill={presence === Presence.Offline ? 'Soft' : 'Solid'}
radii="Pill"
/>
)}

View file

@ -15,7 +15,7 @@ export const AvatarPresenceBadge = style({
zIndex: 1,
display: 'flex',
padding: '5%',
padding: config.borderWidth.B600,
backgroundColor: 'inherit',
borderRadius: config.radii.Pill,
overflow: 'hidden',

View file

@ -12,7 +12,3 @@ export const RoomAvatar = style({
},
},
});
export const RoomAvatarImg = style({
fontSize: 1,
});

View file

@ -1,45 +1,9 @@
import { JoinRule } from 'matrix-js-sdk';
import { AvatarFallback, AvatarImage, Icon, Icons, color } from 'folds';
import React, {
ComponentProps,
ReactEventHandler,
ReactNode,
forwardRef,
useCallback,
useState,
} from 'react';
import classNames from 'classnames';
import React, { ComponentProps, ReactEventHandler, ReactNode, forwardRef, useState } from 'react';
import * as css from './RoomAvatar.css';
import { getRoomIconSrc } from '../../utils/room';
import colorMXID from '../../../util/colorMXID';
import { JoinRule } from '../../../types/matrix/room';
import { useDocumentVisibilityChange } from '../../hooks/useDocumentVisibilityChange';
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
type RoomAvatarErrorProps = {
roomId: string;
setError: (error: boolean) => void;
children: ReactNode;
};
function RoomAvatarError({ roomId, setError, children }: RoomAvatarErrorProps) {
const handleVisibilityChange = useCallback(
(visible: boolean) => {
if (visible) setError(false);
},
[setError]
);
useDocumentVisibilityChange(handleVisibilityChange);
useDocumentFocusChange(handleVisibilityChange);
return (
<AvatarFallback
style={{ backgroundColor: colorMXID(roomId ?? ''), color: color.Surface.Container }}
className={css.RoomAvatar}
>
{children}
</AvatarFallback>
);
}
type RoomAvatarProps = {
roomId: string;
@ -54,7 +18,7 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
evt.currentTarget.setAttribute('data-image-loaded', 'true');
};
if (!src) {
if (!src || error) {
return (
<AvatarFallback
style={{ backgroundColor: colorMXID(roomId ?? ''), color: color.Surface.Container }}
@ -65,17 +29,9 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
);
}
if (error) {
return (
<RoomAvatarError roomId={roomId} setError={setError}>
{renderFallback()}
</RoomAvatarError>
);
}
return (
<AvatarImage
className={classNames(css.RoomAvatar, css.RoomAvatarImg)}
className={css.RoomAvatar}
src={src}
alt={alt}
onError={() => setError(true)}

View file

@ -1,4 +1,5 @@
import React, { ReactNode, useCallback, useRef, useState } from 'react';
import { MatrixError } from 'matrix-js-sdk';
import {
Avatar,
Badge,
@ -18,22 +19,21 @@ import {
} from 'folds';
import classNames from 'classnames';
import FocusTrap from 'focus-trap-react';
import { postJoin, PostJoinOK } from 'neurone/lib/api/matrix/client/v3/join';
import { parseMatrixResp, printMatrixError } from 'neurone/lib/matrix-request';
import * as css from './style.css';
import { RoomAvatar } from '../room-avatar';
import { getMxIdLocalPart } from '../../utils/matrix';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { nameInitials } from '../../utils/common';
import { millify } from '../../plugins/millify';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { onEnterOrSpace, stopPropagation } from '../../utils/keyboard';
import { RoomType } from '../../../types/matrix/room';
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';
import { useLocalRoomSummary } from '../../hooks/useLocalRoomSummary';
import { mxcToHttp } from '../../../client/mxcToHttp';
import { useClient } from '../../hooks/useClient';
type GridColumnCount = '1' | '2' | '3';
const getGridColumnCount = (gridWidth: number): GridColumnCount => {
@ -162,40 +162,46 @@ export const RoomCard = as<'div', RoomCardProps>(
},
ref
) => {
const ct = useClient();
const { baseUrl } = ct.session;
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const joinedRoomId = useJoinedRoomId(allRooms, roomIdOrAlias);
const localSummary = useLocalRoomSummary(joinedRoomId ?? '');
const joinedRoom = mx.getRoom(joinedRoomId);
const [topicEvent, setTopicEvent] = useState(() =>
joinedRoom ? getStateEvent(joinedRoom, StateEvent.RoomTopic) : undefined
);
const fallbackName = getMxIdLocalPart(roomIdOrAlias) ?? roomIdOrAlias;
const fallbackTopic = roomIdOrAlias;
const avatarMxc = localSummary.avatarUrl ?? avatarUrl;
const avatarHttpUrl =
avatarMxc &&
mxcToHttp(baseUrl, avatarMxc, useAuthentication).thumbnail({
width: 96,
height: 96,
method: 'crop',
});
const avatar = joinedRoom
? getRoomAvatarUrl(mx, joinedRoom, 96, useAuthentication)
: avatarUrl && mxcUrlToHttp(mx, avatarUrl, useAuthentication, 96, 96, 'crop');
const roomName = localSummary?.name || name || fallbackName;
const roomTopic = localSummary.topic || topic || fallbackTopic;
const joinedMemberCount = localSummary.memberCount ?? memberCount;
const roomName = joinedRoom?.name || name || fallbackName;
const roomTopic =
(topicEvent?.getContent().topic as string) || undefined || topic || fallbackTopic;
const joinedMemberCount = joinedRoom?.getJoinedMemberCount() ?? memberCount;
const [joinState, join] = useAsyncCallback<PostJoinOK, Error, []>(
useStateEventCallback(
mx,
useCallback(
(event) => {
if (
joinedRoom &&
event.getRoomId() === joinedRoom.roomId &&
event.getType() === StateEvent.RoomTopic
) {
setTopicEvent(getStateEvent(joinedRoom, StateEvent.RoomTopic));
}
},
[joinedRoom]
)
);
const [joinState, join] = useAsyncCallback<void, MatrixError, []>(
useCallback(async () => {
const response = await ct.requestAuthJSON(
postJoin({
pathParams: { roomIdOrAlias },
searchParams: { server_name: viaServers, via: viaServers },
})
);
const [err, result] = await parseMatrixResp<PostJoinOK>(response);
if (err) throw new Error(printMatrixError(err));
return result;
}, [ct, roomIdOrAlias, viaServers])
await joinEstateRoomOrThrow(mx, roomIdOrAlias, viaServers);
}, [mx, roomIdOrAlias, viaServers])
);
const joining =
joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success;
@ -210,7 +216,7 @@ export const RoomCard = as<'div', RoomCardProps>(
<Avatar size="500">
<RoomAvatar
roomId={roomIdOrAlias}
src={avatarHttpUrl}
src={avatar ?? undefined}
alt={roomIdOrAlias}
renderFallback={() => (
<Text as="span" size="H3">
@ -219,7 +225,7 @@ export const RoomCard = as<'div', RoomCardProps>(
)}
/>
</Avatar>
{(roomType === RoomType.Space || localSummary.roomType === RoomType.Space) && (
{(roomType === RoomType.Space || joinedRoom?.isSpaceRoom()) && (
<Badge variant="Secondary" fill="Soft" outlined>
<Text size="L400">Space</Text>
</Badge>
@ -270,7 +276,7 @@ export const RoomCard = as<'div', RoomCardProps>(
variant="Secondary"
size="300"
disabled={joining}
before={joining && <Spinner size="50" variant="Secondary" fill="Solid" />}
before={joining && <Spinner size="50" variant="Secondary" fill="Soft" />}
>
<Text size="B300" truncate>
{joining ? 'Joining' : 'Join'}

View file

@ -1,63 +1,60 @@
import React, { useState } from 'react';
import { Avatar, Box, Button, Text, as } from 'folds';
import React, { useCallback, useState } from 'react';
import { Avatar, Box, Button, Spinner, Text, as } from 'folds';
import { Room } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room';
import { getMemberDisplayName, getStateEvent } from '../../utils/room';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { timeDayMonthYear, timeHourMinute } from '../../utils/time';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { RoomAvatar } from '../room-avatar';
import { nameInitials } from '../../utils/common';
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
import { mDirectAtom } from '../../state/mDirectList';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { InviteUserPrompt } from '../invite-user-prompt';
import { useClient } from '../../hooks/useClient';
import { useRoom } from '../../hooks/useRoom';
import { mxcToHttp } from '../../../client/mxcToHttp';
import { useRoomTopic } from '../../hooks/storage/useRoomTopic';
import { useRoomStateEvent } from '../../hooks/storage/useRoomStateEvent';
import { StateEventType } from '../../../types/matrix/room';
import { useRoomMembersAPI } from '../../hooks/useRoomMembers';
import { joinEstateRoomOrThrow } from '../../features/estate/joinEstateRoom';
export const RoomIntro = as<
'div',
{
name: string;
avatar?: string;
}
>(({ name, avatar, ...props }, ref) => {
const ct = useClient();
const roomId = useRoom();
export type RoomIntroProps = {
room: Room;
};
export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const { navigateRoom } = useRoomNavigate();
const mDirects = useAtomValue(mDirectAtom);
const [invitePrompt, setInvitePrompt] = useState(false);
const { getName } = useRoomMembersAPI();
const topic = useRoomTopic(roomId);
const avatarHttpUrl = avatar
? mxcToHttp(ct.session.baseUrl, avatar, useAuthentication).url()
: undefined;
const createEvent = getStateEvent(room, StateEvent.RoomCreate);
const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId));
const name = useRoomName(room);
const topic = useRoomTopic(room);
const avatarHttpUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined;
const createResult = useRoomStateEvent(roomId, StateEventType.RoomCreate, '');
const event = createResult.value?.event;
const content = event?.content;
const createContent = createEvent?.getContent<IRoomCreateContent>();
const ts = createEvent?.getTs();
const creatorId = createEvent?.getSender();
const creatorName =
creatorId && (getMemberDisplayName(room, creatorId) ?? getMxIdLocalPart(creatorId));
const prevRoomId = createContent?.predecessor?.room_id;
const ts = event?.origin_server_ts;
const creatorId = event?.sender;
const creatorName = creatorId && getName(creatorId);
const [prevRoomState, joinPrevRoom] = useAsyncCallback(
useCallback((roomId: string) => joinEstateRoomOrThrow(mx, roomId), [mx])
);
const prevRoomId =
content &&
'predecessor' in event.content &&
typeof event.content.predecessor === 'object' &&
event.content.predecessor &&
'room_id' in event.content.predecessor &&
event.content.predecessor.room_id;
const [hour24Clock] = useSetting('hour24Clock');
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
return (
<Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
<Box>
<Avatar size="500">
<RoomAvatar
roomId={roomId}
roomId={room.roomId}
src={avatarHttpUrl ?? undefined}
alt={name}
renderFallback={() => <Text size="H2">{nameInitials(name)}</Text>}
@ -86,19 +83,36 @@ export const RoomIntro = as<
</Button>
{invitePrompt && (
<InviteUserPrompt roomId={roomId} requestClose={() => setInvitePrompt(false)} />
)}
{typeof prevRoomId === 'string' && (
<Button
onClick={() => navigateRoom(prevRoomId)}
variant="Secondary"
size="300"
fill="Soft"
radii="300"
>
<Text size="B300">Open Old Room</Text>
</Button>
<InviteUserPrompt room={room} requestClose={() => setInvitePrompt(false)} />
)}
{typeof prevRoomId === 'string' &&
(mx.getRoom(prevRoomId)?.getMyMembership() === Membership.Join ? (
<Button
onClick={() => navigateRoom(prevRoomId, createContent?.predecessor?.event_id)}
variant="Success"
size="300"
fill="Soft"
radii="300"
>
<Text size="B300">Open Old Room</Text>
</Button>
) : (
<Button
onClick={() => joinPrevRoom(prevRoomId)}
variant="Secondary"
size="300"
fill="Soft"
radii="300"
disabled={prevRoomState.status === AsyncStatus.Loading}
after={
prevRoomState.status === AsyncStatus.Loading ? (
<Spinner size="50" variant="Secondary" fill="Soft" />
) : undefined
}
>
<Text size="B300">Join Old Room</Text>
</Button>
))}
</Box>
</Box>
</Box>

View file

@ -1,5 +1,5 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
import { color, config } from 'folds';
export const SplashScreen = style({
minHeight: '100%',
@ -7,11 +7,6 @@ export const SplashScreen = style({
color: color.Background.OnContainer,
});
export const SplashLogo = style({
width: toRem(42),
height: toRem(42),
});
export const SplashScreenFooter = style({
padding: config.space.S400,
});

View file

@ -3,33 +3,26 @@ import React, { ReactNode } from 'react';
import classNames from 'classnames';
import * as patternsCSS from '../../styles/Patterns.css';
import * as css from './SplashScreen.css';
import CinnySVG from '../../../../public/res/svg/cinny.svg';
type SplashScreenProps = {
footer?: ReactNode;
children?: ReactNode;
children: ReactNode;
};
export function SplashScreen({ footer, children }: SplashScreenProps) {
export function SplashScreen({ children }: SplashScreenProps) {
return (
<Box
className={classNames(css.SplashScreen, patternsCSS.BackgroundDotPattern)}
direction="Column"
>
{children || (
<Box grow="Yes" direction="Column" alignItems="Center" justifyContent="Center" gap="400">
<img className={css.SplashLogo} src={CinnySVG} alt="Cinny Logo" />
<Text size="H2" align="Center">
Cinny
</Text>
</Box>
)}
{children}
<Box
className={css.SplashScreenFooter}
shrink="No"
alignItems="Center"
justifyContent="Center"
>
{footer}
<Text size="H2" align="Center">
Cinny
</Text>
</Box>
</Box>
);

View file

@ -5,6 +5,7 @@ import * as css from './styles.css';
import { PickerColumn } from './PickerColumn';
import { hour12to24, hour24to12, hoursToMs, inSameDay, minutesToMs } from '../../utils/time';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
type TimePickerProps = {
min: number;
@ -14,7 +15,7 @@ type TimePickerProps = {
};
export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
({ min, max, value, onChange }, ref) => {
const [hour24Clock] = useSetting('hour24Clock');
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const hour24 = dayjs(value).hour();

View file

@ -1,7 +1,7 @@
import React, { useEffect, useCallback } from 'react';
import { Dialog, Text, Box, Button, config } from 'folds';
import { AuthType } from 'matrix-js-sdk';
import { StageComponentProps } from './types';
import { AuthType } from '../../utils/matrix-uia';
function DummyErrorDialog({
title,

View file

@ -1,10 +1,9 @@
import React, { useEffect, useCallback, FormEventHandler } from 'react';
import { Dialog, Text, Box, Button, config, Input, color, Spinner } from 'folds';
import { printMatrixError } from 'neurone/lib/matrix-request';
import { AuthType, MatrixError } from 'matrix-js-sdk';
import { StageComponentProps } from './types';
import { AsyncState, AsyncStatus } from '../../hooks/useAsyncCallback';
import { RequestEmailTokenCallback, RequestEmailTokenResult } from '../../hooks/types';
import { AuthType } from '../../utils/matrix-uia';
import { RequestEmailTokenCallback, RequestEmailTokenResponse } from '../../hooks/types';
function EmailErrorDialog({
title,
@ -78,14 +77,10 @@ export function EmailStageDialog({
}: StageComponentProps & {
email?: string;
clientSecret: string;
emailTokenState: AsyncState<RequestEmailTokenResult, Error>;
emailTokenState: AsyncState<RequestEmailTokenResponse, MatrixError>;
requestEmailToken: RequestEmailTokenCallback;
}) {
const { errorCode, error, session } = stageData;
const [matrixError, result] =
emailTokenState.status === AsyncStatus.Success ? emailTokenState.data : [];
const requestError =
emailTokenState.status === AsyncStatus.Error ? emailTokenState.error : undefined;
const handleSubmit = useCallback(
(sessionId: string) => {
@ -125,23 +120,14 @@ export function EmailStageDialog({
);
}
if (requestError) {
if (emailTokenState.status === AsyncStatus.Error) {
return (
<EmailErrorDialog
title={requestError.name ?? 'Verify Email'}
message={requestError.message ?? 'Failed to send verification Email request.'}
onRetry={handleEmailSubmit}
onCancel={onCancel}
/>
);
}
if (matrixError) {
return (
<EmailErrorDialog
title={matrixError?.data.errcode ?? 'Verify Email'}
title={emailTokenState.error.errcode ?? 'Verify Email'}
message={
matrixError ? printMatrixError(matrixError) : 'Failed to send verification Email request.'
emailTokenState.error?.data?.error ??
emailTokenState.error.message ??
'Failed to send verification Email request.'
}
onRetry={handleEmailSubmit}
onCancel={onCancel}
@ -149,19 +135,19 @@ export function EmailStageDialog({
);
}
if (result) {
if (emailTokenState.status === AsyncStatus.Success) {
return (
<Dialog>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="100">
<Text size="H4">Verification Request Sent</Text>
<Text>{`Please check your email "${result.email}" and validate before continuing further.`}</Text>
<Text>{`Please check your email "${emailTokenState.data.email}" and validate before continuing further.`}</Text>
{errorCode && (
<Text style={{ color: color.Critical.Main }}>{`${errorCode}: ${error}`}</Text>
)}
</Box>
<Button variant="Primary" onClick={() => handleSubmit(result.result.sid)}>
<Button variant="Primary" onClick={() => handleSubmit(emailTokenState.data.result.sid)}>
<Text as="span" size="B400">
Continue
</Text>

View file

@ -1,78 +0,0 @@
import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds';
import React, { useCallback } from 'react';
import { ObjectReader } from 'neurone/lib/utils';
import { StageComponentProps } from './types';
export function OAuthStage({ stageData, submitAuthDict, onCancel }: StageComponentProps) {
const { errorCode, error, session, info } = stageData;
const url = ObjectReader.string(info, 'url');
const handleSubmit = useCallback(() => {
submitAuthDict({
session,
});
}, [submitAuthDict, session]);
return (
<Dialog>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">Reset encryption identity</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box
style={{ padding: `0 ${config.space.S400} ${config.space.S400}` }}
direction="Column"
gap="400"
>
<Text size="T200">
To reset your end-to-end encryption cross-signing identity, you need to approve this
request from your account dashboard.
</Text>
<Box direction="Column" gap="100">
<Text size="T200">
<b>1.</b> Open your account dashboard and approve the request.
</Text>
<Text size="T200">
<b>2.</b> Return here after approving and Continue.
</Text>
</Box>
{errorCode && (
<Box alignItems="Center" gap="100" style={{ color: color.Critical.Main }}>
<Icon size="50" src={Icons.Warning} filled />
<Text size="T200">
<b>{`${errorCode}: ${error}`}</b>
</Text>
</Box>
)}
<Box direction="Column" gap="300">
<Button
variant="Secondary"
onClick={() => window.open(url, '_blank', 'noopener,noreferrer')}
>
<Text as="span" size="B400">
Open account dashboard
</Text>
</Button>
<Button variant="Primary" onClick={handleSubmit}>
<Text as="span" size="B400">
Continue
</Text>
</Button>
</Box>
</Box>
</Dialog>
);
}

View file

@ -1,9 +1,9 @@
import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds';
import React, { FormEventHandler } from 'react';
import { AuthType } from 'matrix-js-sdk';
import { StageComponentProps } from './types';
import { ErrorCode } from '../../cs-errorcode';
import { PasswordInput } from '../password-input';
import { AuthType } from '../../utils/matrix-uia';
export function PasswordStage({
stageData,

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