Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b33d8de956 | ||
|
|
a4da78edc5 | ||
|
|
9b66b2b250 | ||
|
|
6950da865e | ||
|
|
8f1bb54b4b | ||
|
|
b20b958762 | ||
|
|
00ec686b1b | ||
|
|
873c0e30a2 | ||
|
|
7453a0bd32 | ||
|
|
2c04bbefa3 | ||
|
|
cdfd43fe9f | ||
|
|
f6244a4781 | ||
|
|
719f062ef1 | ||
|
|
44395328e9 | ||
|
|
e59cc93a2a | ||
|
|
2827699439 | ||
|
|
3d4fb161c3 | ||
|
|
c91d0339b5 | ||
|
|
bfb753e3e5 | ||
|
|
eeb9de8f40 | ||
|
|
98d74d564d | ||
|
|
d7afdc5208 | ||
|
|
2e179a7768 | ||
|
|
44774a3ba1 | ||
|
|
0091e5e90d | ||
|
|
a058d2a2c6 | ||
|
|
d07c7f2327 | ||
|
|
11f0a4d325 | ||
|
|
1cb18a2795 | ||
|
|
6eae929cd9 | ||
|
|
1dfe38a33d | ||
|
|
0b7ded9c99 | ||
|
|
41527f6ba3 | ||
|
|
e8efd3ce76 | ||
|
|
067cb10a90 | ||
|
|
c848626caa | ||
|
|
4d0ee78974 | ||
|
|
f810a83a28 | ||
|
|
e90b70e966 | ||
|
|
f2e3223c5f | ||
|
|
2924a3f4f3 | ||
|
|
841784ea64 | ||
|
|
dd88424e5d | ||
|
|
b8e696c81b | ||
|
|
a588ab82b3 | ||
|
|
1e6412ed5b |
||
|
|
14e490c896 |
563 changed files with 19376 additions and 30860 deletions
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,4 +2,5 @@ dist
|
|||
node_modules
|
||||
package.json
|
||||
package-lock.json
|
||||
LICENSE
|
||||
LICENSE
|
||||
harness
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"format_on_save": "on",
|
||||
"prettier": {
|
||||
"parser": "",
|
||||
"allowed": true
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
export default {
|
||||
base: '/',
|
||||
base: process.env.CINNY_BASE ?? '/',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
{}
|
||||
12
config.json
12
config.json
|
|
@ -5,17 +5,7 @@
|
|||
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
"spaces": [
|
||||
"#cinny:matrix.org",
|
||||
"#community:matrix.org",
|
||||
"#space:unredacted.org",
|
||||
"#librewolf-community:matrix.org",
|
||||
"#stickers-and-emojis:tastytea.de",
|
||||
"#videogames:waywardinn.com",
|
||||
"#science-space:matrix.org",
|
||||
"#libregaming-games:tchncs.de",
|
||||
"#mathematics-on:matrix.org"
|
||||
],
|
||||
"spaces": ["#gamecult:gamecult.org"],
|
||||
"rooms": [
|
||||
"#tuwunel:grin.hu",
|
||||
"#freesoftware:matrix.org",
|
||||
|
|
|
|||
352
docs/gamecult/ratatoskr-target.md
Normal file
352
docs/gamecult/ratatoskr-target.md
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
# Ratatoskr: target and prose map
|
||||
|
||||
Ratatoskr is GameCult's Matrix chat client: this fork of Cinny, forge
|
||||
`GameCult/cinny` (to be renamed `GameCult/Ratatoskr`; see the cut sequence).
|
||||
Void stewards it. Campaign `ratatoskr` lives in the eureka mind for now, beside
|
||||
the rulings it rests on. Under `eureka-body:ruling:ruling-home-steward-mind` it
|
||||
is handed to Void's mind once, by procedure, as soon as that hand-off procedure
|
||||
exists (`hand_off` is parked); after that every new ruling on it is admitted in
|
||||
Void's mind. Nothing here is written into Void's mind before then.
|
||||
|
||||
The mind holds the questions, rulings, specs, reports and verdicts. This file
|
||||
holds what no document kind carries: the design truths, body facts, the model
|
||||
page, rationale, the reconstructed collaboration plan, and the cut sequence.
|
||||
All times are UTC.
|
||||
|
||||
## Why
|
||||
|
||||
GameCult is the only server this client talks to. A general Matrix client
|
||||
treats it as one community among thousands, so the operator meets GameCult's
|
||||
structure three clicks deep in an invite list of 50+ rooms (operator, `#swarm`
|
||||
10-31 05:14: "The biggest pain is navigation. GameCult has a structure."). The
|
||||
client should open on GameCult's estate as data: its spaces, its project rooms
|
||||
grouped by steward, its Minds, and the person's own place in it. It should
|
||||
also be a pleasant place to be in its own right: emotes, sound, and weather in
|
||||
the background.
|
||||
|
||||
## Design truths (from rulings in force)
|
||||
|
||||
- **We own the interface, not the engine.** The fork keeps Cinny's engine
|
||||
(matrix-js-sdk, Rust crypto, sync) and replaces the whole UI; every
|
||||
user-facing string and its translation is ours, and upstream locale files
|
||||
are not the source (`gamecult-chat:ruling:we-own-the-ui-and-translation`).
|
||||
- **Mobile first.** On phones, inactive elements hide, vertical space is
|
||||
cheaper than horizontal, and text never gets squeezed into narrow columns
|
||||
(`eureka-body:ruling:chat-client-base-cinny-mobile-first`).
|
||||
- **The first spec** (`eureka-body:ruling:chat-client-first-spec`): an emote
|
||||
slot on every bubble (a pose id carried on the event); room and participant
|
||||
lists tiered (category, then items) and reached from the header menu, not
|
||||
side columns; participants grouped by estate (Minds, Members, Users,
|
||||
Patrons); rooms grouped by kind (general, estates, projects, work rooms);
|
||||
Minds indexed by stewardship, read from Matrix state projected from the
|
||||
stewardship records in the Minds' own minds (the minds stay the truth); one
|
||||
room per project, a Space child, with the steward joined.
|
||||
- **Rooms are visible without a join dance.** No invite screen in the way; a
|
||||
room the server lets you join shows in the same list as joined ones; opening
|
||||
one shows its name and a one-tap Join; joining happens on that tap or a first
|
||||
post, never silently; no peeking (operator, `#swarm` 10-31 04:08, 04:12
|
||||
"Peeking sounds like YAGNI to me", 04:14 "One tap is a reasonable cost").
|
||||
Rooms you may not enter are refused by the server, not hidden by the client
|
||||
(Heimdall 04:10, accepted by Void 04:11 and 04:15).
|
||||
- **Language.** A user's language is the BCP 47 tag in profile field
|
||||
`org.gamecult.language`, owned by the user through the client's language
|
||||
picker (`gamecult-chat:ruling:user-language-profile-field`). The client ships
|
||||
localized; people talk with stewards in their own language; rooms are made
|
||||
for a language, with no per-language mirrors
|
||||
(`gamecult-chat:ruling:multilingual-client-stewards-language-rooms`). One
|
||||
host Mind sits in a language room, Weksa for `#pt-br`
|
||||
(`gamecult-chat:ruling:language-room-one-host-weksa`). The first person this
|
||||
must work for is a non-English, non-technical patron (placeholder
|
||||
`patron-pt1`) whose account we set up and walk through login
|
||||
(`gamecult-chat:ruling:patron-in-his-own-language`).
|
||||
- **Push.** `gamecult-chat:ruling:chat-push` rules out native mobile push for
|
||||
now; Web Push to the installed web app is a different thing and is open
|
||||
(question `ratatoskr:question:pwa-web-push`).
|
||||
- **Collaboration is recorded, not chatted.** Work spanning Minds belongs in
|
||||
Atlas (`eureka-body:ruling:atlas-records-collaborative-work`). Atlas is not
|
||||
deployed, so the plan below is its interim home; Atlas imports it later.
|
||||
|
||||
## Body facts
|
||||
|
||||
Probes run by Imagination on 2026-10-31, session `self-2026-10-29-eureka`.
|
||||
|
||||
1. **Branch.** `void/gamecult-theme-and-project-nav` is `b8e696c8` over main
|
||||
`1e6412ed` (v4.12.8), 2 commits, 14 files, +1153/-32 (686 of it lockfile):
|
||||
a GameCult theme as default, member drawer hidden by default, bubble
|
||||
layout, and steward grouping in `src/app/pages/client/space/Space.tsx`
|
||||
(`useStewardGroups` at 429-437, group headers at 505-524) over
|
||||
`useSpaceJoinedHierarchy` (409). Read by `git diff`.
|
||||
2. **Live estate shape** (Synapse on Yggdrasil, read as `admin` over the
|
||||
client API, counts only). Root Space `#gamecult:gamecult.org`, room version
|
||||
12, has 9 children: 8 rooms and the `Projects` sub-Space. `Projects` has 52
|
||||
children; 50 readable rooms, all room version 12. Server `/hierarchy` from
|
||||
the root returns 59 rooms, 2 of them spaces. **Every room's join rule is
|
||||
`invite`**; none is world-readable. 50 rooms carry `org.gamecult.steward`
|
||||
with content `{mind, repo, source, steward: <mxid>}`. The root carries
|
||||
`org.gamecult.stewardships`: Mind localpart to list of `Org/Repo`.
|
||||
3. **Consequence of 1 and 2.** Project rooms are invite-only and Members are
|
||||
invited, not joined (gamecult-ops `runbooks/gamecult-chat-host-yggdrasil.md`
|
||||
146-158: "Members and every Mind are invited; the steward alone joins").
|
||||
So a Member's project rooms are pending invites. Void's grouping reads
|
||||
`useSpaceJoinedHierarchy` and a per-room state event that an invitee cannot
|
||||
read, so for a typical Member it groups nothing and the rooms stay in the
|
||||
invite list. This is the operator's "three clicks deep" complaint, seen from
|
||||
the data.
|
||||
4. **Provisioning contract.** gamecult-ops
|
||||
`scripts/synapse-accounts-hand-managed.py` writes the rooms and both state
|
||||
events from the Minds' stewardship documents; each project room's alias is
|
||||
`#<repo lowercased>` (runbook 146-150). This is the only place the client
|
||||
can learn a not-yet-joined room's steward today: hierarchy `canonical_alias`
|
||||
joined to the root `org.gamecult.stewardships`. Follow-up
|
||||
`ratatoskr:follow_up:stewardships-carry-room-ids` asks the projection to
|
||||
name room ids so the client stops leaning on the alias convention.
|
||||
5. **Sync and storage** (`src/client/initMatrix.ts`). The client uses
|
||||
matrix-js-sdk 41.7.0 with `IndexedDBStore` (`web-sync-store`, 15-19) and the
|
||||
Rust crypto store, and `startClient({ lazyLoadMembers: true })` (43-47): v2
|
||||
`/sync`, no sliding sync, no custom filter or `initialSyncLimit`. A cold
|
||||
start is therefore an IndexedDB load plus an incremental sync from the
|
||||
stored token, not a full initial sync.
|
||||
6. **Server sync support.** `/_matrix/client/versions` on Synapse 1.164.1
|
||||
(127.0.0.1:8834) advertises `org.matrix.simplified_msc3575: true`:
|
||||
simplified sliding sync (MSC4186) is available. Cinny 4.12.8 does not use it.
|
||||
7. **Shell delivery.** `https://chat.gamecult.org/assets/index-DWIvG3QT.js` is
|
||||
served at 3,736,520 bytes with no `Content-Encoding` even when gzip and br
|
||||
are offered, and with no `Cache-Control` (only `ETag`/`Last-Modified`). The
|
||||
service worker (`src/sw.ts`, 158 lines) handles only `install`, `activate`,
|
||||
a session `message`, and authenticated media `fetch` (79-158); the
|
||||
`vite-plugin-pwa` config sets `injectionPoint: undefined`
|
||||
(`vite.config.ts` 101-113), so nothing is precached.
|
||||
8. **No push path.** `src/sw.ts` has no `push` or `notificationclick` handler;
|
||||
no code subscribes through `PushManager`; the only pusher the client sets is
|
||||
the email pusher (`src/app/features/settings/notifications/SystemNotification.tsx`
|
||||
22-40). Notifications and sounds fire only from a live page:
|
||||
`src/app/pages/client/ClientNonUIFeatures.tsx` 79-129 (invites) and 131-258
|
||||
(messages: `RoomEvent.Timeline` at 181-235, `new window.Notification` at
|
||||
156, `notification.ogg` played at 175-178). Nothing in `src/` listens for
|
||||
`visibilitychange` or calls `retryImmediately`.
|
||||
9. **Sygnal WebPush** (matrix-org/sygnal `docs/applications.md`, read
|
||||
2026-10-31; the page itself is marked partly out of date): a `webpush` app
|
||||
takes `vapid_private_key`, `vapid_contact_email`, optional
|
||||
`allowed_endpoints` and `ttl`; the client's pusher `data` carries
|
||||
`endpoint`, `auth`, `p256dh` and optional `default_payload`; the push
|
||||
carries `room_id`, `room_name`, `sender_display_name`, `content`, unread
|
||||
counts and so on; browsers need a visible notification for each push
|
||||
(`events_only` avoids event-less pushes).
|
||||
|
||||
10. **Baseline at `dd88424e`** (Yggdrasil, `ygg-verify.sh`, image
|
||||
`node:22-bookworm`, `npm ci --ignore-scripts`): `vitest run` passes (1
|
||||
file, 5 tests, Void's steward grouping); `vite build` succeeds, `dist/` 61
|
||||
MB with source maps, main chunk 3,739,716 bytes, Rust crypto wasm
|
||||
5,573,870 bytes, `dist/sw.mjs` 1.66 kB. `tsc --noEmit` fails at base with
|
||||
793 errors in 246 files (455 of them TS2614, named imports from
|
||||
`matrix-js-sdk` 41.7.0 not resolved), so typecheck is not a usable gate as
|
||||
is; a cut gates on zero errors in the files it adds and no rise in the
|
||||
total.
|
||||
|
||||
Not probed: phone frame times, the real resume timeline on the operator's
|
||||
Android Chrome install (needs her device or remote DevTools), the Rust crypto
|
||||
wasm size, and the router base under `/preview/` (its `config.json` sets
|
||||
`hashRouter.basename` to `/`).
|
||||
|
||||
## Why the PWA resumes slowly (diagnosis, moderate confidence)
|
||||
|
||||
Android freezes a backgrounded installed web app and later discards it. On a
|
||||
**discard**, returning is a full cold boot: 3.7 MB of uncompressed JS over a
|
||||
phone link with no precache (fact 7), Rust crypto wasm init, the IndexedDB
|
||||
store load, then a catch-up `/sync` over every room since the stored token
|
||||
(fact 5). On a **freeze without discard**, the in-flight long-poll is dead, and
|
||||
the SDK only notices on its own timers because the client never nudges it on
|
||||
`visibilitychange` (fact 8). The catch-up sync grows with the number of rooms
|
||||
and the swarm's chat volume, which is exactly where sliding sync helps
|
||||
(fact 6), but the first two costs are cheaper to remove and should be measured
|
||||
first. Without Web Push, a frozen page cannot notify at all, which is the
|
||||
second half of the operator's report.
|
||||
|
||||
## Model page: the estate
|
||||
|
||||
| Kind | Named by | Over time | Who decides |
|
||||
|---|---|---|---|
|
||||
| Stewardship | `<instance>:stewardship:<repo>.n<seq>` in each Mind's mind | sequence advances on hand-over | the operator and the Minds; the minds are the truth |
|
||||
| Estate projection | `org.gamecult.stewardships` on the root Space; `org.gamecult.steward` per project room | re-PUT on every provisioning run | gamecult-ops provisioning script (Eureka's slice) |
|
||||
| Estate room list | the server's `/hierarchy` of the root Space for this user | changes with Space children and the user's invites and joins | Synapse (membership and join rules); the client derives |
|
||||
| Room membership | the user's `m.room.member` | join on tap or first post | the user |
|
||||
| Navigator view | derived per render | never stored | the client's estate index |
|
||||
|
||||
## Authority map for the navigator
|
||||
|
||||
- **Owner:** one estate index in the client (`src/app/features/estate/`):
|
||||
`buildEstate()` is pure, `useEstate()` feeds it. It decides what rooms the
|
||||
person sees, in which tier and group, and with what membership affordance.
|
||||
- **Inputs:** the paged server `/hierarchy` of the root Space; root Space
|
||||
state `org.gamecult.stewardships`; the person's membership per room from the
|
||||
client store; unread and selection atoms for emphasis only; the `m.direct`
|
||||
list for the direct-messages tier.
|
||||
- **Outputs:** an ordered list of tiers (one per root room group and per
|
||||
sub-Space, plus Direct), each holding steward groups or rooms, each room
|
||||
marked joined, invited or joinable.
|
||||
- **Derived:** steward group labels (Mind display names); counts; collapsed
|
||||
state is per-device UI state only.
|
||||
- **Demoted or dead:** the sidebar rail and its account-data ordering
|
||||
(`useSidebarItems`) are dead; `useSpaceJoinedHierarchy` no longer decides
|
||||
what is listed; Void's per-room steward reader is deleted, not kept beside
|
||||
the index.
|
||||
- **Shared paths:** every nav slot (home, direct, space routes) renders the
|
||||
same navigator; tapping Join and sending a first post both go through
|
||||
`mx.joinRoom`; deep links into a room still resolve through the existing
|
||||
room routes.
|
||||
|
||||
## Reconstructed collaboration plan (Atlas import)
|
||||
|
||||
Planned in `#swarm` and `#minds` on 2026-10-31 and then lost to scroll. Status
|
||||
as of 2026-10-31 ~15:00.
|
||||
|
||||
**Who holds which part**
|
||||
|
||||
| Part | Holder | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| The client: integration, navigator, emote slot, sound hookup, background canvas | Void (steward) | theme and steward grouping on branch; nothing else built | `void:stewardship:gamecult_-cinny.n1`; `#swarm` 10-31 14:54 Void report |
|
||||
| Dings earcons: motif per event kind, one stable instrument per Mind | Aqua | not rendered: render env blocked, then .NET SDK installed and Faust scouted; AquaSynth PR #1 (Linux Faust loader, `faust-loader-platform-aware`) merged at 3b43b7bd | `aqua-body:follow_up:chat-s410-416`, `aqua-body:follow_up:chat-s410-416-render-env`, `aqua-body:resolution:follow_up.chat-s430-430.n1`, forge AquaSynth pull 1 |
|
||||
| Ambient soundscape for the client | Aqua | idea only; no design recorded | operator `#swarm` 10-31 08:32 ("Motifs for everything"), `eureka-body:ruling:atlas-records-collaborative-work` |
|
||||
| Background math, in CultMath: the public `fbm` gap, Phacelle erosion's Lipschitz bound, any new GLSL mirrors | Libby (steward of CultLib, which owns CultMath; holds `cultmath-tapes`) | sources located; nothing built | `#swarm` 10-31 06:34 and 06:46 Libby; `libby-body:follow_up:cultmath-ts-noise-absent`; `#swarm` 04:22 Libby accepts `cultmath-tapes` |
|
||||
| Background scene and render: the raymarched terrain, the tree-and-squirrel intro scene, lighting and atmosphere, march and TAA tuning on the site-ground framework | Nibu (steward of Aetheria, whose volumetric nebula and cloud shaders she owns: `Raymarching/CloudShader.shader`, `Volumetric.cginc`) | proposed by the operator; not yet taken up by Nibu | operator to Self, 10-31: "If we want to spread the work around, Nibu owns the nebulae so she has shader expertise." |
|
||||
| Animated bubbles, flourishes, corner avatar and XP bar | Void, with Kiko's offer on juice | advice only | `#swarm` 10-31 03:59 Kiko and Void, 06:45 Void |
|
||||
| Attention bubbles | Eureka (Huginn heartbeat publishes), Void (render), Heimdall (scope rules) | advice only | `#swarm` 10-31 04:00 Eureka, 04:02 Heimdall |
|
||||
|
||||
**Agreements**
|
||||
|
||||
- Dings are pre-rendered clips shipped as static assets, no synth in the
|
||||
browser: `dings/<mxid-localpart>/<motif>.ogg` plus a small manifest; an
|
||||
unknown sender falls back to a neutral ding; ogg or mp3, not wav (Void
|
||||
`#minds` 03:19, Aqua 07:41). The set is motif times instrument, and the
|
||||
instrument says who spoke (operator 03:18, Eureka 03:19). Events that sound:
|
||||
a mention, a DM, an invite, a message in an unmuted room; mentions and DMs
|
||||
loud, ordinary messages quiet; everything else silent (Void `#minds` 03:19).
|
||||
The operator wants far more than four motifs (08:32). The instrument per
|
||||
Mind belongs in a typed document on the Mind side, not hardcoded (Eureka
|
||||
03:19, advice). Aqua's ear check comes before anything is called done, and
|
||||
the operator's ear is ground truth (Aqua 07:41). Browsers block audio until
|
||||
the first click, so the first ding after a fresh load may be silent.
|
||||
- The background has its own canvas and budget, separate from the bubble
|
||||
flourishes; capped at 30 fps, paused when hidden; under reduced motion one
|
||||
static frame; an adaptive step-down (Aqua 04:17, Void 04:18 and 04:22).
|
||||
- The background is an infinite raymarched terrain: CultMath noise plus the
|
||||
Phacelle erosion filter, in the shader (operator 04:18 "painter's algorithm",
|
||||
05:24 "all we need is noise", 05:25 erosion, restated by the operator to
|
||||
Self on 10-31: "use CultMath noise and erosion filter running in the shader
|
||||
to have an infinite terrain. We can just raymarch that instead"). Voronoi
|
||||
and Apollonian are dropped (05:24).
|
||||
- Yggdrasil is the scene's centre (operator to Self, 10-31: "Yggdrasil should
|
||||
be the star of the show now, so really the camera should just pull back a
|
||||
bit when you login and the personas should be orbiting the tree, dancing
|
||||
around each other in accordance with their activity"). On login the camera
|
||||
pulls back from the tree; there is no separate intro to cut away from. The
|
||||
Personas orbit the tree and move around each other by their live activity.
|
||||
- One activity vocabulary drives both sound and motion: Aqua's activity-kinds
|
||||
grammar (`aqua-body:follow_up:chat-s431-432`, "enumerate the kinds of agent
|
||||
activity in typed state") is the shared contract between Aqua's motifs and
|
||||
Nibu's orbits, not two vocabularies. The live feed behind it is the same
|
||||
typed state the swarm dashboard projects
|
||||
(`eureka-body:ruling:swarm-dashboard-first-thing2-surface`, Libby;
|
||||
`libby-body:follow_up:chat-s354-359`): Run documents, the heartbeat trace.
|
||||
What reaches the client is a member-safe projection: kind, Mind, time and
|
||||
intensity, never briefs or mind contents. Matrix presence and typing cover
|
||||
humans and are too coarse for Minds. Where the feed comes from is
|
||||
`ratatoskr:question:activity-feed-source`.
|
||||
- The scene renders in two passes, not one loop (operator to Self, 10-31:
|
||||
"The tape optimized solid surface march should run really fast, i imagine,
|
||||
way faster than the volumetric version, so they should not be one loop").
|
||||
Pass 1, the solid-surface march (terrain, tree, squirrel), sphere-traces
|
||||
with the field's Lipschitz bound and interval tile culling, at its own
|
||||
resolution, and writes depth. Pass 2, the volumetric atmosphere march on
|
||||
the site-ground framework (reduced resolution, TAA), is bounded by pass 1's
|
||||
depth, so it marches only up to the surface hit. Each pass has its own
|
||||
budget and its own measurement. Nibu owns both passes.
|
||||
- The web math path is CultLib's own: HLSL source lowered by dxc and
|
||||
SPIRV-Cross to WebGL2 GLSL ES (`packages/cultmath/shaders/CultMath.glsl`,
|
||||
`.Phacelle.glsl`; `tools/glsl/build-glsl.sh`), and C# CPU math through
|
||||
.NET wasm, with parity presumed for now (operator 06:23, 06:48; Libby
|
||||
06:34). No TS or WGSL port. The gamecult.org site ground already
|
||||
concatenates `CultMath.glsl` in `ground.js` and runs an interval-culled,
|
||||
TAA-reprojected per-pixel march (CultLib `docs/cultmath-interval-ground-cut.md`,
|
||||
spec `cultmath-interval-ground-cut-site-ground.r3`): that framework is the
|
||||
base, not new tape work.
|
||||
- Gaps go to the owner: a plain public `fbm` (only `fbm_grad`, `span_fbm`,
|
||||
`af_fbm` exist) is added in CultMath, not in the client
|
||||
(`libby-body:follow_up:cultmath-ts-noise-absent`; Libby 07:57 "tell me what
|
||||
you need and I'll fill it at the library").
|
||||
- Mood and flourish ride as metadata on the event, never guessed from text;
|
||||
bounded per-frame budget; reduced-motion fallback (Void 03:59, Kiko 03:59).
|
||||
- XP is two numbers, never one: a cosmetic, labelled game bar fed by things
|
||||
worth doing, and contribution credit shown only once a real measure exists
|
||||
(Void 06:45, with Nibu and Kiko).
|
||||
|
||||
**Open items**
|
||||
|
||||
- Libby: the raymarch step bound needs the field's Lipschitz bound. CultMath
|
||||
tracks `SNOISE_LIPSCHITZ` for noise; erosion's bound is unknown and must be
|
||||
probed before the march is trusted.
|
||||
- Libby: public `fbm` in CultMath, with its GLSL mirror.
|
||||
- Nibu (once she takes it): the terrain scene, lighting and atmosphere, and
|
||||
march and TAA tuning on the site ground's framework at a phone budget;
|
||||
waits on Libby's `fbm` and the erosion bound. Then the intro scene in the
|
||||
same world.
|
||||
- Aqua: render the first earcon set once Faust builds in her sandbox; then the
|
||||
instrument-per-Mind list and the manifest; then the soundscape design.
|
||||
- Aqua and Nibu: agree the activity vocabulary once (Aqua's grammar), so
|
||||
motifs and orbits read the same kinds.
|
||||
- Void: the navigator (next cut); the Dings hook at
|
||||
`ClientNonUIFeatures.tsx` 131-258; the background canvas after Libby's shader.
|
||||
- Eureka: compression and caching for the shell on the chat host
|
||||
(`ratatoskr:follow_up:chat-shell-compression`); the stewardship projection's
|
||||
room ids; a Web Push gateway if the operator picks it.
|
||||
|
||||
## Cut sequence
|
||||
|
||||
`estate-navigator` is specced (`ratatoskr:cut_spec:cut-estate-navigator.r1`).
|
||||
Each later cut is one line, with what it waits on.
|
||||
|
||||
- `resume-fast`: precache the app shell in the service worker, nudge sync on
|
||||
`visibilitychange`, and measure cold and warm resume on the operator's phone
|
||||
before and after; waits on nothing; runs beside `estate-navigator` in its
|
||||
own worktree, and pairs with the ops follow-up on compression.
|
||||
- `pwa-web-push`: service worker `push` and `notificationclick` handlers, a
|
||||
`webpush` pusher registered with VAPID keys, and a self-hosted Sygnal on the
|
||||
chat host; waits on `ratatoskr:question:pwa-web-push`.
|
||||
- `sliding-sync`: move the client to simplified sliding sync (MSC4186), which
|
||||
the server already offers; waits on `resume-fast`'s measurements showing
|
||||
catch-up sync dominates.
|
||||
- `dings`: per-Mind earcons through the manifest at the existing notification
|
||||
hook, and the same motifs in push notifications; waits on Aqua's renders.
|
||||
- `emote-slot`: the pose-id emote on every bubble and the picker; waits on
|
||||
`estate-navigator` (shared header work).
|
||||
- `participant-tiers`: participants tiered by estate in the header menu, Minds
|
||||
indexed by stewardship; waits on `estate-navigator`.
|
||||
- `language`: our own strings and translations, the `org.gamecult.language`
|
||||
picker, and the walk-through login for `patron-pt1`; waits on
|
||||
`estate-navigator`.
|
||||
- `terrain-background`: the raymarched CultMath noise and Phacelle terrain on
|
||||
the site ground's march and TAA framework, in its own canvas and budget, as
|
||||
two passes (solid-surface sphere trace writing depth, then the volumetric
|
||||
atmosphere bounded by it), each measured on a phone; Nibu on the scene and
|
||||
both passes, Libby's CultMath underneath, Void
|
||||
integrating; waits on Libby's `fbm` and the erosion Lipschitz probe (both in
|
||||
CultLib), then a phone measurement.
|
||||
- `yggdrasil-scene`: the tree and squirrel at the centre of the terrain world;
|
||||
on login the camera pulls back from the tree, and it may play during the
|
||||
cold start or resume sync but never holds the client up; Nibu on the scene,
|
||||
Libby on any CultMath it needs, Void integrating, Aqua's soundscape under
|
||||
it; waits on `terrain-background`.
|
||||
- `orbiting-personas`: Personas orbit the tree and dance around each other by
|
||||
live activity, from the shared activity vocabulary; waits on
|
||||
`yggdrasil-scene`, Aqua's activity-kinds grammar, and
|
||||
`ratatoskr:question:activity-feed-source`.
|
||||
- `bubbles-and-juice`: animated bubbles and mood flourishes from event
|
||||
metadata, the corner avatar and the cosmetic XP bar; waits on `emote-slot`.
|
||||
- `attention-bubbles`: per-person attention bubbles from Huginn's typed
|
||||
attention document, scoped to room members; waits on Huginn publishing it.
|
||||
- `rename-ratatoskr`: package name, app name and branding, manifest and
|
||||
titles, the `/preview/` path and its deploy, and every in-repo `cinny`
|
||||
reference that is ours (not upstream engine paths); waits on Void's forge
|
||||
rename of `GameCult/cinny` to `GameCult/Ratatoskr`
|
||||
(`eureka-body:ruling:chat-client-named-ratatoskr`), and the campaign's repo
|
||||
list moves with it.
|
||||
46
harness/estate/check.cjs
Normal file
46
harness/estate/check.cjs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Assertions for the estate drives: every drive states the outcome it exists to show and exits
|
||||
// non-zero when the page or the fake homeserver's log says otherwise.
|
||||
const fs = require('fs');
|
||||
const fails = [];
|
||||
exports.expect = (label, ok, detail = '') => {
|
||||
console.log(`[ASSERT] ${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` :: ${detail}`}`);
|
||||
if (!ok) fails.push(label);
|
||||
};
|
||||
/** Exit code for the drive: 4 when any assertion failed. */
|
||||
exports.done = () => (fails.length ? 4 : 0);
|
||||
/** The fake homeserver's request log (HSLOG is shared with the server process). */
|
||||
exports.hsLog = () => fs.readFileSync(process.env.HSLOG, 'utf8').split('\n');
|
||||
/** Bodies of the account-data PUTs for m.direct, in order. */
|
||||
exports.mDirectPuts = () =>
|
||||
exports.hsLog().filter((l) => / PUT .*m\.direct BODY /.test(l)).map((l) => l.split(' BODY ')[1]);
|
||||
/** Join requests the server saw for a room id. */
|
||||
exports.joinsOf = (roomId) => exports.hsLog().filter((l) => new RegExp(` JOIN ${roomId}$`).test(l)).length;
|
||||
const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
|
||||
/** The tier header above a row, 'absent' when the page does not show the row, 'none-above' when no tier header precedes it. */
|
||||
const tierOf = (pg, name) =>
|
||||
pg.evaluate((n) => {
|
||||
const s = document.body.innerText.split('\n').map((x) => x.trim());
|
||||
const i = s.indexOf(n);
|
||||
if (i < 0) return 'absent';
|
||||
for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j];
|
||||
return 'none-above';
|
||||
}, name);
|
||||
/**
|
||||
* The tier a room is listed under, read from a fresh visit to the room list: a collapsed tier is
|
||||
* opened only if the row is not already showing, so reading never closes what is open.
|
||||
*/
|
||||
exports.revealedTier = async (pg, name) => {
|
||||
await pg.goto('http://127.0.0.1:8080/#/home/');
|
||||
await pg.waitForTimeout(2500);
|
||||
let t = await tierOf(pg, name);
|
||||
for (const h of HEADERS) {
|
||||
if (t !== 'absent') break;
|
||||
const hl = pg.getByText(h, { exact: true });
|
||||
if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); t = await tierOf(pg, name); }
|
||||
}
|
||||
return t;
|
||||
};
|
||||
/** True when the page text matches re within ms of now (a message can be replaced by a row change). */
|
||||
exports.sawText = (pg, re, ms) =>
|
||||
pg.waitForFunction((src) => new RegExp(src, 'i').test(document.body.innerText), re.source, { timeout: ms, polling: 50 }).then(() => true, () => false);
|
||||
exports.isDm = (tier) => /^direct messages$/i.test(tier);
|
||||
72
harness/estate/drive-failures.cjs
Normal file
72
harness/estate/drive-failures.cjs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// Failure-mode scenarios for the estate join paths (estate tap and inbox Accept), run by run.sh
|
||||
// against fakehs.cjs with the env flag each mode needs.
|
||||
const { chromium } = require('playwright');
|
||||
const { expect, done, isDm, mDirectPuts, joinsOf, revealedTier, sawText } = require('./check.cjs');
|
||||
const MODE = process.env.MODE;
|
||||
const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`;
|
||||
const log = (...a) => console.log(`[${MODE}]`, ...a);
|
||||
const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
|
||||
let b;
|
||||
// Close the browser, but never wait on it: a hung close must not hang the run.
|
||||
const quit = (code) => Promise.race([Promise.resolve(b && b.close()).catch(() => undefined), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(code));
|
||||
setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000);
|
||||
(async () => {
|
||||
b = await chromium.launch();
|
||||
const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 });
|
||||
const pg = await ctx.newPage(); pg.on('console', (m) => { if (m.type() === 'error') console.log('[console]', m.type(), m.text().slice(0, 160)); });
|
||||
const open = async (h = '#/home/') => { await pg.goto('http://127.0.0.1:8080/' + h); await pg.reload(); await pg.waitForTimeout(9000); };
|
||||
await pg.goto('http://127.0.0.1:8080/');
|
||||
await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); });
|
||||
await open();
|
||||
const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' ');
|
||||
const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; };
|
||||
const tierOf = (name) => revealedTier(pg, name);
|
||||
const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); };
|
||||
const tiers = async (tag) => { const r = { friend: await tierOf('Friend DM'), slow: await tierOf('Slow DM') }; log('TIER', tag, '| Friend DM', r.friend, '| Slow DM', r.slow); return r; };
|
||||
const neverUnfiled = () => mDirectPuts().every((b) => b.includes('!dm1:test'));
|
||||
const acceptInInbox = async (name) => {
|
||||
// reach the inbox through the navigator's own Inbox tab
|
||||
await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2000);
|
||||
const tab = pg.locator('[aria-label="Inbox"], [aria-label*="nbox"]'); log('INBOX_TAB', await tab.count());
|
||||
if (await tab.count()) { await tab.first().click(); await pg.waitForTimeout(1500); }
|
||||
const inv = pg.getByText('Invites', { exact: true }); if (await inv.count()) { await inv.first().click(); await pg.waitForTimeout(1500); }
|
||||
log('URL', pg.url());
|
||||
if (!/invites/.test(pg.url())) { await pg.goto('http://127.0.0.1:8080/#/inbox/invites/'); await pg.waitForTimeout(3000); log('URL_FALLBACK', pg.url()); }
|
||||
{ const pub = pg.getByText('Public', { exact: true }); log('PUBLIC_FILTER', await pub.count()); if (await pub.count()) { await pub.first().click(); await pg.waitForTimeout(1500); } }
|
||||
for (let r = 0; r < 3; r++) { const v = pg.getByText('View All', { exact: true }); if (!(await v.count())) break; await v.first().click(); await pg.waitForTimeout(800); }
|
||||
const b0 = await body(); const i = b0.indexOf(name); log('INBOX has', name, i >= 0, JSON.stringify(b0.slice(Math.max(0, i - 120), i + 120)));
|
||||
const card = pg.locator('div').filter({ hasText: name }).filter({ has: pg.getByText('Accept', { exact: true }) }).last();
|
||||
await card.getByText('Accept', { exact: true }).first().click(); log('ACCEPT', name, t());
|
||||
};
|
||||
if (MODE === 'pjfail') {
|
||||
await tap('Friend DM'); await pg.waitForTimeout(3000);
|
||||
const bb = await body(); const k = bb.search(/Internal server error/); log('AFTER_FAIL', k >= 0 ? bb.slice(Math.max(0, k - 40), k + 80) : 'no message');
|
||||
expect('a failed m.direct write shows the refusal', k >= 0, bb.slice(0, 200));
|
||||
expect('no join is sent while the filing is failing', joinsOf('!dm1:test') === 0, String(joinsOf('!dm1:test')));
|
||||
await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('after retry');
|
||||
expect('the retry joins once', joinsOf('!dm1:test') === 1, String(joinsOf('!dm1:test')));
|
||||
expect('Friend DM is filed under Direct Messages after the retry', isDm(r.friend), r.friend);
|
||||
} else if (MODE === 'jdrop') {
|
||||
await tap('Friend DM'); const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_DROP', saw);
|
||||
expect('a dropped join shows the retryable message', saw, (await body()).slice(0, 200));
|
||||
await pg.waitForTimeout(6000); const r1 = await tiers('after drop'); await open(); const r2 = await tiers('after reload');
|
||||
expect('the entry is kept after the drop', isDm(r1.friend) && neverUnfiled(), r1.friend);
|
||||
expect('Friend DM is under Direct Messages after the reload', isDm(r2.friend), r2.friend);
|
||||
} else if (MODE === 'admin') {
|
||||
await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin estate');
|
||||
expect('an admin-invited DM is filed under Direct Messages', isDm(r.friend), r.friend);
|
||||
} else if (MODE === 'inboxadmin') {
|
||||
await acceptInInbox('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin inbox');
|
||||
expect('an admin-invited DM accepted in the inbox is filed under Direct Messages', isDm(r.friend), r.friend);
|
||||
} else if (MODE === 'inboxreload') {
|
||||
await acceptInInbox('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open('#/inbox/invites/');
|
||||
await pg.waitForTimeout(30000); const r = await tiers('inbox reload');
|
||||
expect('a DM accepted in the inbox is filed under Direct Messages after a reload', isDm(r.slow), r.slow);
|
||||
} else if (MODE === 'pjreload') {
|
||||
await tap('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open();
|
||||
await pg.waitForTimeout(8000); await tiers('pjreload');
|
||||
const n = await (await reveal('Slow DM')).count(); log('SLOW_DM_STILL_JOINABLE', n);
|
||||
expect('a join cut off by a reload stays joinable', n === 1, String(n));
|
||||
}
|
||||
await quit(done());
|
||||
})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); });
|
||||
42
harness/estate/drive-routes.cjs
Normal file
42
harness/estate/drive-routes.cjs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
const { chromium } = require('playwright');
|
||||
const { expect, done, isDm, mDirectPuts, hsLog, revealedTier, sawText } = require('./check.cjs');
|
||||
const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`;
|
||||
const log = (...a) => console.log(`[${MODE}]`, ...a);
|
||||
const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
|
||||
let b; const quit = (c) => Promise.race([Promise.resolve(b && b.close()).catch(() => 0), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(c));
|
||||
setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000);
|
||||
(async () => {
|
||||
b = await chromium.launch();
|
||||
const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
||||
const pg = await ctx.newPage();
|
||||
await pg.goto('http://127.0.0.1:8080/');
|
||||
await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); });
|
||||
await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000);
|
||||
const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' ');
|
||||
const tiers = async (tag) => { const f = await revealedTier(pg, 'Friend DM'); log('TIER', tag, '| Friend DM', f); return f; };
|
||||
let joined = false;
|
||||
if (MODE === 'deeplink') {
|
||||
for (const route of ['direct', 'home']) {
|
||||
await pg.goto(`http://127.0.0.1:8080/#/${route}/${encodeURIComponent('!dm1:test')}/`); await pg.waitForTimeout(4000);
|
||||
const bb = await body(); log('ROUTE', route, JSON.stringify(bb.slice(0, 300)));
|
||||
const j = pg.getByRole('button', { name: /^Join$/ }); log('JOIN_BUTTONS', route, await j.count());
|
||||
if (await j.count()) { joined = true; await j.first().click(); log('CLICK_JOIN', route, t()); await pg.waitForTimeout(6000); break; }
|
||||
}
|
||||
const f = await tiers('after deeplink join');
|
||||
const stamps = (re) => hsLog().filter((l) => re.test(l)).map((l) => Number(l.split(' ')[0]));
|
||||
const put = stamps(/ PUT .*m\.direct BODY .*!dm1:test/)[0], join = stamps(/ JOIN !dm1:test$/)[0];
|
||||
expect('the deep link offers one Join button', joined, 'no Join button');
|
||||
expect('the DM is filed before the join is sent', put !== undefined && join !== undefined && put <= join, `put ${put} join ${join}`);
|
||||
expect('the DM is filed under Direct Messages', isDm(f), f);
|
||||
} else if (MODE === 'gw504') {
|
||||
const HL = HEADERS; let l = pg.locator('[aria-label="Join Friend DM"]');
|
||||
for (let r = 0; r < 2 && !(await l.count()); r++) for (const h of HL) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } }
|
||||
log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t());
|
||||
const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_504', saw);
|
||||
expect('a gateway timeout shows the retryable message', saw, (await body()).slice(0, 200));
|
||||
await pg.waitForTimeout(4000); const f = await tiers('after 504 (join delivered by sync)');
|
||||
expect('the DM stays filed after a gateway timeout', mDirectPuts().every((b) => b.includes('!dm1:test')) && mDirectPuts().length > 0, JSON.stringify(mDirectPuts()));
|
||||
expect('the DM is filed under Direct Messages once sync delivers it', isDm(f), f);
|
||||
}
|
||||
await quit(done());
|
||||
})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); });
|
||||
48
harness/estate/drive.cjs
Normal file
48
harness/estate/drive.cjs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
const { chromium } = require('playwright');
|
||||
const { expect, done, isDm, revealedTier } = require('./check.cjs');
|
||||
const MODE = process.env.MODE; // twodm | reload
|
||||
const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`;
|
||||
const log = (...a) => console.log(`[${MODE}]`, ...a);
|
||||
const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages'];
|
||||
let tierBefore;
|
||||
(async () => {
|
||||
const b = await chromium.launch();
|
||||
const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 });
|
||||
const pg = await ctx.newPage(); pg.on('console', (m) => { if (m.type() === 'error' || /command|direct/i.test(m.text())) console.log('[console]', m.type(), m.text().slice(0, 200)); });
|
||||
const open = async () => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); };
|
||||
await pg.goto('http://127.0.0.1:8080/');
|
||||
await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); });
|
||||
await open();
|
||||
const text = () => pg.evaluate(() => document.body.innerText);
|
||||
const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; };
|
||||
const tierOf = (name) => revealedTier(pg, name);
|
||||
const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); };
|
||||
if (MODE === 'twodm') {
|
||||
await tap('Slow DM'); await pg.waitForTimeout(300); await tap('Late DM');
|
||||
await pg.waitForTimeout(45000);
|
||||
} else if (MODE === 'convert') {
|
||||
await tap('Friend DM'); await pg.waitForTimeout(6000);
|
||||
tierBefore = await tierOf('Friend DM'); log('TIER before', tierBefore);
|
||||
await pg.goto('http://127.0.0.1:8080/#/direct/!dm1:test/'); await pg.waitForTimeout(4000);
|
||||
const ed = pg.locator('[contenteditable="true"]'); log('EDITORS', await ed.count(), pg.url());
|
||||
await ed.first().click(); await pg.keyboard.type('/converttoroom', { delay: 30 }); await pg.waitForTimeout(500);
|
||||
log('ED_TEXT', JSON.stringify(await ed.first().innerText())); log('BODY', JSON.stringify((await text()).slice(-600)));
|
||||
await pg.getByText('Convert direct message to room').first().click(); await pg.waitForTimeout(400); log('ED_PICKED', JSON.stringify(await ed.first().innerHTML()).slice(0, 300));
|
||||
await pg.keyboard.press('Enter'); log('SENT', t()); await pg.waitForTimeout(800); log('ED_TEXT2', JSON.stringify(await ed.first().innerText().catch(()=>'')));
|
||||
if (await ed.first().innerText().catch(() => '')) { await pg.keyboard.press('Enter'); log('SENT2', t()); }
|
||||
await pg.waitForTimeout(8000);
|
||||
await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(3000);
|
||||
} else {
|
||||
await tap('Slow DM'); await pg.waitForTimeout(3000);
|
||||
log('RELOAD', t()); await open();
|
||||
await pg.waitForTimeout(30000);
|
||||
}
|
||||
const slow = await tierOf('Slow DM'), late = await tierOf('Late DM'), friend = await tierOf('Friend DM');
|
||||
log('TIER Slow DM', slow, '| Late DM', late, '| Friend DM', friend);
|
||||
if (MODE === 'twodm') { expect('Slow DM is filed under Direct Messages', isDm(slow), slow); expect('Late DM is filed under Direct Messages', isDm(late), late); }
|
||||
else if (MODE === 'convert') {
|
||||
expect('Friend DM was filed under Direct Messages before the conversion', isDm(tierBefore), tierBefore);
|
||||
expect('the converted room left Direct Messages and is still listed', !isDm(friend) && friend !== 'absent', friend);
|
||||
} else expect('Slow DM is filed under Direct Messages after the reload', isDm(slow), slow);
|
||||
await b.close(); process.exitCode = done();
|
||||
})().catch((e) => { log('ERROR', String(e.stack || e)); process.exitCode = 1; });
|
||||
127
harness/estate/fakehs.cjs
Normal file
127
harness/estate/fakehs.cjs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
// Soul s2 fake homeserver. Invitee with no root state; stewards only in m.space.child content.
|
||||
// Rooms: GEN joined; OP invited; 6 project rooms invited (odin slow: join arrives at +24 s);
|
||||
// PUB public joinable, not in store; STRAY joined outside hierarchy; DM1 invited direct (fast);
|
||||
// DM2 invited direct (slow, +24 s). NOROOT=1: the root alias does not resolve.
|
||||
const http = require('http'), fs = require('fs'), path = require('path');
|
||||
const DIST = process.argv[2], PORT = 8080, ME = '@t:test';
|
||||
const NOROOT = process.env.NOROOT === '1';
|
||||
const log = [];
|
||||
const P = ['aetheria', 'ghostlight', 'huginn', 'idunn', 'bifrost', 'odin'];
|
||||
const STEWARD = { aetheria: 'nibu', ghostlight: 'nibu', huginn: 'eureka', idunn: 'eureka' };
|
||||
const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1), key: p }));
|
||||
const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test', PUB = '!pub:test', STRAY = '!stray:test', DM1 = '!dm1:test', DM2 = '!dm2:test', DM3 = '!dm3:test';
|
||||
const SLOW = new Set([PROJ[5].id, DM2, DM3]);
|
||||
const NAMES = { [PUB]: 'Commons', [DM1]: 'Friend DM', [DM2]: 'Slow DM', [DM3]: 'Late DM', [STRAY]: 'Adhoc' };
|
||||
PROJ.forEach((r) => (NAMES[r.id] = r.name));
|
||||
const held = []; const pendingState = []; const pending = []; const adLog = []; let ev = 0;
|
||||
const se = (type, content, sk = '', sender = '@admin:test') => ({ type, content, state_key: sk, sender, event_id: `$e${++ev}`, origin_server_ts: Date.now() });
|
||||
const strip = (id, name, space, direct) => [
|
||||
{ type: 'm.room.create', state_key: '', sender: direct ? '@friend:test' : '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } },
|
||||
{ type: 'm.room.name', state_key: '', sender: '@admin:test', content: { name } },
|
||||
{ type: 'm.room.join_rules', state_key: '', sender: '@admin:test', content: { join_rule: 'invite' } },
|
||||
...(direct ? [{ type: 'm.room.member', state_key: '@friend:test', sender: '@friend:test', content: { membership: 'join', displayname: 'Friend' } }] : []),
|
||||
{ type: 'm.room.member', state_key: ME, sender: direct ? (process.env.INVITER_ADMIN === '1' && id === DM1 ? '@admin:test' : '@friend:test') : '@admin:test', content: direct ? { membership: 'invite', is_direct: true } : { membership: 'invite' } },
|
||||
];
|
||||
const joinedRoom = (id, name, extra = [], space = false) => ({
|
||||
state: { events: [se('m.room.create', space ? { type: 'm.space', room_version: '12' } : { room_version: '12' }), se('m.room.name', { name }), se('m.room.join_rules', { join_rule: 'invite' }), se('m.room.member', { membership: 'join' }, ME, ME), ...extra] },
|
||||
timeline: { events: [{ type: 'm.room.message', sender: '@admin:test', content: { msgtype: 'm.text', body: `welcome to ${name}` }, event_id: `$m${++ev}`, origin_server_ts: Date.now() }], limited: false },
|
||||
ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {},
|
||||
});
|
||||
const invites = () => {
|
||||
const o = {};
|
||||
o[ROOT] = { invite_state: { events: strip(ROOT, 'GameCult', true) } };
|
||||
o[PSP] = { invite_state: { events: strip(PSP, 'Projects', true) } };
|
||||
o[OP] = { invite_state: { events: strip(OP, 'Operator') } };
|
||||
PROJ.forEach((r) => (o[r.id] = { invite_state: { events: strip(r.id, r.name) } }));
|
||||
o[DM1] = { invite_state: { events: strip(DM1, NAMES[DM1], false, true) } };
|
||||
o[DM2] = { invite_state: { events: strip(DM2, NAMES[DM2], false, true) } };
|
||||
o[DM3] = { invite_state: { events: strip(DM3, NAMES[DM3], false, true) } };
|
||||
return o;
|
||||
};
|
||||
const child = (id, steward) => ({ type: 'm.space.child', state_key: id, content: { via: ['test'], ...(steward ? { 'org.gamecult.steward': { mind: steward, repo: 'x', source: 'y', steward: 'z' } } : {}) }, sender: '@admin:test', origin_server_ts: 1 });
|
||||
const hroom = (id, name, alias, extra = {}) => ({ room_id: id, name, canonical_alias: alias, num_joined_members: 1, world_readable: false, guest_can_join: false, join_rule: 'invite', children_state: [], ...extra });
|
||||
const hierarchy = () => [
|
||||
hroom(ROOT, 'GameCult', '#gamecult:test', { room_type: 'm.space', children_state: [child(GEN), child(OP), child(PUB), child(PSP)] }),
|
||||
hroom(GEN, 'General', '#general:test'),
|
||||
hroom(OP, 'Operator', '#operator:test'),
|
||||
hroom(PUB, 'Commons', '#commons:test', { join_rule: 'public' }),
|
||||
hroom(PSP, 'Projects', '#projects:test', { room_type: 'm.space', children_state: PROJ.map((r) => child(r.id, STEWARD[r.key])) }),
|
||||
...PROJ.map((r) => hroom(r.id, r.name, r.alias)),
|
||||
];
|
||||
let syncN = 1;
|
||||
const json = (res, code, body) => { res.writeHead(code, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*', 'Access-Control-Allow-Methods': '*' }); res.end(JSON.stringify(body)); };
|
||||
const mime = { '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.html': 'text/html', '.json': 'application/json', '.wasm': 'application/wasm', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' };
|
||||
const flush = () => fs.writeFileSync(process.env.HSLOG, log.join('\n'));
|
||||
http.createServer((req, res) => {
|
||||
const u = new URL(req.url, 'http://x'); const p = decodeURIComponent(u.pathname);
|
||||
if (req.method === 'OPTIONS') return json(res, 200, {});
|
||||
if (!p.startsWith('/_matrix') && !p.startsWith('/.well-known')) {
|
||||
let f = path.join(DIST, p === '/' ? 'index.html' : p);
|
||||
if (!fs.existsSync(f) || fs.statSync(f).isDirectory()) f = path.join(DIST, 'index.html');
|
||||
res.writeHead(200, { 'Content-Type': mime[path.extname(f)] || 'application/octet-stream' });
|
||||
return fs.createReadStream(f).pipe(res);
|
||||
}
|
||||
let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => {
|
||||
if (req.method === 'PUT' && p.includes('m.direct')) console.log('MDIRECT_PUT ' + (Date.now()) + ' ' + body);
|
||||
log.push(`${Date.now()} ${req.method} ${p}${req.method === 'PUT' && p.includes('account_data') ? ' BODY ' + body : ''}`);
|
||||
if (p === '/_matrix/client/versions') return json(res, 200, { versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} });
|
||||
if (p.endsWith('/sync')) {
|
||||
const since = u.searchParams.get('since');
|
||||
if (!since) {
|
||||
const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[STRAY] = joinedRoom(STRAY, 'Adhoc');
|
||||
return json(res, 200, { next_batch: `s${syncN}`, rooms: { join, invite: invites(), leave: {} }, account_data: { events: held.slice() }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } });
|
||||
}
|
||||
const respond = () => {
|
||||
const now = Date.now(); const join = {};
|
||||
for (let i = pending.length - 1; i >= 0; i--) if (pending[i].at <= now) {
|
||||
const id = pending[i].id; pending.splice(i, 1);
|
||||
const extra = id === DM1 || id === DM2 || id === DM3 ? [se('m.room.member', { membership: 'join', displayname: 'Friend' }, '@friend:test', '@friend:test')] : [];
|
||||
join[id] = joinedRoom(id, NAMES[id] || id, extra); if (extra.length) { const me = join[id].state.events.find((e) => e.type === 'm.room.member' && e.state_key === ME); me.unsigned = { prev_content: { membership: 'invite', is_direct: true } }; } log.push(`${now} SYNC delivers join ${id}`); console.log('DELIVER ' + now + ' ' + id);
|
||||
}
|
||||
for (const s of pendingState.splice(0)) { join[s.id] = join[s.id] || { state: { events: [] }, timeline: { events: [], limited: false, prev_batch: 'p' }, ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {} }; join[s.id].timeline.events.push(s.ev); console.log('STATE_ECHO ' + now + ' ' + s.id + ' ' + JSON.stringify(s.ev.content)); }
|
||||
const k = Number(String(since).replace(/^s/, '')) || 0; const ad = adLog.filter((a) => a.after >= k).map((a) => ({ type: a.type, content: a.content })); // a server answers with what changed since the client's token, whichever client asks
|
||||
if (Object.keys(join).length || ad.length) return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} }, account_data: { events: ad } });
|
||||
return json(res, 200, { next_batch: since, rooms: {} });
|
||||
};
|
||||
return setTimeout(respond, 300);
|
||||
}
|
||||
const jm = p.match(/\/rooms\/([^/]+)\/join$/) || p.match(/\/join\/([^/]+)$/);
|
||||
if (req.method === 'POST' && jm) {
|
||||
const id = jm[1]; log.push(`${Date.now()} JOIN ${id}`);
|
||||
if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' });
|
||||
const prior = pending.find((q) => q.id === DM2 || q.id === DM3);
|
||||
pending.push({ id, at: (id === DM3 || id === DM2) && prior ? prior.at : Date.now() + (SLOW.has(id) ? 24000 : 1000) });
|
||||
if (process.env.JOIN_504 === '1' && id === DM1) { console.log('JOIN_504 ' + Date.now() + ' ' + id); res.writeHead(504, {'Content-Type':'text/html'}); return res.end('<html>504 Gateway Time-out</html>'); }
|
||||
if (process.env.JOIN_DROP === '1' && id === DM1) { console.log('JOIN_DROPPED ' + Date.now() + ' ' + id); return req.socket.destroy(); }
|
||||
if (process.env.JOIN_DELAY === '1') { console.log('JOIN_DELAYED ' + Date.now() + ' ' + id); return setTimeout(() => json(res, 200, { room_id: id }), 6000); }
|
||||
return json(res, 200, { room_id: id });
|
||||
}
|
||||
const sm = p.match(/\/rooms\/([^/]+)\/state\/m\.room\.member\/(.+)$/);
|
||||
if (req.method === 'PUT' && sm) { console.log('STATE_PUT ' + Date.now() + ' ' + sm[1] + ' ' + body); const e = se('m.room.member', JSON.parse(body), sm[2], sm[2]); e.unsigned = { prev_content: { membership: 'join' } }; setTimeout(() => pendingState.push({ id: sm[1], ev: e }), 400); return json(res, 200, { event_id: e.event_id }); }
|
||||
if (p.includes('/account_data/') && req.method === 'PUT' && p.includes('m.direct') && process.env.FAIL_MDIRECT === '1' && !global.failedOnce) { global.failedOnce = 1; console.log('MDIRECT_FAIL ' + Date.now()); return json(res, 500, { errcode: 'M_UNKNOWN', error: 'Internal server error' }); }
|
||||
if (p.includes('/account_data/') && req.method === 'PUT') {
|
||||
const DELAY = p.includes('m.direct') && process.env.DELAY_MDIRECT === '1';
|
||||
const type = decodeURIComponent(p.split('/account_data/')[1]);
|
||||
try { const c = JSON.parse(body); adLog.push({ type, content: c, after: syncN }); const i = held.findIndex((h) => h.type === type); if (i >= 0) held.splice(i, 1); held.push({ type, content: c }); } catch (e) {}
|
||||
if (DELAY) { console.log('MDIRECT_DELAYED ' + Date.now()); return setTimeout(() => json(res, 200, {}), 6000); }
|
||||
return json(res, 200, {});
|
||||
}
|
||||
if (p.includes('/hierarchy')) return json(res, 200, { rooms: hierarchy() });
|
||||
if (p.includes('/directory/room/')) return NOROOT ? json(res, 404, { errcode: 'M_NOT_FOUND', error: 'Room alias not found' }) : json(res, 200, { room_id: ROOT, servers: ['test'] });
|
||||
if (p.endsWith('/keys/upload')) return json(res, 200, { one_time_key_counts: { signed_curve25519: 50 } });
|
||||
if (p.endsWith('/keys/query')) return json(res, 200, { device_keys: {}, failures: {} });
|
||||
if (p.includes('/room_keys/version')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' });
|
||||
if (p.includes('/pushrules')) return json(res, 200, { global: { override: [], underride: [], room: [], sender: [], content: [] } });
|
||||
if (p.includes('/filter')) return json(res, 200, { filter_id: '1' });
|
||||
if (p.includes('/account_data/')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' });
|
||||
if (p.includes('/messages')) return json(res, 200, { chunk: [], start: 't0', end: 't0' });
|
||||
if (p.includes('/members')) return json(res, 200, { chunk: [] });
|
||||
if (p.includes('/profile/')) return json(res, 200, { displayname: 'Tess' });
|
||||
if (p.includes('/capabilities')) return json(res, 200, { capabilities: {} });
|
||||
if (p.includes('/devices')) return json(res, 200, { devices: [] });
|
||||
if (p.includes('summary')) return json(res, 200, { room_id: p.split('/').pop(), name: 'Summary card', num_joined_members: 1, membership: 'invite', join_rule: 'invite' });
|
||||
if (p.includes('/login')) return json(res, 200, { flows: [{ type: 'm.login.password' }] });
|
||||
return json(res, 200, {});
|
||||
});
|
||||
}).listen(PORT, () => console.log('fakehs up'));
|
||||
setInterval(flush, 300);
|
||||
35
harness/estate/run.sh
Normal file
35
harness/estate/run.sh
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
#!/usr/bin/env bash
|
||||
# Runs inside mcr.microsoft.com/playwright:v1.48.2-jammy with the repo at /src:
|
||||
# builds the client, then drives the direct-message and join-failure scenarios against fakehs.cjs.
|
||||
set -e
|
||||
cd /src
|
||||
npm ci --no-audit --no-fund --ignore-scripts >/tmp/npmci.log 2>&1 || { tail -20 /tmp/npmci.log; exit 1; }
|
||||
npm run build > /tmp/build.log 2>&1 || { tail -30 /tmp/build.log; exit 1; }
|
||||
echo BUILD_OK $(git rev-parse HEAD)
|
||||
printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json
|
||||
mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/tmp/pw.log 2>&1
|
||||
cp /src/harness/estate/check.cjs /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /src/harness/estate/drive-routes.cjs /tmp/pw/
|
||||
set +e
|
||||
for m in twodm reload convert; do
|
||||
HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 &
|
||||
HS=$!; sleep 2
|
||||
HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/drive.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m"
|
||||
kill $HS; wait $HS 2>/dev/null; sleep 1
|
||||
echo "=== hs $m"; grep -E "MDIRECT_PUT|DELIVER|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt
|
||||
done
|
||||
failure() { m=$1; shift
|
||||
env "$@" HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 &
|
||||
HS=$!; sleep 2
|
||||
HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/${DRIVER:-drive-failures}.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m"
|
||||
kill $HS; wait $HS 2>/dev/null; sleep 1
|
||||
echo "=== hs $m"; grep -E "MDIRECT|DELIVER|JOIN_|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt; }
|
||||
failure pjfail FAIL_MDIRECT=1
|
||||
failure pjreload DELAY_MDIRECT=1
|
||||
failure jdrop JOIN_DROP=1
|
||||
failure admin INVITER_ADMIN=1
|
||||
failure inboxadmin INVITER_ADMIN=1
|
||||
failure inboxreload JOIN_DELAY=1
|
||||
DRIVER=drive-routes failure deeplink
|
||||
DRIVER=drive-routes failure gw504 JOIN_504=1
|
||||
echo "FAILED_DRIVES:${FAILED:- none}"
|
||||
[ -z "$FAILED" ]
|
||||
947
package-lock.json
generated
947
package-lock.json
generated
File diff suppressed because it is too large
Load diff
26
package.json
26
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "cinny",
|
||||
"version": "4.12.7",
|
||||
"version": "4.12.8",
|
||||
"description": "Yet another matrix client",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
|
@ -18,7 +18,8 @@
|
|||
"typecheck": "tsc --noEmit",
|
||||
"prepare": "husky install",
|
||||
"commit": "git-cz",
|
||||
"bump": "node scripts/update-version.js"
|
||||
"bump": "node scripts/update-version.js",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,jsx}": "eslint",
|
||||
|
|
@ -56,21 +57,20 @@
|
|||
"emojibase-data": "15.3.2",
|
||||
"file-saver": "2.0.5",
|
||||
"focus-trap-react": "10.0.2",
|
||||
"folds": "2.7.3",
|
||||
"folds": "2.7.2",
|
||||
"html-dom-parser": "4.0.0",
|
||||
"html-react-parser": "4.2.0",
|
||||
"i18next": "23.12.2",
|
||||
"i18next-browser-languagedetector": "8.0.0",
|
||||
"i18next-http-backend": "4.0.3",
|
||||
"idb": "8.0.3",
|
||||
"immer": "9.0.16",
|
||||
"is-hotkey": "0.2.0",
|
||||
"jotai": "2.6.0",
|
||||
"linkify-react": "4.3.2",
|
||||
"linkifyjs": "4.3.2",
|
||||
"matrix-widget-api": "1.20.0",
|
||||
"matrix-js-sdk": "41.7.0",
|
||||
"matrix-widget-api": "1.16.1",
|
||||
"millify": "6.1.0",
|
||||
"neurone": "0.0.9",
|
||||
"pdfjs-dist": "4.2.67",
|
||||
"prismjs": "1.30.0",
|
||||
"react": "18.2.0",
|
||||
|
|
@ -83,15 +83,18 @@
|
|||
"react-i18next": "15.0.0",
|
||||
"react-range": "1.8.14",
|
||||
"react-router-dom": "6.30.6",
|
||||
"sanitize-html": "2.17.7",
|
||||
"sanitize-html": "2.18.1",
|
||||
"slate": "0.123.0",
|
||||
"slate-dom": "0.123.0",
|
||||
"slate-history": "0.113.1",
|
||||
"slate-react": "0.123.0",
|
||||
"ua-parser-js": "1.0.35"
|
||||
"ua-parser-js": "1.0.35",
|
||||
"@fontsource/ubuntu": "5.2.5",
|
||||
"@fontsource/montserrat": "5.2.5",
|
||||
"@fontsource/ibm-plex-mono": "5.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@element-hq/element-call-embedded": "0.28.0",
|
||||
"@element-hq/element-call-embedded": "0.20.1",
|
||||
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
|
||||
"@rollup/plugin-inject": "5.0.3",
|
||||
"@rollup/plugin-wasm": "6.1.1",
|
||||
|
|
@ -120,10 +123,11 @@
|
|||
"husky": "9.1.7",
|
||||
"lint-staged": "16.3.2",
|
||||
"prettier": "2.8.1",
|
||||
"typescript": "6.0.3",
|
||||
"typescript": "4.9.4",
|
||||
"vite": "5.4.19",
|
||||
"vite-plugin-pwa": "0.20.5",
|
||||
"vite-plugin-static-copy": "1.0.4",
|
||||
"vite-plugin-top-level-await": "1.4.4"
|
||||
"vite-plugin-top-level-await": "1.4.4",
|
||||
"vitest": "1.6.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
24
src/app/components/RoomUnreadProvider.tsx
Normal file
24
src/app/components/RoomUnreadProvider.tsx
Normal 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);
|
||||
}
|
||||
|
|
@ -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]
|
||||
)
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from './AuthLayout';
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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' }}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from './EncryptedEvent';
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>:
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
41
src/app/components/message/Reply.css.ts
Normal file
41
src/app/components/message/Reply.css.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -12,7 +12,3 @@ export const RoomAvatar = style({
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const RoomAvatarImg = style({
|
||||
fontSize: 1,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue