Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
Void
1455988a83 Merge dev 8ba407cd into main
No content change: tree equals dev's.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 15:48:20 +00:00
8ba407cd2a Merge pull request 'Phone hit: repo-wide guard for bare IconButtons, PhoneHitInPlace for preview arrows, drop dead Editor.preview' (#141) from void/phone-hit-guard into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m39s
2026-11-13 13:51:43 +00:00
Void
d5a0c1b4ff Phone hit: a repo-wide guard for bare IconButtons, PhoneHitInPlace for the preview arrows, drop the dead Editor.preview
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m22s
phoneHit.coverage.test.ts walks every .tsx under src/app and fails on any IconButton without a PhoneHit class
unless it is named with its reason (EstateNavigator Find a room, LobbyHeader Members). UrlPreviewCard's two
absolute arrows take PhoneHitInPlace, which adds the hit area without PhoneHit's position: relative.
Editor.preview.tsx was imported nowhere and held 11 bare IconButtons.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 13:46:44 +00:00
ad9172a311 Merge pull request 'Phone hit: page back buttons (group 3)' (#133) from void/phone-hit-pages into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m11s
2026-11-13 13:43:43 +00:00
4e6b4f0c90 Merge branch 'dev' into void/phone-hit-pages
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m12s
2026-11-13 13:34:34 +00:00
1a3eedf6cb Merge pull request 'Phone hit: editor toolbar, emoji sidebar, upload cards, client root' (#139) from void/phone-hit-editor into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m43s
2026-11-13 13:34:32 +00:00
9153ee945d Merge branch 'dev' into void/phone-hit-editor
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m57s
2026-11-13 13:26:44 +00:00
d5ad73fa5e Merge pull request 'Phone hit: message content and data editors (group 10)' (#138) from void/phone-hit-content into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m43s
2026-11-13 13:26:43 +00:00
4c16e51559 Merge branch 'dev' into void/phone-hit-content
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m11s
2026-11-13 12:58:51 +00:00
f0c8dc2122 Merge pull request 'Phone hit: room/space settings, lobby, message search, explore (group 7)' (#137) from void/phone-hit-room-space into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m12s
2026-11-13 12:58:50 +00:00
d6a6e300a4 Merge pull request 'Phone hit: settings B, auth and verification icon buttons' (#136) from void/phone-hit-settings-b into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m40s
2026-11-13 12:49:59 +00:00
91993ace99 Merge branch 'dev' into void/phone-hit-room-space
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m14s
2026-11-13 12:49:59 +00:00
145e268894 Merge branch 'dev' into void/phone-hit-settings-b
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m50s
2026-11-13 12:42:11 +00:00
8d25db2775 Merge pull request 'Phone hit: viewer headers and UIA stages (group 9)' (#135) from void/phone-hit-viewers into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m13s
2026-11-13 12:42:10 +00:00
6c395d7aef Merge branch 'dev' into void/phone-hit-viewers
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m10s
2026-11-13 12:11:44 +00:00
a7ac67683f Merge pull request 'Phone hit: common-settings IconButtons (group 6)' (#134) from void/phone-hit-common-settings into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m58s
2026-11-13 12:11:43 +00:00
8a52fa9353 Merge branch 'dev' into void/phone-hit-common-settings
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m46s
2026-11-13 10:16:51 +00:00
32b95fb60b Merge pull request 'Phone hit: dialogs and prompts (group 8)' (#132) from void/phone-hit-dialogs into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m34s
2026-11-13 10:16:50 +00:00
Void
ff1a29dbec Merge dev into phone-hit-pages (kit: dev's AST reader already accepts null entries)
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m23s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 10:15:31 +00:00
2c39f89fe4 Merge branch 'dev' into void/phone-hit-dialogs
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m5s
2026-11-13 09:31:02 +00:00
fd2626d366 Merge pull request 'Phone hit: settings A icon buttons (group 4)' (#131) from void/phone-hit-settings-a into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m37s
2026-11-13 09:31:00 +00:00
09e85fad02 Merge branch 'dev' into void/phone-hit-settings-a
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m5s
2026-11-13 09:05:41 +00:00
b2a970f987 Merge pull request 'Phone hit: call controls (audit group 1)' (#130) from void/phone-hit-call into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m7s
2026-11-13 09:05:39 +00:00
45939d544b Merge branch 'dev' into void/phone-hit-call
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m23s
2026-11-13 08:28:38 +00:00
0f7faa902c Merge pull request 'Phone hit kit: pin the className expression, not its source text' (#140) from void/phone-hit-kit-strict into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m48s
2026-11-13 08:28:35 +00:00
Void
4eaa6f9c55 Phone hit kit: read IconButton className off the TypeScript AST
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m53s
className must appear once, with no spread after it, as the imported PhoneHit*
identifier bare, a classNames/clsx/cx direct argument, or a template span, and
the identifier must occur nowhere else in the expression. Tag enumeration uses
the same AST. expectPhoneHitClasses takes (string|null)[] (null = no class), as
the phone-hit-pages branch does, and is exercised by a test on a temp source.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:28:37 +00:00
Void
761d13fac5 Phone hit editor tests: pin estate Targets nesting and selector, ClientRoot inline absolute, sidebar gap
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m33s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:21:57 +00:00
Void
d13cbc9b2e Phone hit kit: read the className expression, not its source text
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
A PhoneHit name inside a string, built by concatenation, behind ?:/&&/||/??, or
followed by a props spread that can override className no longer satisfies the pin.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:21:00 +00:00
Void
da05210db2 Phone hit: editor toolbar, emoji sidebar, upload cards, client root
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:16:46 +00:00
Void
a0d047c139 Phone hit: message content and data editor icon buttons
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m30s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:16:41 +00:00
Void
84ee800e3f Phone hit: room/space settings, lobby, message search, explore icon buttons
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m25s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:16:31 +00:00
Void
32dc46c21f Phone hit: PhoneHit on the settings B, auth and verification icon buttons
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m44s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:16:11 +00:00
Void
200cf8fdb8 Phone hit: viewer headers and UIA stages
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m58s
Back/close buttons beside non-tappable titles take PhoneHit; the zoom out/in
buttons beside the zoom Chip take PhoneHitSpread24. Test pins each variant and
ties the sized ones to the drawn size.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:16:07 +00:00
Void
e209688de1 Phone hit: common-settings IconButtons get PhoneHit, with a pinning test
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m31s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:16:02 +00:00
Void
cd90a044ed Phone hit: page back buttons and Lobby/Notifications lone buttons
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m59s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:16:02 +00:00
Void
0b40f4c66f Phone hit: PhoneHit on the ten dialog and prompt close buttons, with a pinning test
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 5m26s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:15:44 +00:00
Void
b8bef8c175 Phone hit: settings A icon buttons get PhoneHit, with a pinning test
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m32s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:15:25 +00:00
Void
351d1bc745 Phone hit: call controls get their hit-area variants, with a test pinning them
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m31s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:15:10 +00:00
54ac1ba480 Merge pull request 'Phone hit: Spread28/36, Room36, shared test kit and size-tie check' (#129) from void/phone-hit-foundation into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m56s
2026-11-13 07:13:33 +00:00
Void
fe12d8a4b1 Phone hit kit: pin Room composition, spread props are undetermined, tighter tag match
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m4s
Room<N> throws when no Spread of its size exists, and a test reads the compiled class lists to prove Room<N>
composes Spread<N> and PhoneHit. Props spread into an IconButton or Icon make the size undetermined (allow-list
entry required). <IconButton and <Icon match only before whitespace, > or /. iconButtonTags derives from iconButtons.
expectSizeTie fails on a file with no IconButton. Fixture tests live in phoneHitTestKit.test.ts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 07:05:58 +00:00
b77efddcdc Merge pull request 'buildSha: an explicit BUILD_SHA no longer masks a dirty tree; temp-file exclusion is untracked-only' (#128) from void/build-sha-untracked-filter into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m39s
2026-11-13 07:02:44 +00:00
Void
d917ccd3bb Phone hit tests: shared kit, size tie, pinned new variants
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 2m11s
The helpers moved to phoneHitTestKit.ts so each later group adds its own phoneHit.<group>.test.ts. A new
check ties PhoneHitSpread<N>/Room<N> to the px the button draws (IconButton size padding + Icon size); a size the
source does not state needs an explicit allow-list entry. It found RoomInput's reply-cancel (16 px icon, 24 px
button) carrying Spread32; it is Spread24 now. Room<N> takes its inline margin from the Spread class of the same
size, so the two axes cannot disagree.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 06:59:50 +00:00
Void
2b019a4302 Phone hit: Spread28, Spread36 and Room36 variants
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 06:58:48 +00:00
Void
eff894a89c docs: BUILD_SHA also stands where git cannot be read
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m39s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 06:57:45 +00:00
Void
c82193f0fb buildSha: git speaks only for a tree it roots, mismatch and git failure fail closed, temp-file name matched exactly
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Soul found: explicit sha stamped clean over a different HEAD; status failure returned the bare explicit
sha; an archive inside another repo got that repo's verdict; the temp regex let any untracked .mjs pass.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 06:56:27 +00:00
Void
c45cdc7bd7 buildSha: BUILD_SHA no longer masks a dirty tree; the vite temp-file exclusion is untracked-only
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m24s
The pathspec exclude hid a tracked file of that name; the status lines are filtered instead.
Tests against a real repo: explicit sha plus stray file, stray file beside the temp file in root and
subfolder, tracked file of the temp name, new folder. Six mutants of the logic each fail a test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 06:52:32 +00:00
14ee4bee03 Merge pull request 'Phone: 44 px hit areas for the chat path's remaining icon buttons, and tests that pin them' (#127) from void/phone-hit-areas-2 into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m38s
2026-11-13 05:50:20 +00:00
Void
0c8474231e phoneTapTargets: each Spread class owns its margin, ::after declaration set is exact, no phone rule moves position, import must not be aliased
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m1s
Soul re-gate of 446306b1: swapped Spread24/26 definitions, display:none or overflow on the ::after, position:static on a
Spread rule and an aliased import all passed; all killed now.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 05:50:10 +00:00
Void
446306b1e7 PhoneHit: Spread24 for the nav row's 24 px buttons, Spread26 for the unpin button; tests pin position, ::after declarations, className and import alias
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Soul pass on fc92809e: RoomNavItem's buttons are 24 px, not 32 (36 px pitch, hit areas overlapped); the unpin button is 26 px
beside the Open chip. Six mutants survived (position: relative, pointer-events, z-index, title for className, aliased
import); all now killed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 05:46:21 +00:00
Void
fc92809eb9 Phone: 44 px hit areas for the chat path's remaining icon buttons; pin variant, import and ::after content
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
MessageEditor, RoomNavItem, MembersDrawer, ReactionViewer, RoomPinMenu, RoomViewTyping, CallChatView and JumpToTime
get PhoneHit (alone) or PhoneHitSpread32 (two neighbours in a row). phoneTapTargets.test.ts now lists them, plus
PasswordInput, Desk, SkyStateControl and PasswordLoginForm, which had no test, checks each class is imported from
styles/PhoneHit.css, and pins the ::after content (a mutant without it passed before).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 05:40:21 +00:00
2aa468c36a Merge pull request 'Consumer-side tests for #123: RoomOutbox as rendered; hung send to watchdog reload to resend' (#124) from void/stall-consumer-tests into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m18s
2026-11-13 04:11:32 +00:00
67f1267653 Merge pull request 'Phone: 44 px hit areas for icon buttons, reaction chips and short sender names' (#125) from void/phone-hit-areas into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-13 04:11:26 +00:00
Void
ae3760fcc7 RoomOutbox actions test: Resend keeps the entry in the outbox, pending again
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m14s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:11:15 +00:00
Void
bd2d980023 phoneTapTargets: pin top, left and transform of the hit-area box
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m32s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:10:46 +00:00
Void
da9ef70c03 Consumer tests: startClient wiring, Resend/Discard handlers, the 30 s slow-send threshold
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:04:45 +00:00
Void
0e3115809e phoneTapTargets: pin the exact PhoneHit variant of each IconButton
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:02:30 +00:00
Void
e04c7c074f stallRecovery test: a held SDK echo makes the resend use a fresh txn id
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:01:36 +00:00
Void
fab6a04894 stallRecovery test: lint clean
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:01:36 +00:00
Void
09b062d7be stallRecovery test: distinct txn ids per send; pin visible/online guards and own-tab pending filter; fix TS2698
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:01:36 +00:00
Void
9d1d39922c stallRecovery test: lint clean 2026-11-13 04:01:36 +00:00
Void
76c1f56bd8 stallRecovery test: lint 2026-11-13 04:01:36 +00:00
Void
2a1f2c6b17 Consumer-side tests: RoomOutbox as rendered; hung send to watchdog reload to resend
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:01:36 +00:00
Void
21ff167177 phoneTapTargets: every IconButton in Message, RoomInput, RoomViewHeader carries a PhoneHit class
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:01:35 +00:00
Void
ee55c32214 Phone: quick-reaction emoji buttons and delete/report dialog close buttons get a 44 px hit area
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:01:35 +00:00
Void
856096e215 Desk back button: phone hit area
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:01:35 +00:00
Void
f7395f12e3 Phone: icon buttons, reaction chips and short sender names get a 44 px hit area
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 04:01:35 +00:00
95f79a0517 Merge pull request 'Build stamps its git sha into index.html (build-sha meta tag)' (#126) from void/build-sha-stamp into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-13 04:01:23 +00:00
Void
917f9a0da2 buildSha test: remove the temp repos after each test
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m54s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 03:48:22 +00:00
Void
46e96500e4 buildSha: ignore vite's temp config file, run git in the repo dir
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 03:20:59 +00:00
Void
ccb7a8ebb6 Build stamps its git sha into index.html as a build-sha meta tag
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m52s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-13 01:40:05 +00:00
b6b1d522ab Merge pull request 'client: unconfirmed sends are visible and recovered; watchdog for a dead connection' (#123) from void/resume-reload into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m43s
2026-11-12 21:35:47 +00:00
Void
bb6391a70b initMatrix: resolve sessionStorage inside the guard's try; ownPendingAges tested
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m39s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 21:35:46 +00:00
Void
69d752ff0b watchdog: send stall only, own-page entries, online only; resume sync nudges and never restarts
Soul pass on 77edebff: the watchdog no longer counts interrupted entries from
a previous page (reload loop at startup), no longer reloads on sync ERROR/STOPPED
(js-sdk recovers offline by itself) or while offline, refuses instead of
throwing when sessionStorage is unusable, startClient is idempotent, and the
slow-send label says Resend may duplicate. resumeSync drops its health check
and recover hook: nudge only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 21:34:21 +00:00
Void
77edebff9c client: watchdog, resend on reload, outbox for every send, no in-place client restart
Sends have no timeout and sync polls do, so a dead connection leaves a send
SENDING forever with no request reaching the server while its echo looked
delivered. A watchdog (send pending past 20 s, sync ERROR/STOPPED for 30 s)
calls a guarded reload; what the reload interrupts is sent again under its
own txn id. Uploads and stickers go through the outbox. Resend takes a fresh
txn id while the SDK still holds the failed echo. The resume-sync recovery no
longer calls stopClient/startClient, which closes the rust crypto for good
("null pointer passed to rust" on later encrypted sends).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 21:31:29 +00:00
Void
a92f6c4294 timeline: hide unconfirmed local echoes; the outbox is the one place they show
The SDK puts a local echo in the live timeline on sendMessage and marks it
NOT_SENT on failure; the timeline rendered it like any delivered message and a
reload dropped it, so a send killed with the page looked sent and vanished.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 21:25:50 +00:00
92a98664c9 Merge pull request 'composer: persist the draft on input, keep unconfirmed sends in a durable outbox' (#122) from void/draft-persist into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-12 21:17:30 +00:00
Void
a531976675 lint: import order, explicit no-op test stubs
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m9s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 21:17:26 +00:00
Void
350ec987e0 outbox: survive a refused storage write, mark reload-interrupted sends, hide Resend on live sends
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Soul pass findings: a failed localStorage write no longer loses the entry
(memory copy wins until a write succeeds), a stale draft is dropped when
its rewrite fails, storage reads/removes cannot throw, and pending entries
older than the page load read as interrupted with Resend available.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 21:17:06 +00:00
Void
7316c92ff3 composer: persist the draft on input, keep unconfirmed sends in a durable outbox
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
The draft lived in a memory-only jotai atom written only when RoomInput
unmounted, so a reload lost it. A sent message was cleared from the editor
at once and held nowhere durable. The draft is now written to localStorage
(debounced, flushed on pagehide/hidden) and restored on load; a send stays
in a per-account outbox under its txn id until the server confirms, shown
above the composer with Resend and Discard.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 21:14:16 +00:00
a8d39ee917 Merge pull request 'pwa: installable manifest and Web Push stages 0-1 (dark by default)' (#121) from void/pwa-push-stage-0-1 into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m46s
2026-11-12 20:05:41 +00:00
Void
695a016d4f push: keep the token only while subscribed; toggle reads the account's pusher; replace a stale VAPID subscription
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m14s
Soul pass findings on the stage 0-1 branch: the worker stored the token on every deployment, push configured or not
(now only while the browser holds a push subscription; the page re-sends its session after the toggle changes it);
the toggle read the browser's subscription, not the account's pusher (now both); a subscription under an old VAPID
key was reused (now replaced); a failing client list now still shows the notification.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 20:05:10 +00:00
Void
64a2656ad7 pwa: installable manifest fixes and Web Push stage 0-1 (worker handlers, session in IndexedDB, settings toggle, dark config block)
Stage 0: manifest gains id "/" (what Chrome already derives from start_url), scope, a 512 maskable
icon, and drops the portrait lock. The service worker keeps the session ({baseUrl, accessToken}) in
IndexedDB `ratatoskr-sw` so a push can wake a worker with no tab; the page's existing logout message
deletes it.

Stage 1: `push` and `notificationclick` handlers (src/app/features/push), a pusher toggle in
notification settings, `push` block in config.json shipped empty (off). Tested against a mock
homeserver and Matrix gateway body.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 19:58:40 +00:00
f79613ca5f Merge pull request 'docs: chat shell compression, measured state and brotli plan' (#120) from void/chat-shell-compression into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m44s
2026-11-12 18:16:04 +00:00
Void
74fc2fbcd4 docs: fix build id, SW claim, step order, twin size after Soul pass
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m48s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 18:15:59 +00:00
Void
9297ba08dd docs: chat shell compression, measured state and brotli plan
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 18:09:47 +00:00
3d8c98e1c8 Merge pull request 'runbook: handoff build ships the repo config.json; live check always runs' (#119) from void/runbook-config-flow into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m33s
2026-11-12 17:08:05 +00:00
Void
3543b1013b runbook: sha256sum --ignore-missing hides nothing; name the config compare
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m26s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 17:07:54 +00:00
Void
45579b8ec8 runbook: the handoff build ships the repo's config.json and its live check always runs
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 16:54:58 +00:00
5cce38f38b Merge pull request 'docs: client build, install, verify and rollback runbook' (#118) from void/client-config-runbook into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-12 16:51:49 +00:00
Void
45facff436 runbook: handoff build ships its own pinned config; drift check is not a pass check
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m36s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 16:51:41 +00:00
Void
fef7d34fdb runbook: record the served-config drift as observed
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 16:50:28 +00:00
Void
3751adcb64 docs: Ratatoskr client build, install, verify and rollback runbook
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 16:50:16 +00:00
02a0c10665 Merge pull request 'Phone and desk visual findings: notice scrim, tap areas stop at neighbours, desk affordance' (#117) from void/phone-desk-visual-fixes into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 6m5s
2026-11-12 14:31:17 +00:00
Void
96954c0fb5 Desk: fold chevron, quiet text 0.9, solid note border, recorded rulings on their own ground
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m58s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 14:28:34 +00:00
Void
d4463a80cf Phone and desk visual findings: notice line owns its ground, tap hit areas stop at their neighbours, nav rows as tall as their targets
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 14:19:25 +00:00
494d2a7ed4 File zone share: component tests, state machine moved out of ShareSet (#116)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-12 13:15:49 +00:00
c5d1b9c90f File zone: share a set of files as one album (flag off) (#115)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-12 13:01:46 +00:00
f9f94808d5 An explicit theme choice (even Midnight Sky) wins over the sky (#114)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-12 11:41:43 +00:00
df26eb4c99 Footer band test pins the emitted tint and ink (#113)
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m57s
2026-11-12 10:50:08 +00:00
010e68b5e8 A long reply quote stays inside its column (#112)
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-12 10:50:03 +00:00
07b82f2477 Pin sceneButtons folds recipe classes to the installed folds (#111)
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m6s
2026-11-12 09:51:15 +00:00
df245e0b76 Ridge scene: physical sky over Vienna, real horizon, live weather (Nibu) (#110)
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m45s
Nibu's ridge atmosphere (nibu/ridge-atmosphere b0489bc9): Hillaire sky, Copernicus horizon, TAWES weather with clear-sky fallback. Credit: Datenquelle: GeoSphere Austria - https://data.hub.geosphere.at. Soul passed (Nibu 5 findings fixed; Void Soul pass).

Co-authored-by: Nibu <nibu@minds.nibu.gamecult.org>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 02:48:04 +00:00
d3fa27859d Merge pull request 'File receipts say "fetched for <Mind>", not "read by"' (#109) from void/file-received-wording into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m8s
2026-11-12 02:07:03 +00:00
Void
6ba5647ea8 File receipts read 'fetched for <Mind>', never 'read by'
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m32s
The receipt means the mouth fetched and stored the file for the Mind, not that it is in her sandbox or was read.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 01:58:39 +00:00
98b4ec8ab1 Merge pull request 'File zone: one shared room subscription for read-by rows' (#108) from void/file-readers-store into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m47s
2026-11-11 22:47:21 +00:00
Void
1c5331da4d File zone: one room subscription for every 'read by' row
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m30s
receiptStore.ts holds one Timeline/Redaction/TimelineReset listener set per room while any row is mounted and a snapshot of the receipt events; useFileReaders derives each row's readers from it with readersOf (same rules). 200 rows register one listener per event, not 200.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 22:41:35 +00:00
4876d31428 Merge pull request 'File zone: read-by receipts found anywhere in the room' (#107) from void/file-receipts-anywhere into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m13s
2026-11-11 22:39:14 +00:00
9aa34905c9 Merge pull request 'config.json: ship the ridge scene style' (#106) from void/config-ridge into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-11 22:35:38 +00:00
Void
da2445044d File zone: the readers hook rescans on redaction and reset; replies guard is one local; reader order by time
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m26s
Soul findings 1, 2 (doc), 4 and the merge order.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 22:34:39 +00:00
Void
00890b2108 File zone: 'read by' finds receipts anywhere in the room, in the album listing and on the timeline row
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
readersOf (fileReceipt.ts) is the one reader: a receipt is a thread reply or a plain room event, matched by content.event_id; the sender must be @<mind>:<this homeserver>, the hash the file event's, an m.reference present must name the same event, one entry per Mind. The timeline file message draws it too.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 22:33:36 +00:00
Void
64ecdc03f7 config.json: ship the ridge scene style (Meta, 2026-11-11)
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m31s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 22:08:12 +00:00
83baee2c2d Merge pull request 'File zone: Mind contract, plaintext sha256 on send, read-by receipts' (#104) from void/file-zone-contract into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 3m38s
2026-11-11 22:01:23 +00:00
Void
3971c72de7 File zone tests: no shadowed helper name
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 3m42s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 21:28:49 +00:00
Void
08ecacd438 File zone: a receipt counts only from @<mind>:<this homeserver>; doc says what info holds when media fails to load
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Soul: a federated @void:evil.example passed the localpart check.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 21:28:30 +00:00
Void
c34e536132 File zone: a receipt counts only from a Mind's own sender and with the file's hash; info.sha256
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Heimdall's two rules (#minds): trust the Matrix sender, never a field inside; read means the receipt hash equals the file event's.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 21:27:24 +00:00
Void
af328bdb90 File zone: Mind contract; plaintext sha256 in info on send; 'read by <Mind>' from org.gamecult.file_received
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 21:26:36 +00:00
89759f9092 Merge pull request 'config.json: the live chat.gamecult.org config' (#103) from void/config-live into dev
All checks were successful
Rat dev preview / Build dev preview (push) Successful in 4m7s
2026-11-11 21:23:27 +00:00
38e9fcdff3 Merge pull request 'Pose thumbs: stale size comments corrected' (#102) from void/pose-size-docs into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-11 21:19:16 +00:00
Void
4b7697aaeb preview-config: pin the preview scene style (ridge unless given) now that config.json says terrain
All checks were successful
Build pull request / Build pull request (pull_request) Successful in 4m5s
Soul: the repo config now carries the live style, so previews would have turned terrain.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 21:08:53 +00:00
Void
9c5af7aede config.json is the live chat.gamecult.org config
Some checks failed
Build pull request / Build pull request (pull_request) Has been cancelled
Fetched from https://chat.gamecult.org/config.json 2026-11-11: one homeserver, custom homeservers off, gamecult featured space, hash router on, scene tier full with style terrain. dings and registration (keys the live release predates) keep the repo's off defaults. A release built from the repo now ships its own config.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 21:08:15 +00:00
08bf515997 Merge pull request 'Release candidate: dev into main' (#88) from dev into main 2026-11-11 02:13:08 +00:00
683d3b92f8 Merge pull request 'Release candidate: dev 64edacac to main' (#71) from release/dev-64edacac into main 2026-11-10 17:42:15 +00:00
242 changed files with 9508 additions and 638 deletions

View file

@ -1,39 +1,38 @@
{
"defaultHomeserver": 0,
"homeserverList": [
"chat.gamecult.org",
"gamecult.org",
"converser.eu",
"matrix.org",
"mozilla.org",
"unredacted.org",
"xmr.se"
"chat.gamecult.org"
],
"allowCustomHomeservers": true,
"allowCustomHomeservers": false,
"featuredCommunities": {
"openAsDefault": false,
"spaces": ["#gamecult:gamecult.org"],
"rooms": [
"#tuwunel:grin.hu",
"#free-software-movement:matrix.org",
"#gentoo:matrix.org"
"spaces": [
"#gamecult:gamecult.org"
],
"servers": ["activematrixrooms.com", "matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"]
"rooms": [],
"servers": [
"gamecult.org"
]
},
"hashRouter": {
"enabled": false,
"enabled": true,
"basename": "/"
},
"backgroundScene": {
"tier": "full"
"tier": "full",
"style": "ridge"
},
"dings": {
"enabled": false
},
"fileZoneShare": {
"enabled": false
},
"push": {
"vapidPublicKey": "",
"gatewayUrl": "",
"appId": ""
},
"registration": {
"enabled": false,
"url": ""

View file

@ -0,0 +1,98 @@
# File zone: what a Mind can read from a file event
Ruling `ratatoskr:ruling:persona-file-sharing-zone`. The Mind-side fetch is the mouth's
(`eureka-body:cut_spec:mouth-fetch-attachment.r1`); this page is the client side of the same contract.
A Mind cannot fetch an `mxc://` itself (the media endpoint answers 401), so it never holds one.
## What the Mind passes
Room id and event id of the file event. Never a bare `mxc://`, URL or hash. The mouth fetches the event,
reads the media reference from its content, and enforces the Mind's own membership in that room at fetch
time. A room the Mind is not in is refused by the mouth, whatever the client wrote.
## What the client writes (this repo, `RoomInput.tsx` send path)
A file event is an `m.room.message` whose `msgtype` is `m.audio`, `m.image`, `m.video` or `m.file`:
| field | where | note |
| ------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `msgtype` | content | selects the kind from the file's mimetype prefix |
| `body`, `filename` | content | both the file's name (display only; never a path) |
| `url` | content | the `mxc://` of an unencrypted file; the mouth reads it |
| `file` | content | present instead of `url` in an encrypted room: `{url, key, iv, hashes.sha256 (ciphertext), v}`. A Mind has no key; the mouth refuses it |
| `info.mimetype` | content | the browser-reported type, normalised by `safeFile` (e.g. `application/ogg` becomes `audio/ogg`). A claim, not proof: the mouth sniffs magic bytes |
| `info.size` | content | bytes of the file as uploaded |
| `info.sha256` | content | lower-case hex sha256 of the plaintext bytes, computed by the client before send; written in unencrypted rooms only (a plaintext hash in an encrypted room would leak the file's identity) |
The hash is computed over the same bytes that are uploaded, reading the whole file into memory before send (large videos pay for it); if hashing fails the file is sent without one. A file sent by something other than this
client may lack `info.sha256`; the mouth then logs the hash it computed, and the row can show no `fetched for`
for it.
`info.mimetype` and `info.size` are written on audio and file events always; on image and video events `info` exists only if the browser could load the media, otherwise `info` holds `sha256` alone. Image and video events also carry `info.w`, `info.h`, `info.xyz.amorgan.blurhash` and a thumbnail
reference; audio carries no duration. A Mind should not rely on any of those.
## Limits and refusals
- Size: the mouth streams at most 5 MiB and refuses a larger file. The client does not enforce this cap;
its own limit is the homeserver's `m.upload.size`. A file over 5 MiB can be shared with people and
not fetched for a Mind.
- Refusals are the mouth's and are typed: not a member of the room, bridged or Discord room, encrypted
file, over the cap, content type not matching the magic bytes. The client does not re-check them and
nothing in the client depends on the mouth's log.
- The Mind cites files by room id and event id only; a file in a room the Mind is not in does not exist
for it.
## Receipt: `org.gamecult.file_received`
The Mind posts one event of type `org.gamecult.file_received` after a successful fetch, to the room of the
file. When the file has an album root the receipt is a reply in the album's thread, so it arrives with the album's
own listing; otherwise (a composer file sent with no thread) it is a plain room event with no `m.relates_to`. Both
count, and the client finds a receipt by its `event_id` anywhere in the room's loaded timeline or the album's thread:
```json
{
"m.relates_to": { "rel_type": "m.thread", "event_id": "<album root event id>" },
"org.gamecult.file_received": {
"event_id": "<file event id>",
"sha256": "<hex sha256 of the bytes the Mind fetched>",
"size": 123456
}
}
```
A plain room-event receipt may also carry `m.relates_to: {rel_type: "m.reference", event_id: <file event id>}`, which lets
the mouth find its earlier receipts through `/relations` for dedup. The client matches by `content.event_id` and reads no
relation but this one: if a `m.reference` is present its `event_id` must equal `content.event_id`, else the receipt counts
for nothing. A thread relation is not read. However many receipts a Mind has posted for a file, the row draws one
`fetched for` for that Mind.
Two rules, both enforced on each side:
1. **The sender is the Mind.** The row trusts the Matrix `sender` of the receipt event and nothing inside it:
no Mind field is read. `fetched for X` counts only when the sender is X's own Matrix identity (the
full id is `@<mind>:<this homeserver>` for a Mind in the Persona catalogue; a display name, a lookalike or another server's `@void:evil.example` does not count).
2. **Fetched means verified.** The Mind posts only after the sha256 of the bytes it fetched equals the file
event's `info.sha256`, and the receipt carries that hash. On a mismatch it posts nothing (or a typed
refusal that the row does not draw). The row checks it again: the receipt's `sha256` must equal the
file event's `info.sha256`. A file event with no `info.sha256` can show no `fetched for`.
The receipt is inert: no paths, URLs, filenames or anything a browser would fetch, and the client reads only
`event_id`, `sha256` and the sender. It has no `msgtype`, so the album's file listing ignores it. The mouth's
log is the fuller record for Minds; this event is the public tick.
The row draws `fetched for <Mind>`: one receipt per Mind per file (the latest), several Minds side by side. A
receipt for an event that is not a file of that album, a malformed one, and a redacted one are ignored.
The wording is deliberate: the receipt says the mouth fetched and stored the file for the Mind, not that it is in the Mind's sandbox and not that it was read. Client strings never say "saved" or "read".
The sender check keeps a member from ticking for a Mind; it does not prove the Mind read the file beyond
what the hash and the Mind's own account show.
## Not built yet
- Files sent from the composer carry no thread relation, so they reach an album only if written into a
thread by another tool; the album upload path (docs/file-zone.md, "Upload") is unwritten. Their receipts are plain
room events, and the timeline row for the file shows `fetched for` from them.
- A receipt that is still encrypted when it arrives is not seen until a later timeline event triggers a rescan; zones are
unencrypted and the hash is written only there, so this is the encrypted-room limit again.
- The timeline row reads the room's loaded timeline only: a receipt older than what the client has paged in is not
seen there (an album's own listing reads the thread, which is paged separately).
- Encrypted zones are not listed.

View file

@ -71,21 +71,21 @@ Reuse the composer's pipeline, pointed at an album instead of the main timeline:
- `createUploadAtom` / `useBindUploadAtom` (`src/app/state/upload.ts`), `UploadCard`, `upload-board` for progress.
- On success, the msgContent builder produces the event; the zone adds the thread relation to the album root before sending (the composer already does this for thread replies, `RoomInput.tsx`).
- "New album" = send the root text event, then upload into its thread. Multi-file drop = N events, sent in order.
- "New album" = send the root text event, then upload into its thread. Multi-file drop = N events, sent in order.
- Size limit from `MediaConfigLoader` (server `m.upload.size`). **UNKNOWN:** whether the 32 MB `shelf` kit's clips (42 x ~0.75 MB) should be uploaded individually or batched; individually is the only option without a server change.
## Existing code to reuse (verified present in the repo)
| need | where |
|---|---|
| audio player (play/seek/volume, encrypted download) | `src/app/components/message/content/AudioContent.tsx` (210 lines) |
| video, image, generic file | `.../content/VideoContent.tsx`, `ImageContent.tsx`, `FileContent.tsx` |
| full-screen image viewer | `src/app/components/image-viewer/ImageViewer.tsx` |
| media dispatch by msgtype | `src/app/components/RenderMessageContent.tsx`, `message/MsgTypeRenderers.tsx` |
| upload + progress | `state/upload.ts`, `components/upload-card`, `upload-board` |
| event content builders | `features/room/msgContent.ts` |
| thread plumbing | `features/room/message/Message.tsx`, `RoomTimeline.tsx`, `RoomInput.tsx` (thread relation on send) |
| room-scoped file/pin precedent | `features/room/room-pin-menu/RoomPinMenu.tsx` |
| need | where |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| audio player (play/seek/volume, encrypted download) | `src/app/components/message/content/AudioContent.tsx` (210 lines) |
| video, image, generic file | `.../content/VideoContent.tsx`, `ImageContent.tsx`, `FileContent.tsx` |
| full-screen image viewer | `src/app/components/image-viewer/ImageViewer.tsx` |
| media dispatch by msgtype | `src/app/components/RenderMessageContent.tsx`, `message/MsgTypeRenderers.tsx` |
| upload + progress | `state/upload.ts`, `components/upload-card`, `upload-board` |
| event content builders | `features/room/msgContent.ts` |
| thread plumbing | `features/room/message/Message.tsx`, `RoomTimeline.tsx`, `RoomInput.tsx` (thread relation on send) |
| room-scoped file/pin precedent | `features/room/room-pin-menu/RoomPinMenu.tsx` |
Nothing named "gallery" exists as a feature; `AudioContent` takes its controls as render props (`renderMediaControl`), so it can be rehosted in a row without a fork. **UNKNOWN:** whether `AudioContent` can be rendered outside a timeline event context (it needs only `url`, `info`, `mimeType`, `encInfo`, from the event content, so probably yes).
@ -104,3 +104,20 @@ Slices 1+2 serve the first use case. Each is its own PR. Because marks are ordin
- How the client identifies the zone room (config id recommended).
- Whether exports are per listener (proposed) or a merged all-listeners file.
- Where `device` comes from, now that reactions cannot carry it (export dialog, proposed).
## Share a set (built, behind `fileZoneShare.enabled`)
The ruling `ratatoskr:ruling:persona-file-sharing-zone` asks that a set of files arrive together, not one attachment at a time. The zone already holds one thread per album, so a set is an album: **one root text message plus one `m.thread` reply per file**, the exact shape `buildAlbums` reads. Nothing new on the wire and no homeserver change; a Mind's mouth that posts into the zone room and a person using the client produce the same events.
Matrix options checked: (a) a thread, chosen: stock, rendered by every client, already the zone's album; (b) `m.gallery` (MSC4274) puts several items in one event, but it is unstable, most clients and this one do not render it, and each item still needs its own upload; (c) a shared custom relation: no client groups by it. A thread costs one extra event per set (the root) and nothing else.
Client, `src/app/features/albums`:
- `shareSet.ts`: `sendShareSet(port, title, files)`. Sends the root, then each file in pick order, each reply falling back to the previous event (`threadRelation`). A file whose upload fails is named in the result and the rest still go; a root that fails to send throws before any upload. The root alone is not an album in the list (`/threads` returns only roots that have a reply), so a share where every upload failed leaves a lone text message in the timeline. After a send the panel keeps only the failed files picked; a retry makes a second album. Ports are injected, so the order and failure rules are tested without a homeserver (`shareSet.test.ts` reads the sent events back through `buildAlbums`).
- `shareController.ts`: the panel's state machine (picked files, phase, the one-send-per-room guard, the encrypted refusal, failed files kept by position), apart from React so tests drive it without a DOM; `shareController.test.ts` and `ShareSet.test.tsx` (static render of each state, the flag) pin it. No DOM test environment exists in this repo, so a real tap on a real input is covered by the phone check, not by tests.
- `ShareSet.tsx`: phone layout. Idle, it is one text row, "Share files". Picking files (any number from the native picker; a second pick replaces the first) mounts a title field, the picked names on one dim line, and Send / Cancel as full-width rows; progress is "Sending 2 of 5". It is shown only on the album list (kept mounted but hidden while an album is open, so a running send keeps its state), one send at a time per room (tracked outside the component; leaving the page does not stop a send, it only loses its progress and result), and never in an encrypted zone (re-checked when Send is tapped) (the plaintext `info.sha256` the Mind verifies against is written in unencrypted rooms only).
- Content per file comes from `getMsgContent` in `features/room/msgContent.ts`, now also used by the composer, so a shared file and an attached file carry the same `info` including `sha256`.
Flag: `fileZoneShare.enabled` in `config.json`, default `false`. With it off nothing mounts.
Not built: a Mind's own path into the zone (outbox, Persona decision, mouth post) belongs to gamecult-ops / Huginn / the mouth, not this client; sharing into an existing album ("add files"); upload cancel; pagination beyond 100 albums/files.

View file

@ -0,0 +1,108 @@
# chat.gamecult.org: shell compression and caching
Follow-up `ratatoskr:follow_up:chat-shell-compression`. Measured 2026-11-12 against the live
host, which serves release `ratatoskr-nemesis-9b9b495d` (the `index-CAataSJe.js` bundle; the offline table
below also uses the `f8115609` handoff build, `index-D0yL0Dgy.js`, and says which row is which). The host config is
`nginx/chat.gamecult.org.conf` in `gamecult-ops`, owned by Eureka; nothing here is applied
to the host.
## What the host serves now
The 2026-10-31 probe (no Content-Encoding, no Cache-Control on `/assets/`) is stale: the
`nginx.conf` has `gzip on`, the site file sets level 5, `gzip_vary` and `gzip_types` for js, css, json, svg and wasm, and carries an immutable
`Cache-Control` on `/assets/` and `/preview/assets/`. Probed with `curl -H 'Accept-Encoding: X'`.
| Resource | raw | gzip (served now) | brotli (served now) |
|---|---|---|---|
| `/assets/index-CAataSJe.js` | 3,732,771 | 956,085 | none: `br` is ignored, identity returned |
| `/assets/index-BLdKqKqx.css` | 111,267 | 19,952 | none |
| `/assets/matrix_sdk_crypto_wasm_bg-DwIieW7K.wasm` | 5,573,870 | 1,878,147 | none |
| `/` (index.html, no-cache) | 3,066 | gzip | none |
Headers: hashed `/assets/` answer `Cache-Control: public, max-age=31536000, immutable`;
`/`, `index.html`, `sw.js`, `manifest.json`, `config.json` answer `no-cache`; compressed
responses carry `Vary: Accept-Encoding` and a weak ETag. `config.json` (424 B) is below
`gzip_min_length 1024` and goes uncompressed, which is correct.
So the original ask is done. What is left is brotli, which nginx 1.26.3 here cannot serve:
`/usr/sbin/nginx -V` shows `--with-http_gzip_static_module` and no brotli module.
## What brotli buys
Level-5 gzip is what the host does per request. Offline measurement on the same files
(node `zlib`, 2026-11-12):
| File | raw | gzip 5 (node) | gzip 9 | brotli 11 | brotli vs live |
|---|---|---|---|---|---|
| live main chunk `index-CAataSJe.js` | 3,732,771 | 957,199 | 945,424 | 725,167 | -24% |
| handoff main chunk `index-D0yL0Dgy.js` | 3,868,313 | 1,015,680 | 1,001,446 | 773,992 | -24% |
| crypto wasm | 5,573,870 | 1,868,282 | 1,851,108 | 1,343,595 | -28% |
| `ReactPrism-Dm2uRF4v.js` | 577,517 | 209,339 | 208,376 | 163,525 | -22% |
A cold load of the two big files falls from about 2.8 MB to about 2.1 MB on the wire; the
saving is ~0.7 MB once per hashed build per device while the HTTP cache keeps the file (immutable; the
service worker does no precaching, so eviction or a new device pays again). gzip 9 alone buys 1-2%, so
`gzip_static` without brotli is not worth a build step. Brotli is the whole prize, and it
needs one root step.
Cost: brotli q11 takes about 12 s for the wasm and 8 s for the main chunk on one core, so
the whole dist is about two minutes at build time (measured, 84 files) and zero CPU per request. Do not use q11
on the fly; that is why the plan is precompressed twins.
## Root list for Eureka (host)
1. `apt install libnginx-mod-http-brotli-filter libnginx-mod-http-brotli-static` (trixie/main, 1.0.0~rc-6,
depend on `nginx-abi-1.26.3-1`, which the installed nginx provides; apt may offer an nginx patch upgrade
alongside, say so before accepting). Do not enable the config yet.
2. Install a build with twins (`PRECOMPRESS=1`, below) the usual way
(`docs/gamecult/client-build-install.md`). Twins for files that are not there change nothing.
3. In the `chat.gamecult.org` `server` block, beside the existing gzip lines:
```nginx
brotli_static on; # serve <file>.br when the client sends br
gzip_static on; # serve <file>.gz; falls back to the on-the-fly gzip above
brotli on; # on-the-fly br for files with no twin (index.html, small files)
brotli_comp_level 5;
brotli_types text/css application/javascript text/javascript application/json application/manifest+json image/svg+xml application/wasm;
```
The `location ^~ /assets/` block and `/preview/`, `/element/` and `/mockups/` inherit these; they only add headers.
No other change: the `Cache-Control` and `Vary` lines stay as they are, and
`gzip_vary on` already makes the on-the-fly and static paths vary correctly.
4. `nginx -t && systemctl reload nginx`. Twins first, config second: with `brotli on` and no twins every
client gets on-the-fly q5 brotli of the 3.7 and 5.6 MB files, which costs CPU per request.
Rollback: remove the five lines and reload. The `.br`/`.gz` files can stay on disk, unused.
## Build side
`build-prod-dist.sh` (Void's, in `/opt/commons/handoff/ratatoskr-main-build/`) gains
`PRECOMPRESS=1`, which runs `precompress.cjs <dist>` before the manifest is written. It
writes `<file>.br` (q11) and `<file>.gz` (level 9, no mtime, deterministic for one node) next
to each js, css, wasm, json, svg, html file over 1 KiB, only when smaller than the original.
Default is off, so builds installed before the host change do not ship ~27 MB of twins that
nothing serves. The twins appear in `SHA256SUMS`; the determinism compare still applies.
Tested on `ReactPrism-Dm2uRF4v.js` (gzip roundtrip identical to the original); a full
`PRECOMPRESS=1` build has not been run because it waits on step 1.
The script is not in this repo; its placement is a standing open item of the build handoff.
## Before/after measurement
Run from any machine; `X` is the asset URL. Before = today's table above.
```sh
for ae in identity gzip br; do
printf '%s ' "$ae"; curl -s -o /dev/null -H "Accept-Encoding: $ae" -w '%{size_download} %header{content-encoding}\n' "$X"
done
curl -sI -H 'Accept-Encoding: br' "$X" | grep -iE 'content-encoding|vary|etag|cache-control'
```
Pass: with `br` the main chunk is about 774 KB and the wasm about 1.34 MB with
`Content-Encoding: br`; with `gzip` the bytes are no larger than today; with `identity`
the file is the raw size; `Vary: Accept-Encoding` and the immutable `Cache-Control` are
still present on all three; `index.html`, `sw.js` and `config.json` still `no-cache`.
The service worker's registration and an existing session must still work (open chat on
the operator's phone, hard reload once, check the network panel's "transferred" column).
Check `/preview/assets/` the same way, but `/preview/` is served from `/srv/cinny/preview`, which this build
script does not produce: it gets on-the-fly brotli, not twins, until a preview build carries them.

View file

@ -0,0 +1,121 @@
# Ratatoskr client: build, install, verify, roll back
Who does what. The steward (Void) builds and hands off; Void holds no root and does not deploy.
Eureka installs on the chat.gamecult.org host, which is Eureka's. This page is the shared
procedure. Nothing here is run by the build step against the live host.
## Facts about the host
- Web root: `/srv/cinny/`. `current` is a symlink to one directory under `releases/`; the web
server serves `current`. Releases are `releases/ratatoskr-<8hex>` (older ones are
`ratatoskr-nemesis-<8hex>`). Previous releases stay on disk; that is the rollback.
- The client is static. `config.json` sits in the release root beside `index.html` and is read
at load. Cinny's `Dockerfile` and `docker-nginx.conf` are upstream's container path, not the
chat.gamecult.org path.
- Check the pointer before anything else: `readlink /srv/cinny/current`.
## 1. Build (steward)
Two builds exist, and they treat `config.json` differently.
**By hand (the repo's config).** `vite build` copies the repo's `config.json` into `dist/`
(`vite.config.js`, static copy of `config.json`). Ratatoskr #103 made the repo file the intended
served config, and #106 set `backgroundScene.style` to `ridge`; the served file has not caught up
(see Open). Do not hand-edit `dist/config.json`.
```sh
git clone https://eureka.gamecult.org/forge/GameCult/Ratatoskr.git && cd Ratatoskr
git checkout <full-40-hex-sha> # build a named commit, not a branch tip
npm ci --ignore-scripts # .npmrc sets legacy-peer-deps; skips husky's prepare hook
node scripts/sync-poses.mjs # pose art is not committed; this also rewrites the tracked
# src/app/components/message/pose/catalogue.json
NODE_OPTIONS=--max_old_space_size=6144 npm run build # writes dist/
```
Skip `sync-poses` and the build ships with no poses. Do not use `npm run build:preview` for
production: it rewrites `dist/config.json` for preview (hash router, chosen tier and style).
**The handoff build (what the host installs).**
`/opt/commons/handoff/ratatoskr-main-build/build-prod-dist.sh <full-sha>` (see its `README.md`)
writes `/opt/commons/handoff/ratatoskr-main-<full-sha>/{dist,SHA256SUMS,BUILD-INFO,build.log}`,
locks the files read-only and refuses to overwrite. The dist carries the repo's `config.json` at
that sha (BUILD-INFO `config_json_source`, `config_json_sha256`); there is no separate pinned copy.
Its live check always runs: it fetches the served config and compares it byte for byte with the
repo's. A difference prints the diff and stops the build. `ALLOW_CONFIG_DRIFT=1` builds anyway and
BUILD-INFO records `config_json_live_check: DIFFERS ...` and `config_json_live_sha256`, so the
handoff states which config differs and the installer decides. From Yggdrasil the public name does
not resolve: pass `LIVE_CURL_ARGS="--resolve chat.gamecult.org:443:127.0.0.1"`.
The folder, with the three pins (`SHA256SUMS` sha256, `BUILD-INFO` sha256, lockfile sha256) and
the node and npm versions, is what the install request names.
Every build stamps its commit into `index.html` (`scripts/buildSha.js`): `<meta name="build-sha" content="<40-hex>">`,
with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. Where git governs the tree it decides: an explicit `BUILD_SHA` naming a different commit than HEAD stamps `<sha>-mismatch`, a git failure stamps `unknown`. A full-sha `BUILD_SHA` stands only for a tree git does not govern (a source archive) or cannot read (unusable git, for example a dubious-ownership refusal). The handoff
build refuses a dist whose stamp differs from the sha it was asked for, and prints the stamp in its block.
To read it from anywhere: `curl -s https://chat.gamecult.org/ | grep -o 'build-sha" content="[^"]*'`.
Before handing off:
```sh
cd <folder>/dist && sha256sum -c ../SHA256SUMS # 0 failures
grep -rl 'demo.invalid\|demo-preview-token' <folder>/dist # must print nothing
# from Yggdrasil the public name may not resolve; --resolve points it at the local server
diff <(curl -fsS --resolve chat.gamecult.org:443:127.0.0.1 https://chat.gamecult.org/config.json) <folder>/dist/config.json
```
A config difference is a finding to report, not something to paper over: either live drifted or
the repo is wrong, and a ruling (Meta, for `backgroundScene.style`) says which ships.
## 2. Install (host owner, root, by hand)
Note the rollback target first: `readlink /srv/cinny/current`.
Unpack beside the live release; do not touch the live directory. The target must not exist, or
`cp -r` nests `dist` inside it.
```sh
R=ratatoskr-<8hex>
H=/opt/commons/handoff/ratatoskr-main-<full-sha>
test ! -e /srv/cinny/releases/$R
cp -r $H/dist /srv/cinny/releases/$R
cmp /srv/cinny/current/config.json /srv/cinny/releases/$R/config.json
(cd /srv/cinny/releases/$R && sha256sum -c $H/SHA256SUMS)
```
`cmp` is a drift check, not a pass check. Live has no trailing newline; the repo's file has one,
so even equal content differs. Read the difference (`diff` the two) and settle which config
ships. To keep the host's config byte-for-byte, as past installs did,
`cp /srv/cinny/current/config.json /srv/cinny/releases/$R/config.json`; `sha256sum -c` then flags
`config.json` only (read that one line; `--ignore-missing` does not hide a mismatch).
Switch (a single symlink swap; `ln -sfn` unlinks then links, so there is a moment with no link):
```sh
ln -sfn releases/$R /srv/cinny/current
```
## 3. Verify
- `readlink /srv/cinny/current` names `releases/$R`.
- `curl -s https://chat.gamecult.org/config.json | sha256sum` equals `sha256sum /srv/cinny/releases/$R/config.json`.
- Load https://chat.gamecult.org/ and hard-refresh (the service worker, `sw.js`, can hold the
previous assets). Sign-in card shows, the background scene draws, the browser console has no
page errors, a poses/asset request returns 200 and not index.html.
## 4. Roll back
Point `current` at the release noted before the switch. Live at the time of writing is
`ratatoskr-nemesis-9b9b495d`:
```sh
ln -sfn releases/ratatoskr-nemesis-9b9b495d /srv/cinny/current
```
Hard-refresh to clear `sw.js`. Do not delete the failed release; leave it for diagnosis.
## Open
- The served config (checked 2026-11-12) is not the repo's: it says `backgroundScene.style`
`terrain` where the repo says `ridge` (#106, Meta 2026-11-11), lacks the repo's `dings`,
`fileZoneShare` and `registration` blocks (all off in the repo), and has no trailing newline.
The new keys default off or inert when absent, so only the style is a decision. Meta says which
style ships. Until the served file is replaced, every handoff build stops at the live check
unless `ALLOW_CONFIG_DRIFT=1` is set (section 1).

View file

@ -29,6 +29,7 @@ esbuild
plugins: [rawPlugin],
format: 'iife',
target: 'es2020',
define: { 'import.meta.env.BASE_URL': '"/"' },
})
.then(() => {
if (ridge) {

View file

@ -1,12 +1,13 @@
{
"name": "Ratatoskr",
"short_name": "Ratatoskr",
"id": "/",
"description": "GameCult's Matrix client",
"dir": "auto",
"lang": "en-US",
"display": "standalone",
"orientation": "portrait",
"start_url": "./",
"scope": "./",
"background_color": "#07111a",
"theme_color": "#07111a",
"icons": [
@ -54,6 +55,12 @@
"src": "./public/android/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "./public/android/android-chrome-maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

123
scripts/bake-horizon.py Normal file
View file

@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Bake the 360 degree horizon seen from the ridge viewpoint (src/app/features/scene/ridge/vienna.ts: VIENNA) into
src/app/features/scene/ridge/viennaHorizon.json: per azimuth, the highest elevation angle the terrain subtends, the distance and
height of the point that sets it, and the surface slope there (the normal). The source is Copernicus DEM GLO-30, a DSM (it
includes buildings and trees, which is what a horizon is made of). Source, licence and attribution go beside the bake, in its
`source` block, so the file cannot travel without them.
python3 scripts/bake-horizon.py <dir with the nine Copernicus_DSM_COG_10_N{47..49}_00_E{015..017}_00_DEM.tif> [out.json]
Needs numpy, tifffile, imagecodecs (pip, in a venv: this is a build-time tool, the bake is committed).
"""
import json
import math
import sys
from pathlib import Path
import numpy as np
import tifffile
LAT, LON = 48.1929826, 16.3534237 # the server location Meta gave, 2026-11-11; keep equal to VIENNA in vienna.ts
STEP_DEG = 0.5
RADIUS_KM = 60.0
EARTH_R = 6_371_000.0
REFRACTION_K = 0.13 # standard terrestrial refraction: effective radius R / (1 - k)
EYE_ABOVE_GROUND = 0.0 # the viewpoint is the DSM surface at the point (a roof, in a city); the page lifts it with VIENNA.altitudeKm
MIN_KM = 1.0 # the DSM at 30 m cannot resolve neighbouring buildings as a horizon; nearer than this is not ridge
class Mosaic:
def __init__(self, folder: Path):
self.tiles = {}
for p in sorted(folder.glob("Copernicus_DSM_COG_10_N*_E*_DEM.tif")):
lat0 = int(p.name.split("_N")[1][:2])
lon0 = int(p.name.split("_E")[1][:3])
with tifffile.TiffFile(p) as t:
self.tiles[(lat0, lon0)] = t.pages[0].asarray()
def height(self, lat: float, lon: float) -> float:
key = (math.floor(lat), math.floor(lon))
a = self.tiles.get(key)
if a is None:
return float("nan")
n = a.shape[0]
# Tile origin is the north-west corner; pixels are 1/n degree, pixel-is-area (centre at +0.5).
y = (key[0] + 1 - lat) * n - 0.5
x = (lon - key[1]) * n - 0.5
y0, x0 = int(math.floor(y)), int(math.floor(x))
y0 = min(max(y0, 0), n - 2)
x0 = min(max(x0, 0), n - 2)
fy, fx = min(max(y - y0, 0), 1), min(max(x - x0, 0), 1)
top = a[y0, x0] * (1 - fx) + a[y0, x0 + 1] * fx
bot = a[y0 + 1, x0] * (1 - fx) + a[y0 + 1, x0 + 1] * fx
return float(top * (1 - fy) + bot * fy)
def offset(lat: float, lon: float, az_deg: float, d_m: float):
az = math.radians(az_deg)
dlat = d_m * math.cos(az) / 111_320.0
dlon = d_m * math.sin(az) / (111_320.0 * math.cos(math.radians(lat)))
return lat + dlat, lon + dlon
def main() -> None:
folder = Path(sys.argv[1])
out = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("src/app/features/scene/ridge/viennaHorizon.json")
m = Mosaic(folder)
h0 = m.height(LAT, LON) + EYE_ABOVE_GROUND
az_count = int(360 / STEP_DEG)
elev, dist, peak, east, north = [], [], [], [], []
for i in range(az_count):
az = i * STEP_DEG
best, best_d, best_h, best_pt = -90.0, 0.0, h0, (LAT, LON)
d = MIN_KM * 1000
while d <= RADIUS_KM * 1000:
lat, lon = offset(LAT, LON, az, d)
h = m.height(lat, lon)
if not math.isnan(h):
drop = d * d / (2 * EARTH_R) * (1 - REFRACTION_K)
ang = math.degrees(math.atan2(h - h0 - drop, d))
if ang > best:
best, best_d, best_h, best_pt = ang, d, h, (lat, lon)
d += 30.0 if d < 5000 else 60.0 if d < 20000 else 120.0
# Surface slope at the peak (rise per metre, east and north), central difference over 60 m.
la, lo = best_pt
dl, dn = 60.0 / 111_320.0, 60.0 / (111_320.0 * math.cos(math.radians(la)))
ge = (m.height(la, lo + dn) - m.height(la, lo - dn)) / 120.0
gn = (m.height(la + dl, lo) - m.height(la - dl, lo)) / 120.0
elev.append(round(best, 3))
dist.append(round(best_d / 1000, 2))
peak.append(round(best_h, 1))
east.append(round(ge, 3))
north.append(round(gn, 3))
doc = {
"viewpoint": {"lat": LAT, "lon": LON, "groundM": round(h0, 1)},
"stepDeg": STEP_DEG,
"radiusKm": RADIUS_KM,
"minKm": MIN_KM,
"curvature": f"drop d^2/2R*(1-{REFRACTION_K}), R={int(EARTH_R)} m",
"source": {
"dataset": "Copernicus DEM GLO-30 (COP-DEM_GLO-30-DGED), a DSM, EGM2008 heights, 1 arc-second grid",
"tiles": sorted(f"N{a:02d}_00_E{b:03d}_00" for a, b in m.tiles),
"tileBytesEach": "about 42-44 MB (Cloud Optimized GeoTIFF); nine tiles, about 380 MB, read once, not committed",
"url": "https://copernicus-dem-30m.s3.amazonaws.com/ (AWS Open Data, eu-central-1, no account)",
"licence": "Copernicus DEM licence: free of charge for any use incl. commercial, attribution required, no warranty "
"(https://spacedata.copernicus.eu/collections/copernicus-digital-elevation-model, Licencing)",
"attribution": "Contains modified Copernicus data: DEM © DLR e.V. 2010-2014 and © Airbus Defence and Space GmbH 2014-2018 "
"provided under COPERNICUS by the European Union and ESA; all rights reserved.",
"bakedBy": "scripts/bake-horizon.py",
},
"elevationDeg": elev,
"distanceKm": dist,
"peakHeightM": peak,
"slopeEast": east,
"slopeNorth": north,
}
out.write_text(json.dumps(doc, separators=(",", ":")) + "\n")
top = int(np.argmax(elev))
print(f"ground {h0:.1f} m; {az_count} azimuths; highest {elev[top]:.2f} deg at az {top * STEP_DEG}, {dist[top]} km, {peak[top]} m")
print(f"wrote {out} ({out.stat().st_size} bytes)")
if __name__ == "__main__":
main()

56
scripts/buildSha.js Normal file
View file

@ -0,0 +1,56 @@
// The commit a bundle was built from, stamped into its index.html as <meta name="build-sha">, so a built
// folder says what it is without a build log: `grep -o 'build-sha" content="[^"]*' dist/index.html`.
// Where git governs the tree (REPO_DIR is a repository root) git decides: HEAD, suffixed -dirty when the tree has
// uncommitted changes, or `<BUILD_SHA>-mismatch` when an explicit BUILD_SHA names a different commit, so a stamp
// never vouches for code it does not hold; a git failure there is 'unknown'. Where git does not govern (a source
// archive), a full-sha BUILD_SHA stands, else 'unknown'.
import { execFileSync } from 'child_process';
import { dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
const REPO_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
// Vite writes vite.config.js.timestamp-*.mjs next to the config while it loads it; that file is the
// build's own, not source, so an UNTRACKED one must not mark the tree dirty. A tracked file of that name,
// or any other change, still does. Filtering status lines (not a pathspec) is what keeps it untracked-only.
const VITE_TEMP = /^\?\? (?:.*\/)?vite\.config\.[a-z]+\.timestamp-\d+-[0-9a-f]+\.mjs$/;
export const gitIn =
(cwd) =>
(...args) =>
execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
const FULL_SHA = /^[0-9a-f]{40}$/;
export const readBuildSha = (env = process.env, run = gitIn(REPO_DIR)) => {
const fromEnv = (env.BUILD_SHA ?? '').trim();
const explicit = FULL_SHA.test(fromEnv) ? fromEnv : null;
// Git governs this tree only when REPO_DIR is its root: a source archive unpacked inside some other
// repository has no git of its own, and that repository's HEAD says nothing about it.
let governed;
try {
governed = run('rev-parse', '--show-prefix').trim() === '';
} catch {
governed = false;
}
if (!governed) return explicit ?? 'unknown';
// Git governs and answers: from here a failure is 'unknown', never the bare explicit sha.
try {
const head = run('rev-parse', 'HEAD').trim();
if (!FULL_SHA.test(head)) return 'unknown';
if (explicit && explicit !== head) return `${explicit}-mismatch`;
const dirty = run('status', '--porcelain', '-uall', '--', '.')
.split('\n')
.some((l) => l.trim() !== '' && !VITE_TEMP.test(l));
return dirty ? `${head}-dirty` : head;
} catch {
return 'unknown';
}
};
export const buildShaPlugin = (sha = readBuildSha()) => ({
name: 'ratatoskr-build-sha',
transformIndexHtml: () => [
{ tag: 'meta', attrs: { name: 'build-sha', content: sha }, injectTo: 'head-prepend' },
],
});

170
scripts/buildSha.test.js Normal file
View file

@ -0,0 +1,170 @@
import { execFileSync } from 'child_process';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import { buildShaPlugin, gitIn, readBuildSha } from './buildSha';
const SHA = 'b6b1d522f1c0a9d3e2b4c5d6e7f8091a2b3c4d5e';
const gitAt =
(head, status = '') =>
(cmd, ...args) =>
cmd === 'rev-parse' ? (args[0] === '--show-prefix' ? '\n' : `${head}\n`) : status;
describe('readBuildSha', () => {
it('reads the checkout it is built from', () => {
expect(readBuildSha({}, gitAt(SHA))).toBe(SHA);
});
it('marks a tree with uncommitted changes, so a stamp never vouches for code it does not hold', () => {
expect(readBuildSha({}, gitAt(SHA, ' M src/x.ts\n'))).toBe(`${SHA}-dirty`);
});
it('lets BUILD_SHA stand in where there is no git, and ignores a value that is not a full sha', () => {
const noGit = () => {
throw new Error('not a git repository');
};
expect(readBuildSha({ BUILD_SHA: SHA }, noGit)).toBe(SHA);
expect(readBuildSha({ BUILD_SHA: SHA }, gitAt(SHA))).toBe(SHA);
// a different HEAD is a mismatch, not a clean stamp of the explicit sha
expect(readBuildSha({ BUILD_SHA: SHA }, gitAt('c'.repeat(40)))).toBe(`${SHA}-mismatch`);
// a value that is not a lowercase full sha is ignored
['b6b1d52', SHA.toUpperCase(), `${SHA}0`].forEach((v) =>
expect(readBuildSha({ BUILD_SHA: v }, noGit)).toBe('unknown')
);
// git that answers but cannot say what the tree holds fails closed
const brokenStatus = (cmd, ...args) => {
if (cmd === 'status') throw new Error('index file smaller than expected');
return args[0] === '--show-prefix' ? '\n' : `${SHA}\n`;
};
expect(readBuildSha({ BUILD_SHA: SHA }, brokenStatus)).toBe('unknown');
expect(readBuildSha({}, brokenStatus)).toBe('unknown');
expect(readBuildSha({}, gitAt('nope'))).toBe('unknown');
// inside another repository's subfolder git does not govern: the explicit sha stands
const nested = (cmd, ...args) => (args[0] === '--show-prefix' ? 'vendor/ratatoskr/\n' : SHA);
expect(readBuildSha({ BUILD_SHA: SHA }, nested)).toBe(SHA);
expect(readBuildSha({}, nested)).toBe('unknown');
expect(readBuildSha({ BUILD_SHA: 'main' }, noGit)).toBe('unknown');
});
it("says 'unknown' rather than failing the build when git is absent", () => {
expect(
readBuildSha({}, () => {
throw new Error('spawn git ENOENT');
})
).toBe('unknown');
});
});
describe('readBuildSha against a real repository', () => {
const made = [];
afterEach(() => {
made.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true }));
});
const repo = () => {
const dir = mkdtempSync(join(tmpdir(), 'buildsha-'));
made.push(dir);
const g = (...a) => execFileSync('git', a, { cwd: dir, stdio: 'ignore' });
g('init', '-q');
mkdirSync(join(dir, 'src'));
writeFileSync(join(dir, 'src', 'a.ts'), 'export {};\n');
g('add', '.');
g('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'x');
return dir;
};
it("stays clean with vite's temp config file present, and is dirty with a stray source file", () => {
const dir = repo();
const run = gitIn(dir);
writeFileSync(join(dir, 'vite.config.js.timestamp-1700000000-abc123.mjs'), '');
expect(readBuildSha({}, run)).toMatch(/^[0-9a-f]{40}$/);
writeFileSync(join(dir, 'src', 'zz-stray.ts'), '');
expect(readBuildSha({}, run)).toMatch(/^[0-9a-f]{40}-dirty$/);
});
});
describe('readBuildSha dirty rules against a real repository', () => {
const made2 = [];
afterEach(() => {
made2.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true }));
});
const TEMP = 'vite.config.js.timestamp-1700000000-abc123.mjs';
const repo2 = (tracked = []) => {
const dir = mkdtempSync(join(tmpdir(), 'buildsha-'));
made2.push(dir);
const g = (...a) => execFileSync('git', a, { cwd: dir, stdio: 'ignore' });
g('init', '-q');
mkdirSync(join(dir, 'src'));
writeFileSync(join(dir, 'src', 'a.ts'), 'export {};\n');
tracked.forEach((f) => writeFileSync(join(dir, f), 'x\n'));
g('add', '.');
g('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'x');
return {
dir,
head: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim(),
};
};
it('does not let an explicit BUILD_SHA mask a dirty tree', () => {
const { dir, head } = repo2();
const run = gitIn(dir);
expect(readBuildSha({ BUILD_SHA: head }, run)).toBe(head);
writeFileSync(join(dir, 'src', 'zz-stray.ts'), '');
expect(readBuildSha({ BUILD_SHA: head }, run)).toBe(`${head}-dirty`);
});
it('lets git speak only for a tree whose root it is: an archive unpacked inside another repository keeps its explicit sha', () => {
const { dir } = repo2();
const inner = join(dir, 'vendor', 'ratatoskr');
mkdirSync(inner, { recursive: true });
writeFileSync(join(inner, 'x.ts'), '');
expect(readBuildSha({ BUILD_SHA: SHA }, gitIn(inner))).toBe(SHA);
expect(readBuildSha({}, gitIn(inner))).toBe('unknown');
});
it("notices an untracked .mjs that is not vite's temp file", () => {
const { dir, head } = repo2();
writeFileSync(join(dir, 'foo.mjs'), '');
expect(readBuildSha({}, gitIn(dir))).toBe(`${head}-dirty`);
rmSync(join(dir, 'foo.mjs'));
// only vite's exact shape is its own: vite.config.<ext>.timestamp-<digits>-<hex>.mjs
[
'vite.config.js.timestamp-1-ab.mjs.bak',
'vite.config.js.timestamp-x-ab.mjs',
'vite.config.js.timestamp-1-zz.mjs',
'vite.config.a.b.timestamp-1-ab.mjs',
'vite.config.js.timestamp-1-ab-c.mjs',
].forEach((name) => {
writeFileSync(join(dir, name), '');
expect(readBuildSha({}, gitIn(dir)), name).toBe(`${head}-dirty`);
rmSync(join(dir, name));
});
expect(readBuildSha({}, gitIn(dir))).toBe(head);
});
it('treats a stray source file as dirty even beside the vite temp file, in the root or a subfolder', () => {
const { dir, head } = repo2();
const run = gitIn(dir);
writeFileSync(join(dir, TEMP), '');
writeFileSync(join(dir, 'src', TEMP), '');
expect(readBuildSha({}, run)).toBe(head);
writeFileSync(join(dir, 'src', 'zz-stray.ts'), '');
expect(readBuildSha({}, run)).toBe(`${head}-dirty`);
});
it('marks a tracked file of the temp name dirty once it changes', () => {
const { dir, head } = repo2([TEMP]);
const run = gitIn(dir);
expect(readBuildSha({}, run)).toBe(head);
writeFileSync(join(dir, TEMP), 'changed\n');
expect(readBuildSha({}, run)).toBe(`${head}-dirty`);
});
it('marks an untracked file in a new folder dirty (status lists files, not the folder)', () => {
const { dir, head } = repo2();
mkdirSync(join(dir, 'new'));
writeFileSync(join(dir, 'new', TEMP), '');
expect(readBuildSha({}, gitIn(dir))).toBe(head);
writeFileSync(join(dir, 'new', 'b.ts'), '');
expect(readBuildSha({}, gitIn(dir))).toBe(`${head}-dirty`);
});
});
describe('buildShaPlugin', () => {
it("hands vite a meta tag for the page's head carrying the sha", () => {
const plugin = buildShaPlugin(SHA);
expect(plugin.transformIndexHtml()).toEqual([
{ tag: 'meta', attrs: { name: 'build-sha', content: SHA }, injectTo: 'head-prepend' },
]);
});
});

View file

@ -1,5 +1,6 @@
// Preview builds: set the background scene tier in dist/config.json (default 'low'); config.json
// in the repo stays 'off'. Previews are static directories with no SPA fallback, so they also switch
// in the repo is the live chat.gamecult.org config (style ridge), and previews pin the style too, ridge unless
// given. Previews are static directories with no SPA fallback, so they also switch
// to the hash router: a reload on /login/matrix.org would otherwise be an nginx 404. With --demo it
// also switches on the pretend homeserver (src/preview-demo): previewDemo true, and the pretend
// server as the only homeserver. Usage:
@ -10,11 +11,11 @@ import { fileURLToPath } from 'url';
const args = process.argv.slice(2);
const demo = args.includes('--demo');
const [tier = 'low', style] = args.filter((a) => !a.startsWith('--'));
const [tier = 'low', style = 'ridge'] = args.filter((a) => !a.startsWith('--'));
const file = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist', 'config.json');
const config = JSON.parse(fs.readFileSync(file, 'utf8'));
config.hashRouter = { enabled: true, basename: '/' };
config.backgroundScene = { ...config.backgroundScene, tier, ...(style ? { style } : {}) };
config.backgroundScene = { ...config.backgroundScene, tier, style };
if (demo) {
config.previewDemo = true;
config.defaultHomeserver = 0;
@ -29,5 +30,5 @@ if (demo) {
}
fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
console.log(
`${file}: backgroundScene.tier = ${tier}${style ? `, style = ${style}` : ''}${demo ? ', previewDemo on' : ''}`
`${file}: backgroundScene.tier = ${tier}, style = ${style}${demo ? ', previewDemo on' : ''}`
);

View file

@ -16,6 +16,7 @@ import {
} from 'folds';
import { MatrixError } from 'matrix-js-sdk';
import { Cursor } from '../plugins/text-area';
import { PhoneHit } from '../styles/PhoneHit.css';
import { syntaxErrorPosition } from '../utils/dom';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { Page, PageHeader } from './page';
@ -294,7 +295,7 @@ export function AccountDataEditor({
</Chip>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<IconButton onClick={requestClose} variant="Surface" className={PhoneHit}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>

View file

@ -30,6 +30,7 @@ import {
} from '../hooks/useKeyBackup';
import { stopPropagation } from '../utils/keyboard';
import { useRestoreBackupOnVerification } from '../hooks/useRestoreBackupOnVerification';
import { PhoneHit } from '../styles/PhoneHit.css';
type BackupStatusProps = {
enabled: boolean;
@ -177,6 +178,7 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
<BackupSyncing count={remainingSession} />
)}
<IconButton
className={PhoneHit}
aria-pressed={!!menuCords}
size="300"
variant="Surface"

View file

@ -30,6 +30,7 @@ import {
} from '../hooks/useVerificationRequest';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { ContainerColor } from '../styles/ContainerColor.css';
import { PhoneHit } from '../styles/PhoneHit.css';
const DialogHeaderStyles: CSSProperties = {
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@ -261,7 +262,7 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
<Box grow="Yes">
<Text size="H4">Device Verification</Text>
</Box>
<IconButton size="300" radii="300" onClick={handleCancel}>
<IconButton className={PhoneHit} size="300" radii="300" onClick={handleCancel}>
<Icon src={Icons.Cross} />
</IconButton>
</Header>

View file

@ -25,6 +25,7 @@ import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useAlive } from '../hooks/useAlive';
import { UseStateProvider } from './UseStateProvider';
import { PhoneHit } from '../styles/PhoneHit.css';
type UIACallback<T> = (
authDict: AuthDict | null
@ -301,7 +302,7 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
<Box grow="Yes">
<Text size="H4">Setup Device Verification</Text>
</Box>
<IconButton size="300" radii="300" onClick={onCancel}>
<IconButton className={PhoneHit} size="300" radii="300" onClick={onCancel}>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@ -336,7 +337,7 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
<Box grow="Yes">
<Text size="H4">Reset Device Verification</Text>
</Box>
<IconButton size="300" radii="300" onClick={onCancel}>
<IconButton className={PhoneHit} size="300" radii="300" onClick={onCancel}>
<Icon src={Icons.Cross} />
</IconButton>
</Header>

View file

@ -27,6 +27,7 @@ import { AsyncStatus } from '../../hooks/useAsyncCallback';
import { useZoom } from '../../hooks/useZoom';
import { createPage, usePdfDocumentLoader, usePdfJSLoader } from '../../plugins/pdfjs-dist';
import { stopPropagation } from '../../utils/keyboard';
import { PhoneHit, PhoneHitSpread24 } from '../../styles/PhoneHit.css';
export type PdfViewerProps = {
name: string;
@ -108,7 +109,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
<Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}>
<Header className={css.PdfViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200">
<IconButton size="300" radii="300" onClick={requestClose}>
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}>
<Icon size="50" src={Icons.ArrowLeft} />
</IconButton>
<Text size="T300" truncate>
@ -117,6 +118,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
</Box>
<Box shrink="No" alignItems="Center" gap="200">
<IconButton
className={PhoneHitSpread24}
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom < 1}
size="300"
@ -130,6 +132,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
<Text size="B300">{Math.round(zoom * 100)}%</Text>
</Chip>
<IconButton
className={PhoneHitSpread24}
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom > 1}
size="300"

View file

@ -13,6 +13,7 @@ import {
IconButton,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { PhoneHit } from '../styles/PhoneHit.css';
export type UIAFlowOverlayProps = {
currentStep: number;
@ -53,6 +54,7 @@ export function UIAFlowOverlay({
>
{(anchorRef) => (
<IconButton
className={PhoneHit}
ref={anchorRef}
variant="Critical"
size="300"

View file

@ -1,84 +0,0 @@
import React, { useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
config,
Icon,
IconButton,
Icons,
Line,
Modal,
Overlay,
OverlayBackdrop,
OverlayCenter,
} from 'folds';
import { CustomEditor, useEditor } from './Editor';
import { Toolbar } from './Toolbar';
import { stopPropagation } from '../../utils/keyboard';
export function EditorPreview() {
const [open, setOpen] = useState(false);
const editor = useEditor();
const [toolbar, setToolbar] = useState(false);
return (
<>
<IconButton variant="SurfaceVariant" onClick={() => setOpen(!open)}>
<Icon src={Icons.BlockQuote} />
</IconButton>
<Overlay open={open} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setOpen(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal size="500">
<div style={{ padding: config.space.S400 }}>
<CustomEditor
editor={editor}
placeholder="Send a message..."
before={
<IconButton variant="SurfaceVariant" size="300" radii="300">
<Icon src={Icons.PlusCircle} />
</IconButton>
}
after={
<>
<IconButton
variant="SurfaceVariant"
size="300"
radii="300"
onClick={() => setToolbar(!toolbar)}
aria-pressed={toolbar}
>
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
</IconButton>
<IconButton variant="SurfaceVariant" size="300" radii="300">
<Icon src={Icons.Smile} />
</IconButton>
<IconButton variant="SurfaceVariant" size="300" radii="300">
<Icon src={Icons.Send} />
</IconButton>
</>
}
bottom={
toolbar && (
<div>
<Line variant="SurfaceVariant" size="300" />
<Toolbar />
</div>
)
}
/>
</div>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
</>
);
}

View file

@ -36,6 +36,7 @@ import { KeySymbol } from '../../utils/key-symbol';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { stopPropagation } from '../../utils/keyboard';
import { PhoneHit, PhoneHitRoom36, PhoneHitSpread36 } from '../../styles/PhoneHit.css';
function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) {
return (
@ -72,6 +73,7 @@ export function MarkButton({ format, icon, tooltip }: MarkButtonProps) {
<TooltipProvider tooltip={tooltip} delay={500}>
{(triggerRef) => (
<IconButton
className={PhoneHitSpread36}
ref={triggerRef}
variant="SurfaceVariant"
onClick={handleClick}
@ -104,6 +106,7 @@ export function BlockButton({ format, icon, tooltip }: BlockButtonProps) {
<TooltipProvider tooltip={tooltip} delay={500}>
{(triggerRef) => (
<IconButton
className={PhoneHitSpread36}
ref={triggerRef}
variant="SurfaceVariant"
onClick={handleClick}
@ -163,6 +166,7 @@ export function HeadingBlockButton() {
>
{(triggerRef) => (
<IconButton
className={PhoneHitRoom36}
ref={triggerRef}
onClick={() => handleMenuSelect(1)}
size="400"
@ -178,6 +182,7 @@ export function HeadingBlockButton() {
>
{(triggerRef) => (
<IconButton
className={PhoneHitRoom36}
ref={triggerRef}
onClick={() => handleMenuSelect(2)}
size="400"
@ -193,6 +198,7 @@ export function HeadingBlockButton() {
>
{(triggerRef) => (
<IconButton
className={PhoneHitRoom36}
ref={triggerRef}
onClick={() => handleMenuSelect(3)}
size="400"
@ -208,6 +214,7 @@ export function HeadingBlockButton() {
}
>
<IconButton
className={PhoneHit}
style={{ width: 'unset' }}
variant="SurfaceVariant"
onClick={handleMenuOpen}
@ -239,6 +246,7 @@ export function ExitFormatting({ tooltip }: ExitFormattingProps) {
<TooltipProvider tooltip={tooltip} delay={500}>
{(triggerRef) => (
<IconButton
className={PhoneHit}
ref={triggerRef}
variant="SurfaceVariant"
onClick={handleClick}
@ -344,6 +352,7 @@ export function Toolbar() {
>
{(triggerRef) => (
<IconButton
className={PhoneHit}
ref={triggerRef}
variant="SurfaceVariant"
onClick={() => setIsMarkdown(!isMarkdown)}

View file

@ -14,6 +14,7 @@ import {
} from 'folds';
import classNames from 'classnames';
import * as css from './styles.css';
import { PhoneHit } from '../../../styles/PhoneHit.css';
export function Sidebar({ children }: { children: ReactNode }) {
return (
@ -68,6 +69,7 @@ function SidebarBtn<T extends string>({
>
{(ref) => (
<IconButton
className={PhoneHit}
aria-pressed={active}
aria-labelledby={`SidebarStackItem-${id}-label`}
ref={ref}

View file

@ -24,6 +24,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { getMouseEventCords } from '../../utils/dom';
import { PhoneHit } from '../../styles/PhoneHit.css';
export type EventReadersProps = {
room: Room;
@ -52,7 +53,7 @@ export const EventReaders = as<'div', EventReadersProps>(
<Box grow="Yes">
<Text size="H3">Seen by</Text>
</Box>
<IconButton size="300" onClick={requestClose}>
<IconButton className={PhoneHit} size="300" onClick={requestClose}>
<Icon src={Icons.Cross} />
</IconButton>
</Header>

View file

@ -2,6 +2,7 @@ import React from 'react';
import classNames from 'classnames';
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
import * as css from './ImageEditor.css';
import { PhoneHit } from '../../styles/PhoneHit.css';
export type ImageEditorProps = {
name: string;
@ -24,7 +25,7 @@ export const ImageEditor = as<'div', ImageEditorProps>(
>
<Header className={css.ImageEditorHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200">
<IconButton size="300" radii="300" onClick={requestClose}>
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}>
<Icon size="50" src={Icons.ArrowLeft} />
</IconButton>
<Text size="T300" truncate>

View file

@ -1,6 +1,7 @@
import React from 'react';
import { Box, IconButton, Text, Icon, Icons, Scroll, Chip } from 'folds';
import { PackAddress } from '../../plugins/custom-emoji';
import { PhoneHit } from '../../styles/PhoneHit.css';
import { Page, PageHeader, PageContent } from '../page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { RoomImagePack } from './RoomImagePack';
@ -29,7 +30,7 @@ export function ImagePackView({ address, requestClose }: ImagePackViewProps) {
</Chip>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<IconButton onClick={requestClose} variant="Surface" className={PhoneHit}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>

View file

@ -7,6 +7,7 @@ import * as css from './ImageViewer.css';
import { useZoom } from '../../hooks/useZoom';
import { usePan } from '../../hooks/usePan';
import { downloadMedia } from '../../utils/matrix';
import { PhoneHit, PhoneHitSpread24 } from '../../styles/PhoneHit.css';
export type ImageViewerProps = {
alt: string;
@ -33,7 +34,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
>
<Header className={css.ImageViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200">
<IconButton size="300" radii="300" onClick={requestClose}>
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}>
<Icon size="50" src={Icons.ArrowLeft} />
</IconButton>
<Text size="T300" truncate>
@ -42,6 +43,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
</Box>
<Box shrink="No" alignItems="Center" gap="200">
<IconButton
className={PhoneHitSpread24}
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom < 1}
size="300"
@ -55,6 +57,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
<Text size="B300">{Math.round(zoom * 100)}%</Text>
</Chip>
<IconButton
className={PhoneHitSpread24}
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom > 1}
size="300"

View file

@ -42,6 +42,7 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { BreakWord } from '../../styles/Text.css';
import { useAlive } from '../../hooks/useAlive';
import { PhoneHit } from '../../styles/PhoneHit.css';
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000,
@ -173,7 +174,7 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
</Text>
</Box>
<Box shrink="No">
<IconButton size="300" radii="300" onClick={requestClose}>
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>

View file

@ -20,6 +20,7 @@ import { stopPropagation } from '../../utils/keyboard';
import { isRoomAlias, isRoomId } from '../../utils/matrix';
import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '../../plugins/matrix-to';
import { tryDecodeURIComponent } from '../../utils/dom';
import { PhoneHit } from '../../styles/PhoneHit.css';
type JoinAddressProps = {
onOpen: (roomIdOrAlias: string, via?: string[], eventId?: string) => void;
@ -82,7 +83,7 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
<Box grow="Yes">
<Text size="H4">Join with Address</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>

View file

@ -20,6 +20,7 @@ import { MatrixError } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { stopPropagation } from '../../utils/keyboard';
import { PhoneHit } from '../../styles/PhoneHit.css';
type LeaveRoomPromptProps = {
roomId: string;
@ -68,7 +69,7 @@ export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptPro
<Box grow="Yes">
<Text size="H4">Leave Room</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>

View file

@ -20,6 +20,7 @@ import { MatrixError } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { stopPropagation } from '../../utils/keyboard';
import { PhoneHit } from '../../styles/PhoneHit.css';
type LeaveSpacePromptProps = {
roomId: string;
@ -68,7 +69,7 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
<Box grow="Yes">
<Text size="H4">Leave Space</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>

View file

@ -2,6 +2,7 @@ import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'f
import React, { ReactNode, useCallback } from 'react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import FileSaver from 'file-saver';
import { PhoneHit } from '../../styles/PhoneHit.css';
import { mimeTypeToExt } from '../../utils/mimeTypes';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@ -48,6 +49,7 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
variant={hasError ? 'Critical' : 'SurfaceVariant'}
size="300"
radii="300"
className={PhoneHit}
>
{downloading ? (
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />

View file

@ -1,6 +1,9 @@
import { createVar, style } from '@vanilla-extract/css';
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize';
import { PHONE_TAP } from '../../styles/PhoneHit.css';
const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
const Container = createVar();
const ContainerHover = createVar();
const ContainerActive = createVar();
@ -25,6 +28,14 @@ export const Reaction = style([
selectors: {
'button&': {
cursor: 'pointer',
// Phone tap target: the chip is 26 px tall, so its hit area grows 9 px above and below (an ::after) and the
// same 9 px of margin opens the row, so the chips of one row and of the next never share a tap.
'@media': { [PHONE]: { position: 'relative', marginBlock: (PHONE_TAP - 26) / 2 } },
},
'button&::after': {
'@media': {
[PHONE]: { content: '""', position: 'absolute', inset: `${-(PHONE_TAP - 26) / 2}px 0` },
},
},
'&[aria-pressed=true]': {
vars: {

View file

@ -3,6 +3,7 @@ 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 { PhoneHitSpread24 } from '../../../styles/PhoneHit.css';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { IAudioInfo } from '../../../../types/matrix/common';
@ -159,6 +160,7 @@ export function AudioContent({
variant="SurfaceVariant"
size="300"
radii="Pill"
className={PhoneHitSpread24}
onClick={() => setMute(!mute)}
aria-pressed={mute}
>

View file

@ -60,13 +60,13 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
<Box
grow={own ? 'No' : 'Yes'}
direction="Column"
className={classNames(own && css.BubbleColumnOwn)}
className={classNames(css.BubbleColumn, own && css.BubbleColumnOwn)}
>
{header && <div className={classNames(own && css.BubbleHeaderOwn)}>{header}</div>}
{hideBubble ? (
children
) : (
<Box>
<Box className={css.BubbleContentRow}>
<Box
className={classNames(
css.BubbleContent,

View file

@ -0,0 +1,48 @@
import { readFileSync } from 'fs';
import { describe, expect, it } from 'vitest';
import zlib from 'zlib';
import { compile, processVanillaFile } from '@vanilla-extract/integration';
// A long reply quote (nowrap) sized the own-message row to 782px on a 390px phone. The row, the column and the bubble
// must be able to shrink to their parent, so pin the emitted declarations (as phoneTapTargets.test.ts does).
const emitted = async () => {
const filePath = `${process.cwd()}/src/app/components/message/layout/layout.css.ts`;
const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' });
const code = await processVanillaFile({ source, filePath, identOption: 'debug' });
const bytes = Buffer.from(
(code.match(/source=#?([A-Za-z0-9+/=]+)/) as RegExpMatchArray)[1],
'base64'
);
return (bytes[0] === 0x1f && bytes[1] === 0x8b ? zlib.gunzipSync(bytes) : bytes).toString();
};
const declarations = (css: string, className: string): string[] => {
const rule = new RegExp(`^\\.layout_${className}__[\\w-]+ \\{\\n([^}]*)\\}`, 'm').exec(css);
expect(rule, `no .layout_${className} rule emitted`).toBeTruthy();
return (rule as RegExpExecArray)[1].split('\n').map((l) => l.trim().replace(/;$/, ''));
};
describe('bubble rows shrink to their column', () => {
it.each([
['BubbleColumn', ['min-width: 0']],
['BubbleContentRow', ['min-width: 0', 'max-width: 100%']],
['BubbleContent', ['min-width: 0']],
])('%s', async (name, expected) => {
const css = await emitted();
expect(declarations(css, name)).toEqual(expect.arrayContaining(expected));
});
it('BubbleContent keeps its 800px cap inside the column width', async () => {
const css = await emitted();
expect(declarations(css, 'BubbleContent').join('\n')).toMatch(/max-width: min\(100%, 50rem\)/);
});
it('Bubble.tsx puts BubbleColumn on the column and BubbleContentRow on the row', () => {
const tsx = readFileSync(
`${process.cwd()}/src/app/components/message/layout/Bubble.tsx`,
'utf8'
);
expect(tsx).toMatch(/classNames\(css\.BubbleColumn,/);
expect(tsx).toMatch(/<Box className=\{css\.BubbleContentRow\}>/);
});
});

View file

@ -139,8 +139,22 @@ export const BubbleBefore = style({
minWidth: toRem(36),
});
// minWidth 0 down the column: a flex item never shrinks below its content (a reply quote's unbroken text),
// so without it a long quote pushes the column past the row, and a right-aligned row pushes it off the left edge.
export const BubbleColumn = style({
minWidth: 0,
});
// The row around a bubble sizes to its content in a right-aligned (own) column, and a quote's nowrap text
// makes that content as wide as the quote: cap the row at the column so the bubble can shrink inside it.
export const BubbleContentRow = style({
minWidth: 0,
maxWidth: '100%',
});
export const BubbleContent = style({
maxWidth: toRem(800),
minWidth: 0,
maxWidth: `min(100%, ${toRem(800)})`,
padding: config.space.S200,
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
@ -285,7 +299,10 @@ export const BubbleLeftArrow = style({
});
// Phone tap target: a sender name is 23 px tall, so the button grows 11 px above and below (44+ px to tap) and the
// negative margin takes the growth back out of the layout. Hit area only: no background, border or outline moves.
// negative margin takes the growth back out of the layout. Sideways it cannot borrow from the layout the
// same way (a negative inline margin makes the button shrink inside its shrink-to-fit parent and clip the name), so
// a name under 44 px is 44 px wide, text kept at the start: real growth of at most 16 px, in a row with room for it.
// No background, border or outline moves.
const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
export const Username = style({
overflow: 'hidden',
@ -295,7 +312,7 @@ export const Username = style({
'button&': {
cursor: 'pointer',
'@media': {
[SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11 },
[SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11, minWidth: 44, textAlign: 'start' },
},
},
'button&:hover, button&:focus-visible': {

View file

@ -53,6 +53,10 @@ export const NavCategoryHeader = style({
'&&': { paddingLeft: 0 },
},
'@media': {
// A phone's section header is as tall as its 44 px button, so it does not overlap the next row.
'screen and (max-width: 750px)': {
selectors: { '&&': { minHeight: toRem(44), height: toRem(44) } },
},
// Category labels sit on the scene like the rows do, and need a wash to be read (wide screens;
// a phone's list is plain text).
'screen and (min-width: 751px)': {
@ -117,6 +121,8 @@ const NavItemBase = style({
[PHONE]: {
backgroundColor: 'transparent',
borderRadius: 0,
// The row is as tall as the 44 px tap target inside it (EstateNavigator.css.ts), so neighbours do not overlap.
minHeight: toRem(44),
},
[WIDE]: {
selectors: {

View file

@ -1,6 +1,7 @@
import React, { ComponentProps, forwardRef } from 'react';
import { Icon, IconButton, Input, config, Icons } from 'folds';
import { UseStateProvider } from '../UseStateProvider';
import { PhoneHit } from '../../styles/PhoneHit.css';
type PasswordInputProps = Omit<ComponentProps<typeof Input>, 'type' | 'size'> & {
size: '400' | '500';
@ -23,6 +24,7 @@ export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
<>
{after}
<IconButton
className={PhoneHit}
onClick={() => setVisible(!visible)}
type="button"
variant={visible ? 'Warning' : variant}

View file

@ -4,6 +4,7 @@ import classNames from 'classnames';
import Linkify from 'linkify-react';
import * as css from './style.css';
import { LINKIFY_OPTS, scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
import { PhoneHit } from '../../styles/PhoneHit.css';
export const RoomTopicViewer = as<
'div',
@ -26,7 +27,7 @@ export const RoomTopicViewer = as<
{name}
</Text>
</Box>
<IconButton size="300" onClick={requestClose} radii="300">
<IconButton className={PhoneHit} size="300" onClick={requestClose} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>

View file

@ -5,6 +5,7 @@ import { Box, Chip, Header, Icon, IconButton, Icons, Scroll, Text, as } from 'fo
import { ErrorBoundary } from 'react-error-boundary';
import * as css from './TextViewer.css';
import { copyToClipboard } from '../../utils/dom';
import { PhoneHit } from '../../styles/PhoneHit.css';
const ReactPrism = lazy(() => import('../../plugins/react-prism/ReactPrism'));
@ -53,7 +54,7 @@ export const TextViewer = as<'div', TextViewerProps>(
>
<Header className={css.TextViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200">
<IconButton size="300" radii="300" onClick={requestClose}>
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}>
<Icon size="50" src={Icons.ArrowLeft} />
</IconButton>
<Text size="T300" truncate>

View file

@ -4,6 +4,7 @@ import { AuthType } from 'matrix-js-sdk';
import { StageComponentProps } from './types';
import { ErrorCode } from '../../cs-errorcode';
import { PasswordInput } from '../password-input';
import { PhoneHit } from '../../styles/PhoneHit.css';
export function PasswordStage({
stageData,
@ -45,7 +46,7 @@ export function PasswordStage({
<Box grow="Yes">
<Text size="H4">Account Password</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>

View file

@ -1,6 +1,7 @@
import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds';
import React, { useCallback, useEffect, useState } from 'react';
import { StageComponentProps } from './types';
import { PhoneHit } from '../../styles/PhoneHit.css';
export function SSOStage({
ssoRedirectURL,
@ -56,7 +57,7 @@ export function SSOStage({
<Box grow="Yes">
<Text size="H4">SSO Login</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>

View file

@ -6,6 +6,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { TUploadContent } from '../../utils/matrix';
import { bytesToSize, getFileTypeIcon } from '../../utils/common';
import { useMediaConfig } from '../../hooks/useMediaConfig';
import { PhoneHitSpread28 } from '../../styles/PhoneHit.css';
type CompactUploadCardRendererProps = {
isEncrypted?: boolean;
@ -63,6 +64,7 @@ export function CompactUploadCardRenderer({
</Chip>
)}
<IconButton
className={PhoneHitSpread28}
onClick={removeUpload}
aria-label="Cancel Upload"
variant="SurfaceVariant"

View file

@ -12,6 +12,7 @@ import {
} from '../../state/room/roomInputDrafts';
import { useObjectURL } from '../../hooks/useObjectURL';
import { useMediaConfig } from '../../hooks/useMediaConfig';
import { PhoneHitSpread28 } from '../../styles/PhoneHit.css';
type PreviewImageProps = {
fileItem: TUploadItem;
@ -160,6 +161,7 @@ export function UploadCardRenderer({
</Chip>
)}
<IconButton
className={PhoneHitSpread28}
onClick={removeUpload}
aria-label="Cancel Upload"
variant="SurfaceVariant"

View file

@ -1,3 +1,4 @@
import classNames from 'classnames';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { IPreviewUrlResponse } from 'matrix-js-sdk';
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
@ -10,6 +11,7 @@ import {
useIntersectionObserver,
} from '../../hooks/useIntersectionObserver';
import * as css from './UrlPreviewCard.css';
import { PhoneHitInPlace } from '../../styles/PhoneHit.css';
import { tryDecodeURIComponent } from '../../utils/dom';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@ -181,7 +183,10 @@ export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => {
<>
<div className={css.UrlPreviewHolderGradient({ position: 'Left' })} />
<IconButton
className={css.UrlPreviewHolderBtn({ position: 'Left' })}
className={classNames(
css.UrlPreviewHolderBtn({ position: 'Left' }),
PhoneHitInPlace
)}
variant="Secondary"
radii="Pill"
size="300"
@ -199,7 +204,10 @@ export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => {
<>
<div className={css.UrlPreviewHolderGradient({ position: 'Right' })} />
<IconButton
className={css.UrlPreviewHolderBtn({ position: 'Right' })}
className={classNames(
css.UrlPreviewHolderBtn({ position: 'Right' }),
PhoneHitInPlace
)}
variant="Primary"
radii="Pill"
size="300"

View file

@ -43,6 +43,7 @@ import { getPowerTagIconSrc, useGetMemberPowerTag } from '../../hooks/useMemberP
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
import { PhoneHit } from '../../styles/PhoneHit.css';
type SelfDemoteAlertProps = {
power: number;
@ -70,7 +71,7 @@ function SelfDemoteAlert({ power, onCancel, onChange }: SelfDemoteAlertProps) {
<Box grow="Yes">
<Text size="H4">Self Demotion</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@ -120,7 +121,7 @@ function SharedPowerAlert({ power, onCancel, onChange }: SharedPowerAlertProps)
<Box grow="Yes">
<Text size="H4">Shared Power</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>

View file

@ -54,6 +54,7 @@ import { StateEvent } from '../../../types/matrix/room';
import { getViaServers } from '../../plugins/via-servers';
import { rateLimitedActions } from '../../utils/matrix';
import { useAlive } from '../../hooks/useAlive';
import { PhoneHit } from '../../styles/PhoneHit.css';
const SEARCH_OPTS: UseAsyncSearchOptions = {
limit: 500,
@ -201,7 +202,7 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM
<Text size="H4">Add Existing</Text>
</Box>
<Box shrink="No">
<IconButton size="300" radii="300" onClick={requestClose}>
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>

View file

@ -49,3 +49,23 @@ export const FilesWide = style({
});
export const Dim = style({ opacity: config.opacity.P300 });
/** The share panel: idle it is one row; the title, names and Send mount only once files are picked. */
export const SharePanel = style({
display: 'flex',
flexDirection: 'column',
gap: config.space.S200,
});
export const ShareTitle = style({
all: 'unset',
boxSizing: 'border-box',
minHeight: 44,
width: '100%',
borderBottom: `1px solid ${color.Background.ContainerLine}`,
color: color.Background.OnContainer,
':focus-visible': { borderBottomColor: color.Background.OnContainer },
});
/** Picked names and send notes wrap, even a long unbroken name. */
export const ShareNames = style({ overflowWrap: 'anywhere' });

View file

@ -15,6 +15,8 @@ import { getBlobSafeMimeType } from '../../utils/mimeTypes';
import { timeDayMonYear } from '../../utils/time';
import { getMemberDisplayName } from '../../utils/room';
import { Album, AlbumFile } from './buildAlbums';
import { FileReadBy } from './FileReadBy';
import { ShareSet, useShareEnabled } from './ShareSet';
import { useAlbums } from './useAlbums';
import * as css from './AlbumView.css';
@ -47,6 +49,7 @@ function FileRow({ room, file, author }: FileRowProps) {
<Text size="T300" truncate>
{file.name}
</Text>
<FileReadBy room={room} fileId={file.id} content={file.content} also={file.readBy} />
{audio ? (
<AudioContent {...audio} renderMediaControl={(p) => <MediaControl {...p} />} />
) : (
@ -91,6 +94,7 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) {
const open: Album | undefined = albums.find((a) => a.id === openId);
const authorOf = (a: Album) => getMemberDisplayName(room, a.author) ?? a.author;
const encrypted = room.hasEncryptionStateEvent();
const canShare = useShareEnabled(room);
let body: React.ReactNode;
if (loading) body = <Text>Loading albums…</Text>;
@ -132,6 +136,12 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) {
<Scroll hideTrack visibility="Hover">
<div className={css.Column}>
{body}
{/* Stays mounted while an album is open, so a running send keeps its progress. */}
{canShare && (
<div hidden={!!open}>
<ShareSet room={room} />
</div>
)}
<button type="button" className={css.Row} onClick={onTimeline}>
<Text size="B400">Timeline ›</Text>
</button>

View file

@ -0,0 +1,32 @@
import React from 'react';
import { MatrixEvent, Room } from 'matrix-js-sdk';
import { Text } from 'folds';
import { getMemberDisplayName } from '../../utils/room';
import { FileReader, readLine } from './fileReceipt';
import { useFileReaders } from './useFileReaders';
import * as css from './AlbumView.css';
type FileReadByProps = {
room: Room;
fileId: string;
content: Record<string, unknown>;
/** Receipts already read elsewhere (an album's thread replies): merged with the timeline's, a Mind once. */
also?: FileReader[];
};
/** "fetched for <Mind>" under a file: the Minds whose verified receipts are in the room's timeline. Nothing when none. */
export function FileReadBy({ room, fileId, content, also = [] }: FileReadByProps) {
const seen = useFileReaders(room, fileId, content);
const readers = [...new Map([...also, ...seen].map((r) => [r.sender, r])).values()].sort(
(a, b) => a.ts - b.ts
);
if (readers.length === 0) return null;
return (
<Text size="T200" className={css.Dim}>
{readLine(readers, (id) => getMemberDisplayName(room, id) ?? id)}
</Text>
);
}
/** A timeline message that is a file: audio, image, video or file. */
export const isFileMessage = (mEvent: MatrixEvent): boolean =>
['m.audio', 'm.image', 'm.video', 'm.file'].includes(mEvent.getContent().msgtype);

View file

@ -0,0 +1,108 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { ClientConfigProvider } from '../../hooks/useClientConfig';
import type { ClientConfig } from '../../hooks/useClientConfig';
import { ShareState } from './shareController';
import { ShareSetView, shareEnabled, useShareEnabled } from './ShareSet';
const text = (html: string) =>
html
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const noop = () => undefined;
const file = (name: string) => ({ name, type: 'audio/ogg' } as File);
const view = (over: Partial<ShareState<File>> = {}) =>
renderToStaticMarkup(
<ShareSetView
state={{ files: [], title: '', phase: { kind: 'idle' }, epoch: 0, ...over }}
onPickClick={noop}
onPicked={noop}
onTitle={noop}
onSend={noop}
onCancel={noop}
/>
);
describe('the share panel, drawn', () => {
it('idle: one "Share files" row and no title, names, Send or Cancel', () => {
const html = view();
expect(text(html)).toBe('Share files ›');
expect(html).not.toContain('aria-label="Album title"');
expect(html).toContain('type="file"');
expect(html).toContain('multiple');
expect(html).toContain('hidden');
});
it('picked: the title field, the names on one line, Send N files and Cancel; no pick row', () => {
const html = view({ files: [file('a.ogg'), file('b.ogg')] });
expect(html).toContain('aria-label="Album title"');
expect(html).toContain('placeholder="Shared files"');
expect(text(html)).toBe('a.ogg · b.ogg Send 2 files Cancel');
expect(text(html)).not.toContain('Share files');
});
it('one picked file reads "Send 1 file"', () => {
expect(text(view({ files: [file('a.ogg')] }))).toContain('Send 1 file Cancel');
});
it('sending: progress replaces Send and Cancel, and the title is locked', () => {
const html = view({
files: [file('a'), file('b'), file('c')],
phase: { kind: 'sending', done: 2 },
});
expect(text(html)).toContain('Sending 2 of 3…');
expect(text(html)).not.toContain('Send 3 files');
expect(text(html)).not.toContain('Cancel');
expect(html).toMatch(
/aria-label="Album title"[^>]*disabled|disabled[^>]*aria-label="Album title"/
);
});
it('the encrypted note shows above the kept pick', () => {
const html = view({
files: [file('a.ogg')],
phase: { kind: 'done', text: 'Not shared: this room is encrypted.' },
});
expect(text(html)).toBe('Not shared: this room is encrypted. a.ogg Send 1 file Cancel');
});
it('after a full success the idle row and the note show together', () => {
expect(text(view({ phase: { kind: 'done', text: 'Shared 2 files.' } }))).toBe(
'Share files › Shared 2 files.'
);
});
});
describe('the flag', () => {
it('off, missing or empty offers no share', () => {
expect(shareEnabled({}, false)).toBe(false);
expect(shareEnabled({ fileZoneShare: {} }, false)).toBe(false);
expect(shareEnabled({ fileZoneShare: { enabled: false } }, false)).toBe(false);
});
it('on offers it, except in an encrypted room', () => {
expect(shareEnabled({ fileZoneShare: { enabled: true } }, false)).toBe(true);
expect(shareEnabled({ fileZoneShare: { enabled: true } }, true)).toBe(false);
});
it('the hook reads the client config and the room, as AlbumView mounts it', () => {
const room = (encrypted: boolean) => ({ hasEncryptionStateEvent: () => encrypted } as never);
function Probe({ encrypted }: { encrypted: boolean }) {
return <span>{String(useShareEnabled(room(encrypted)))}</span>;
}
const probe = (config: ClientConfig, encrypted: boolean) =>
text(
renderToStaticMarkup(
<ClientConfigProvider value={config}>
<Probe encrypted={encrypted} />
</ClientConfigProvider>
)
);
expect(probe({}, false)).toBe('false');
expect(probe({ fileZoneShare: { enabled: true } }, false)).toBe('true');
expect(probe({ fileZoneShare: { enabled: true } }, true)).toBe('false');
});
});

View file

@ -0,0 +1,160 @@
import React, { useRef, useState, useSyncExternalStore } from 'react';
import { Text } from 'folds';
import { useQueryClient } from '@tanstack/react-query';
import { Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useClientConfig } from '../../hooks/useClientConfig';
import { uploadContent } from '../../utils/matrix';
import { safeFile } from '../../utils/mimeTypes';
import { getMsgContent } from '../room/msgContent';
import { FILE_HASH_KEY, hashForSend } from './fileReceipt';
import { ShareController, ShareState, createShareController } from './shareController';
import { DEFAULT_TITLE, sendShareSet } from './shareSet';
import * as css from './AlbumView.css';
/** Off unless `fileZoneShare.enabled` is true. An encrypted zone is never offered the share. */
export const shareEnabled = (
config: { fileZoneShare?: { enabled?: boolean } },
encrypted: boolean
) => !!config.fileZoneShare?.enabled && !encrypted;
export const useShareEnabled = (room: Room): boolean =>
shareEnabled(useClientConfig(), room.hasEncryptionStateEvent());
type ShareViewProps = {
state: ShareState<File>;
onPickClick: () => void;
onPicked: (files: File[]) => void;
onTitle: (title: string) => void;
onSend: () => void;
onCancel: () => void;
inputRef?: React.Ref<HTMLInputElement>;
};
/**
* One row when idle. Picking files (any number, one picker) opens the panel: a title, the picked
* names, Send. Nothing but the row is visible until files are picked.
*/
export function ShareSetView({
state,
onPickClick,
onPicked,
onTitle,
onSend,
onCancel,
inputRef,
}: ShareViewProps) {
const { files, title, phase, epoch } = state;
return (
<div className={css.SharePanel}>
<input
key={epoch}
ref={inputRef}
type="file"
multiple
hidden
onChange={(e) => onPicked(Array.from(e.target.files ?? []))}
/>
{files.length === 0 && phase.kind !== 'sending' && (
<button type="button" className={css.Row} onClick={onPickClick}>
<Text size="B400">Share files ›</Text>
</button>
)}
{phase.kind === 'done' && (
<Text size="T200" className={css.ShareNames}>
{phase.text}
</Text>
)}
{files.length > 0 && (
<>
<input
className={css.ShareTitle}
value={title}
placeholder={DEFAULT_TITLE}
aria-label="Album title"
disabled={phase.kind === 'sending'}
onChange={(e) => onTitle(e.target.value)}
/>
<Text size="T200" className={`${css.Dim} ${css.ShareNames}`}>
{files.map((f) => f.name).join(' · ')}
</Text>
{phase.kind === 'sending' ? (
<Text size="T300">
Sending {phase.done} of {files.length}…
</Text>
) : (
<>
<button type="button" className={css.Row} onClick={onSend}>
<Text size="B400">
Send {files.length} {files.length === 1 ? 'file' : 'files'}
</Text>
</button>
<button type="button" className={css.Row} onClick={onCancel}>
<Text size="B400">Cancel</Text>
</button>
</>
)}
</>
)}
</div>
);
}
export function ShareSet({ room }: { room: Room }) {
const mx = useMatrixClient();
const queryClient = useQueryClient();
const input = useRef<HTMLInputElement>(null);
// Built once per mount (AlbumView keys the panel by room): the pick lives in the controller, so a
// rerender with a new client or Room object must not build another. Live values go through `live`.
const live = useRef({ mx, room, queryClient });
live.current = { mx, room, queryClient };
const [controller]: [ShareController<File>, unknown] = useState(() =>
createShareController<File>({
roomId: room.roomId,
isEncrypted: () => live.current.room.hasEncryptionStateEvent(),
onSent: () =>
live.current.queryClient.invalidateQueries({ queryKey: ['albums', room.roomId] }),
run: (files, title, onFile) =>
sendShareSet(
{
send: async (content) =>
(await live.current.mx.sendMessage(room.roomId, content as any)).event_id,
contentOf: async (f) => {
const file = safeFile(f.file);
const mxc = await new Promise<string>((resolve, reject) => {
uploadContent(live.current.mx, file, { onSuccess: resolve, onError: reject });
});
const content = await getMsgContent(
live.current.mx,
{
file,
originalFile: f.file,
metadata: { markedAsSpoiler: false },
encInfo: undefined,
},
mxc
);
const sha256 = await hashForSend(f.file);
if (sha256) content.info = { ...content.info, [FILE_HASH_KEY]: sha256 };
return content;
},
onFile,
},
title,
files.map((file) => ({ name: file.name, mimeType: file.type, file }))
),
})
);
const state = useSyncExternalStore(controller.subscribe, controller.getState);
return (
<ShareSetView
state={state}
inputRef={input}
onPickClick={() => input.current?.click()}
onPicked={controller.pick}
onTitle={controller.setTitle}
onSend={controller.send}
onCancel={controller.cancel}
/>
);
}

View file

@ -214,3 +214,87 @@ describe('buildAlbums', () => {
expect(one(root('$q', 1), [threaded]).count).toBe(1);
});
});
describe('Mind receipts', () => {
const HASH = 'a'.repeat(64);
const file = (id: string, sha?: string) =>
reply(id, 10, 'm.audio', id, { info: sha ? { sha256: sha } : {} });
const receipt = (
id: string,
ts: number,
sender: string,
fileId: unknown,
sha?: unknown,
extra: Record<string, unknown> = {}
) =>
({
event_id: id,
type: 'org.gamecult.file_received',
sender,
origin_server_ts: ts,
content: { 'org.gamecult.file_received': { event_id: fileId, sha256: sha, ...extra } },
} as ZoneEvent);
const oneX = (r: ZoneEvent, replies: ZoneEvent[]) => buildAlbums([{ root: r, replies }], 'x')[0];
it('shows who read a file when the receipt carries the file event hash', () => {
const a = oneX(root('$r', 1), [file('$f', HASH), receipt('$x', 20, '@void:x', '$f', HASH)]);
expect(a.files[0].readBy).toEqual([{ sender: '@void:x', ts: 20 }]);
expect(a.count).toBe(1);
});
it('counts nothing for a hash that differs, a missing hash on either side, or a bad hash', () => {
const a = oneX(root('$r', 1), [
file('$f', HASH),
file('$g'),
receipt('$x', 20, '@void:x', '$f', 'b'.repeat(64)),
receipt('$y', 21, '@void:x', '$g', HASH),
receipt('$z', 22, '@void:x', '$f', undefined),
receipt('$w', 23, '@void:x', '$f', 7),
]);
expect(a.files.every((f) => f.readBy.length === 0)).toBe(true);
});
it('trusts the event sender, never a Mind field inside the content', () => {
const a = oneX(root('$r', 1), [
file('$f', HASH),
receipt('$x', 20, '@mallory:x', '$f', HASH, { mind: 'void', sender: '@void:x' }),
]);
expect(a.files[0].readBy).toEqual([]);
});
it('counts only a Mind of this homeserver: not another server, a lookalike, or a bare name', () => {
const a = oneX(root('$r', 1), [
file('$f', HASH),
receipt('$x', 20, '@void-fan:x', '$f', HASH),
receipt('$y', 21, '@x:void', '$f', HASH),
receipt('$z', 22, '@aqua:x', '$f', HASH),
receipt('$e', 23, '@void:evil.example', '$f', HASH),
receipt('$b', 24, 'void:x', '$f', HASH),
]);
expect(a.files[0].readBy.map((r) => r.sender)).toEqual(['@aqua:x']);
});
it('keeps one receipt per Mind, the latest, and a second Mind beside it', () => {
const a = oneX(root('$r', 1), [
file('$f', HASH),
receipt('$1', 20, '@void:x', '$f', HASH),
receipt('$2', 30, '@void:x', '$f', HASH),
receipt('$3', 25, '@eureka:x', '$f', HASH),
]);
expect(a.files[0].readBy).toEqual([
{ sender: '@eureka:x', ts: 25 },
{ sender: '@void:x', ts: 30 },
]);
});
it('ignores a receipt for a file not in the album, a malformed one, and a redacted one', () => {
const redacted = {
...receipt('$d', 20, '@void:x', '$f', HASH),
unsigned: { redacted_because: {} },
};
const a = oneX(root('$r', 1), [
file('$f', HASH),
receipt('$a', 20, '@void:x', '$elsewhere', HASH),
receipt('$b', 21, '@void:x', 42, HASH),
redacted,
]);
expect(a.files[0].readBy).toEqual([]);
expect(a.count).toBe(1);
});
});

View file

@ -1,6 +1,8 @@
// Pure: turns the thread roots of a file-zone room and each root's thread replies, in the JSON
// Synapse returns from GET /rooms/{roomId}/threads and GET /rooms/{roomId}/relations/{root}/m.thread,
// into albums. See docs/file-zone.md for the rulings this encodes.
import { readersOf } from './fileReceipt';
import type { FileReader } from './fileReceipt';
/** The slice of a Matrix event this module reads. Every field may be missing. */
export type ZoneEvent = {
@ -23,6 +25,8 @@ export type AlbumFile = {
ts: number;
/** The event content, as RenderMessageContent reads it. */
content: Record<string, unknown>;
/** Receipts in this album that name this file, one per sender (the latest), oldest first. */
readBy: FileReader[];
};
export type Album = {
@ -70,6 +74,7 @@ const toFile = (e: ZoneEvent): AlbumFile | undefined => {
name: asString(e.content.filename) || asString(e.content.body),
ts: tsOf(e.origin_server_ts),
content: e.content,
readBy: [],
};
};
@ -84,18 +89,20 @@ const titleOf = (root: ZoneEvent & { content: Record<string, unknown> }): string
return asString(edited) || asString(root.content.body) || UNTITLED;
};
const toAlbum = (input: AlbumInput): Album | undefined => {
const toAlbum = (input: AlbumInput, server: string): Album | undefined => {
const { root, replies } = input ?? {};
if (!live(root)) return undefined;
const seen = new Set<string>();
const files = (Array.isArray(replies) ? replies : [])
const list = Array.isArray(replies) ? replies : [];
const files = list
.map(toFile)
.filter((f): f is AlbumFile => {
if (!f || seen.has(f.id)) return false;
seen.add(f.id);
return true;
})
.sort((a, b) => a.ts - b.ts);
.sort((a, b) => a.ts - b.ts)
.map((f) => ({ ...f, readBy: readersOf(list, f.id, f.content, server) }));
return {
id: root.event_id,
title: titleOf(root),
@ -108,10 +115,11 @@ const toAlbum = (input: AlbumInput): Album | undefined => {
};
/** Newest album first; files oldest first. Loose room messages never reach this function. */
export const buildAlbums = (inputs: AlbumInput[]): Album[] => {
/** `server` is the homeserver's own domain: only its Minds' receipts count. */
export const buildAlbums = (inputs: AlbumInput[], server = ''): Album[] => {
const seen = new Set<string>();
return inputs
.map(toAlbum)
.map((i) => toAlbum(i, server))
.filter((a): a is Album => {
if (!a || seen.has(a.id)) return false;
seen.add(a.id);

View file

@ -0,0 +1,165 @@
import { describe, expect, it } from 'vitest';
import { ReceiptEvent, hashForSend, readLine, readersOf, sha256Hex } from './fileReceipt';
describe('sha256Hex', () => {
it('hashes the bytes: the FIPS 180 vector for "abc"', async () => {
expect(await sha256Hex(new Blob(['abc']))).toBe(
'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'
);
});
it('hashes empty input', async () => {
expect(await sha256Hex(new Blob([]))).toBe(
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
);
});
});
describe('hashForSend', () => {
it('gives no hash, and does not throw, when the blob cannot be read', async () => {
const broken = { arrayBuffer: () => Promise.reject(new Error('gone')) } as unknown as Blob;
expect(await hashForSend(broken)).toBeUndefined();
});
});
describe('readLine', () => {
it('names each reader', () => {
const name = (s: string) => s.slice(1, s.indexOf(':'));
expect(
readLine(
[
{ sender: '@void:x', ts: 1 },
{ sender: '@eureka:x', ts: 2 },
],
name
)
).toBe('fetched for void, eureka');
});
});
describe('readersOf: receipts anywhere in the room, thread reply or plain event', () => {
const HASH = 'a'.repeat(64);
const file = { info: { sha256: HASH } };
const receipt = (
id: string,
ts: number,
sender: string,
sha: unknown | undefined,
fileId: string | undefined,
relates?: object
): ReceiptEvent => ({
event_id: id,
type: 'org.gamecult.file_received',
sender,
origin_server_ts: ts,
content: {
...(relates ? { 'm.relates_to': relates } : {}),
'org.gamecult.file_received': {
event_id: fileId ?? '$f',
sha256: sha === undefined ? HASH : sha,
},
},
});
const readers = (events: ReceiptEvent[], content: Record<string, unknown> | undefined) =>
readersOf(events, '$f', content, 'gamecult.org').map((r) => r.sender);
it('counts a threadless receipt, as a composer file with no album root gets', () => {
expect(readers([receipt('$1', 5, '@void:gamecult.org', undefined, undefined)], file)).toEqual([
'@void:gamecult.org',
]);
});
it('counts a thread reply the same way', () => {
const t = { rel_type: 'm.thread', event_id: '$root' };
expect(readers([receipt('$1', 5, '@eureka:gamecult.org', HASH, '$f', t)], file)).toEqual([
'@eureka:gamecult.org',
]);
});
it('refuses lookalike localparts and foreign servers', () => {
expect(
readers(
[
receipt('$1', 1, '@void2:gamecult.org', undefined, undefined),
receipt('$2', 2, '@voidd:gamecult.org', undefined, undefined),
receipt('$3', 3, '@Void:gamecult.org', undefined, undefined),
receipt('$4', 4, '@void:evil.example', undefined, undefined),
receipt('$5', 5, '@void:gamecult.org.evil.example', undefined, undefined),
receipt('$6', 6, '@mallory:gamecult.org', undefined, undefined),
],
file
)
).toEqual([]);
});
it('shows nothing for a mismatched hash, a file with no hash, or another file', () => {
expect(
readers([receipt('$1', 1, '@void:gamecult.org', 'b'.repeat(64), undefined)], file)
).toEqual([]);
expect(
readers([receipt('$1', 1, '@void:gamecult.org', undefined, undefined)], { info: {} })
).toEqual([]);
expect(
readers([receipt('$1', 1, '@void:gamecult.org', undefined, undefined)], undefined)
).toEqual([]);
expect(readers([receipt('$1', 1, '@void:gamecult.org', HASH, '$other')], file)).toEqual([]);
});
it('keeps one entry per Mind, the latest, oldest first', () => {
expect(
readersOf(
[
receipt('$1', 30, '@void:gamecult.org', undefined, undefined),
receipt('$2', 10, '@void:gamecult.org', undefined, undefined),
receipt('$3', 20, '@aqua:gamecult.org', undefined, undefined),
],
'$f',
file,
'gamecult.org'
)
).toEqual([
{ sender: '@aqua:gamecult.org', ts: 20 },
{ sender: '@void:gamecult.org', ts: 30 },
]);
});
it('ignores a redacted receipt and a malformed one', () => {
const redacted = {
...receipt('$1', 1, '@void:gamecult.org', undefined, undefined),
unsigned: { redacted_because: {} },
};
expect(
readers(
[
redacted,
receipt('$2', 2, '@void:gamecult.org', 7, undefined),
{
...receipt('$3', 3, '@void:gamecult.org', undefined, undefined),
type: 'm.room.message',
},
],
file
)
).toEqual([]);
});
describe('an m.reference to the file', () => {
const ref = (id: string) => ({ rel_type: 'm.reference', event_id: id });
it('counts when it names the same event as the content', () => {
expect(
readers([receipt('$1', 1, '@void:gamecult.org', HASH, '$f', ref('$f'))], file)
).toEqual(['@void:gamecult.org']);
});
it('counts for nothing when it names another event', () => {
expect(
readers([receipt('$1', 1, '@void:gamecult.org', HASH, '$f', ref('$other'))], file)
).toEqual([]);
});
it('draws one entry per Mind however many receipts exist', () => {
expect(
readers(
[
receipt('$1', 1, '@void:gamecult.org', HASH, '$f', ref('$f')),
receipt('$2', 2, '@void:gamecult.org', HASH, '$f'),
receipt('$3', 3, '@void:gamecult.org', HASH, '$f', ref('$f')),
],
file
)
).toEqual(['@void:gamecult.org']);
});
});
});

View file

@ -0,0 +1,89 @@
// The file-zone Mind contract (docs/file-zone-mind-contract.md): the plaintext hash a sender writes
// into a file event, and the receipt a Mind answers with.
import { MINDS } from '../../components/message/pose/poses';
/** A Mind's verified receipt for one file: its sender, who the Matrix server says posted it. */
export type FileReader = { sender: string; ts: number };
/** The slice of a Matrix event a receipt is read from. Every field may be missing. */
export type ReceiptEvent = {
event_id?: string;
type?: string;
sender?: string;
origin_server_ts?: number;
content?: Record<string, unknown>;
unsigned?: Record<string, unknown>;
};
/** Hex sha256 of the plaintext bytes, written at `content.info[FILE_HASH_KEY]` in unencrypted rooms. */
export const FILE_HASH_KEY = 'sha256';
/** A Mind's receipt: event type, and the content key holding { event_id, sha256, size }. Inert: nothing in it is fetched or trusted but the sender and the hash. */
export const FILE_RECEIVED = 'org.gamecult.file_received';
export const sha256Hex = async (data: Blob): Promise<string> => {
const digest = await crypto.subtle.digest('SHA-256', await data.arrayBuffer());
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
};
/** The hash to write for a file about to be sent in an unencrypted room; none when it cannot be computed. */
export const hashForSend = async (data: Blob): Promise<string | undefined> => {
try {
return await sha256Hex(data);
} catch {
return undefined;
}
};
export const readLine = (readBy: FileReader[], name: (sender: string) => string): string =>
`fetched for ${readBy.map((r) => name(r.sender)).join(', ')}`;
const receiptOf = (e: ReceiptEvent) => {
if (!e || typeof e !== 'object' || e.type !== FILE_RECEIVED || !e.event_id) return undefined;
if (e.unsigned?.redacted_because) return undefined;
const body = e.content?.[FILE_RECEIVED] as { event_id?: unknown; sha256?: unknown } | undefined;
if (typeof body?.event_id !== 'string' || typeof body.sha256 !== 'string') return undefined;
if (typeof e.sender !== 'string') return undefined;
// A reference to the file (Eureka's dedup, via /relations) must name the same event as the content, or the
// receipt counts for nothing. Matching is by content.event_id; other relations (a thread) are not read.
const rel = e.content?.['m.relates_to'] as { rel_type?: unknown; event_id?: unknown } | undefined;
if (rel?.rel_type === 'm.reference' && rel.event_id !== body.event_id) return undefined;
const ts = e.origin_server_ts;
return {
file: body.event_id,
sender: e.sender,
ts: typeof ts === 'number' && Number.isFinite(ts) ? ts : 0,
sha256: body.sha256,
};
};
// Only the event's sender counts, never a name inside the content, and only a Mind's own Matrix
// identity on this homeserver (`@<mind>:<server>`, a mind named in the Persona catalogue).
const isMind = (sender: string, server: string) => MINDS.some((m) => sender === `@${m}:${server}`);
/**
* Who has read the file `fileId`, from receipts found anywhere in `events` (an album's thread replies, or the room's
* timeline: a receipt is a thread reply when the file has an album root, otherwise a plain room event; both count).
* "Read" means verified: the receipt's sha256 must equal the file event's `info.sha256`, so a file with no hash, or a
* mismatch, shows nothing. One entry per Mind (its latest), oldest first.
*/
export const readersOf = (
events: ReceiptEvent[],
fileId: string,
fileContent: Record<string, unknown> | undefined,
server: string
): FileReader[] => {
const sent = (fileContent?.info as Record<string, unknown> | undefined)?.[FILE_HASH_KEY];
if (typeof sent !== 'string') return [];
const latest = new Map<string, FileReader>();
events
.map(receiptOf)
.filter(
(r): r is NonNullable<typeof r> =>
!!r && r.file === fileId && r.sha256 === sent && isMind(r.sender, server)
)
.sort((a, b) => a.ts - b.ts)
.forEach((r) => latest.set(r.sender, { sender: r.sender, ts: r.ts }));
return [...latest.values()].sort((a, b) => a.ts - b.ts);
};

View file

@ -0,0 +1,116 @@
import { EventEmitter } from 'events';
import { describe, expect, it, vi } from 'vitest';
import { MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk';
import { receiptStoreOf } from './receiptStore';
import { readersOf } from './fileReceipt';
const HASH = 'a'.repeat(64);
type Fake = {
id: string;
type: string;
sender: string;
ts: number;
content: object;
redacted?: boolean;
};
const event = (f: Fake) =>
({
getId: () => f.id,
getType: () => f.type,
getSender: () => f.sender,
getTs: () => f.ts,
getContent: () => f.content,
isRedacted: () => !!f.redacted,
} as unknown as MatrixEvent);
const receipt = (id: string, sender: string, fileId = '$f', over: Partial<Fake> = {}): Fake => ({
id,
type: 'org.gamecult.file_received',
sender,
ts: 1,
content: { 'org.gamecult.file_received': { event_id: fileId, sha256: HASH } },
...over,
});
const fakeRoom = (initial: Fake[] = []) => {
const emitter = new EventEmitter();
emitter.setMaxListeners(0);
let events = initial;
const room = Object.assign(emitter, {
getUnfilteredTimelineSet: () => ({
getLiveTimeline: () => ({ getEvents: () => events.map(event) }),
}),
}) as unknown as Room;
return {
room,
emitter,
set: (next: Fake[]) => {
events = next;
},
};
};
const listeners = (e: EventEmitter) =>
[RoomEvent.Timeline, RoomEvent.Redaction, RoomEvent.TimelineReset].map((n) => e.listenerCount(n));
describe('receiptStoreOf: one subscription per room', () => {
it('registers one listener per event for 200 file rows, and none once they are gone', () => {
const { room, emitter } = fakeRoom();
const store = receiptStoreOf(room);
const off = Array.from({ length: 200 }, () => store.subscribe(() => undefined));
expect(listeners(emitter)).toEqual([1, 1, 1]);
off.forEach((o) => o());
expect(listeners(emitter)).toEqual([0, 0, 0]);
});
it('is one store per room', () => {
const a = fakeRoom();
const b = fakeRoom();
expect(receiptStoreOf(a.room)).toBe(receiptStoreOf(a.room));
expect(receiptStoreOf(a.room)).not.toBe(receiptStoreOf(b.room));
});
it('notifies rows when a receipt arrives, not for unrelated timeline events, and gives readersOf what it needs', () => {
const { room, emitter, set } = fakeRoom();
const store = receiptStoreOf(room);
const cb = vi.fn();
store.subscribe(cb);
expect(store.getSnapshot()).toEqual([]);
set([{ id: '$m', type: 'm.room.message', sender: '@x:gamecult.org', ts: 1, content: {} }]);
emitter.emit(RoomEvent.Timeline);
expect(cb).not.toHaveBeenCalled();
set([receipt('$r', '@void:gamecult.org')]);
emitter.emit(RoomEvent.Timeline);
expect(cb).toHaveBeenCalledTimes(1);
const snap = store.getSnapshot();
expect(
readersOf(snap, '$f', { info: { sha256: HASH } }, 'gamecult.org').map((r) => r.sender)
).toEqual(['@void:gamecult.org']);
emitter.emit(RoomEvent.Timeline);
expect(store.getSnapshot()).toBe(snap);
});
it('rescans on a redaction and on a timeline reset', () => {
const { room, emitter, set } = fakeRoom([receipt('$r', '@void:gamecult.org')]);
const store = receiptStoreOf(room);
const cb = vi.fn();
store.subscribe(cb);
expect(store.getSnapshot()).toHaveLength(1);
set([receipt('$r', '@void:gamecult.org', '$f', { redacted: true })]);
emitter.emit(RoomEvent.Redaction);
expect(cb).toHaveBeenCalledTimes(1);
expect(
readersOf(store.getSnapshot(), '$f', { info: { sha256: HASH } }, 'gamecult.org')
).toEqual([]);
set([]);
emitter.emit(RoomEvent.TimelineReset);
expect(cb).toHaveBeenCalledTimes(2);
expect(store.getSnapshot()).toEqual([]);
});
it('picks up what is already loaded when the first row subscribes', () => {
const { room } = fakeRoom([receipt('$r', '@aqua:gamecult.org')]);
const store = receiptStoreOf(room);
store.subscribe(() => undefined);
expect(store.getSnapshot()).toHaveLength(1);
});
});

View file

@ -0,0 +1,79 @@
import type { MatrixEvent, Room } from 'matrix-js-sdk';
import { RoomEvent } from 'matrix-js-sdk';
import { FILE_RECEIVED, ReceiptEvent } from './fileReceipt';
/**
* One subscription per room to the file receipts in its loaded timeline, shared by every file row: a row costs a
* callback in a Set, not a Room listener (matrix-js-sdk warns past 100 listeners). The snapshot is the receipt events
* themselves; each row derives its own readers from it with `readersOf`, so the rules (sender, hash, reference, one per
* Mind) live in one place and the store holds no verdicts. The snapshot changes identity only when the set of receipts
* or one's redaction state does, so rows re-render for a receipt, not for every timeline event.
*/
export type ReceiptStore = {
subscribe: (onChange: () => void) => () => void;
getSnapshot: () => ReceiptEvent[];
};
const asReceiptEvent = (e: MatrixEvent): ReceiptEvent => ({
event_id: e.getId(),
type: e.getType(),
sender: e.getSender(),
origin_server_ts: e.getTs(),
content: e.getContent(),
unsigned: e.isRedacted() ? { redacted_because: true } : undefined,
});
const same = (a: ReceiptEvent[], b: ReceiptEvent[]): boolean =>
a.length === b.length &&
a.every(
(r, i) =>
r.event_id === b[i].event_id &&
!r.unsigned?.redacted_because === !b[i].unsigned?.redacted_because
);
// A redaction removes a tick and a reset drops the window: both rescan, not only a new event.
const EVENTS = [RoomEvent.Timeline, RoomEvent.Redaction, RoomEvent.TimelineReset] as const;
const make = (room: Room): ReceiptStore => {
const subscribers = new Set<() => void>();
let snapshot: ReceiptEvent[] = [];
const read = (): boolean => {
const next = room
.getUnfilteredTimelineSet()
.getLiveTimeline()
.getEvents()
.filter((e) => e.getType() === FILE_RECEIVED)
.map(asReceiptEvent);
if (same(snapshot, next)) return false;
snapshot = next;
return true;
};
const scan = () => {
if (read()) subscribers.forEach((cb) => cb());
};
return {
subscribe: (onChange) => {
subscribers.add(onChange);
if (subscribers.size === 1) {
EVENTS.forEach((ev) => room.on(ev, scan));
read(); // the first row sees what is already loaded; it reads the snapshot after subscribing
}
return () => {
subscribers.delete(onChange);
if (subscribers.size === 0) EVENTS.forEach((ev) => room.off(ev, scan));
};
},
getSnapshot: () => snapshot,
};
};
const stores = new WeakMap<Room, ReceiptStore>();
export const receiptStoreOf = (room: Room): ReceiptStore => {
let store = stores.get(room);
if (!store) {
store = make(room);
stores.set(room, store);
}
return store;
};

View file

@ -0,0 +1,261 @@
import { describe, expect, it } from 'vitest';
import { Picked, ShareDeps, createShareController } from './shareController';
import { ShareResult } from './shareSet';
const file = (name: string): Picked => ({ name, type: 'audio/ogg' });
const ok = (n: number): ShareResult => ({ albumId: '$r', sent: n, failed: [], failedAt: [] });
/** A run the test settles by hand, so "in flight" is a state the test can stand in. */
const hand = () => {
const calls: { files: Picked[]; title: string; onFile: () => void }[] = [];
let release: (r: ShareResult) => void = () => undefined;
let fail: (e: Error) => void = () => undefined;
const run: ShareDeps<Picked>['run'] = (files, title, onFile) => {
calls.push({ files, title, onFile });
return new Promise<ShareResult>((resolve, reject) => {
release = resolve;
fail = reject;
});
};
return { calls, run, release: (r: ShareResult) => release(r), fail: (e: Error) => fail(e) };
};
const make = (over: Partial<ShareDeps<Picked>> = {}, roomId = '!zone:x') => {
const h = hand();
const sentTo: string[] = [];
const c = createShareController<Picked>({
roomId,
isEncrypted: () => false,
run: h.run,
onSent: () => sentTo.push(roomId),
...over,
});
return { c, h, sentTo };
};
describe('picking', () => {
it('starts idle with nothing picked, and a pick holds the files in order', () => {
const { c } = make();
expect(c.getState().files).toEqual([]);
expect(c.getState().phase).toEqual({ kind: 'idle' });
c.pick([file('a'), file('b')]);
expect(c.getState().files.map((f) => f.name)).toEqual(['a', 'b']);
});
it('Cancel empties the pick and the title and bumps the input epoch', () => {
const { c } = make();
c.pick([file('a')]);
c.setTitle('Take 4');
const before = c.getState().epoch;
c.cancel();
expect(c.getState()).toMatchObject({ files: [], title: '', epoch: before + 1 });
});
it('subscribers hear every change and stop hearing after unsubscribe', () => {
const { c } = make();
let heard = 0;
const off = c.subscribe(() => {
heard += 1;
});
c.pick([file('a')]);
off();
c.pick([]);
expect(heard).toBe(1);
});
});
describe('the Send guard', () => {
it('a double tap starts one send', async () => {
const { c, h } = make();
c.pick([file('a'), file('b')]);
const first = c.send();
const second = c.send();
expect(h.calls).toHaveLength(1);
h.release(ok(2));
await Promise.all([first, second]);
expect(h.calls).toHaveLength(1);
});
it('a re-mounted panel for the same room cannot start a second send while one is in flight', async () => {
const { c, h } = make();
c.pick([file('a')]);
const first = c.send();
// The page was left and returned to: a new controller, nothing picked in it but a new pick.
const again = make({}, '!zone:x');
again.c.pick([file('b')]);
await again.c.send();
expect(again.h.calls).toHaveLength(0);
h.release(ok(1));
await first;
// Once the first has settled the room is free again.
const next = again.c.send();
expect(again.h.calls).toHaveLength(1);
again.h.release(ok(1));
await next;
});
it('another room is not blocked by a send in flight', async () => {
const a = make({}, '!a:x');
const b = make({}, '!b:x');
a.c.pick([file('a')]);
b.c.pick([file('b')]);
const sendA = a.c.send();
const sendB = b.c.send();
expect(b.h.calls).toHaveLength(1);
a.h.release(ok(1));
b.h.release(ok(1));
await Promise.all([sendA, sendB]);
});
it('sends nothing when nothing is picked', async () => {
const { c, h } = make();
await c.send();
expect(h.calls).toHaveLength(0);
});
it('a send that throws frees the room and says so', async () => {
const { c, h } = make();
c.pick([file('a')]);
const p = c.send();
h.fail(new Error('M_FORBIDDEN'));
await p;
expect(c.getState().phase).toEqual({ kind: 'done', text: 'Could not share: M_FORBIDDEN' });
expect(c.getState().files).toHaveLength(1);
const retry = c.send();
expect(h.calls).toHaveLength(2);
h.release(ok(1));
await retry;
});
});
describe('progress and results', () => {
it('counts files as they settle, passing the title through', async () => {
const { c, h } = make();
c.pick([file('a'), file('b')]);
c.setTitle('Take 4');
const p = c.send();
expect(c.getState().phase).toEqual({ kind: 'sending', done: 0 });
expect(h.calls[0].title).toBe('Take 4');
h.calls[0].onFile();
expect(c.getState().phase).toEqual({ kind: 'sending', done: 1 });
h.release(ok(2));
await p;
expect(c.getState().phase).toEqual({ kind: 'done', text: 'Shared 2 files.' });
});
it('full success clears the pick, tells the room list, and remounts the input', async () => {
const { c, h, sentTo } = make();
c.pick([file('a')]);
const { epoch } = c.getState();
const p = c.send();
h.release(ok(1));
await p;
expect(c.getState()).toMatchObject({ files: [], title: '', epoch: epoch + 1 });
expect(sentTo).toEqual(['!zone:x']);
});
it('failed files stay picked by position, so a same-named twin that went is not re-sent', async () => {
const { c, h } = make();
const twins = [file('a.ogg'), file('a.ogg'), file('b.ogg')];
c.pick(twins);
const p = c.send();
h.release({ albumId: '$r', sent: 2, failed: ['a.ogg'], failedAt: [1] });
await p;
expect(c.getState().files).toEqual([twins[1]]);
expect(c.getState().phase).toEqual({ kind: 'done', text: 'Shared 2. Not sent: a.ogg' });
});
it('when every upload failed everything stays picked and the note says nothing was shared', async () => {
const { c, h } = make();
const picked = [file('a'), file('b')];
c.pick(picked);
const p = c.send();
h.release({ albumId: '$r', sent: 0, failed: ['a', 'b'], failedAt: [0, 1] });
await p;
expect(c.getState().files).toEqual(picked);
expect(c.getState().phase).toEqual({
kind: 'done',
text: 'Nothing was shared. Not sent: a, b',
});
});
it('a new pick after a result drops the result note', async () => {
const { c, h } = make();
c.pick([file('a')]);
const p = c.send();
h.release(ok(1));
await p;
c.pick([file('b')]);
expect(c.getState().phase).toEqual({ kind: 'idle' });
});
});
describe('what a send leaves behind', () => {
it('keeps the title when some files failed, so the retry has the same name', async () => {
const { c, h } = make();
c.pick([file('a'), file('b')]);
c.setTitle('Take 4');
const p = c.send();
h.release({ albumId: '$r', sent: 1, failed: ['b'], failedAt: [1] });
await p;
expect(c.getState().title).toBe('Take 4');
});
it('tells the room list only after the send settled, and not when it threw', async () => {
const { c, h, sentTo } = make();
c.pick([file('a')]);
const p = c.send();
expect(sentTo).toEqual([]);
h.release(ok(1));
await p;
expect(sentTo).toEqual(['!zone:x']);
const bad = make();
bad.c.pick([file('a')]);
const q = bad.c.send();
bad.h.fail(new Error('boom'));
await q;
expect(bad.sentTo).toEqual([]);
});
it('a success lands in one notification: the pick is never empty while still sending', async () => {
const { c, h } = make();
c.pick([file('a')]);
const p = c.send();
const seen: string[] = [];
c.subscribe(() => {
const st = c.getState();
seen.push(`${st.files.length}:${st.phase.kind}`);
});
h.release(ok(1));
await p;
expect(seen).toEqual(['0:done']);
});
});
describe('an encrypted room', () => {
it('says so, sends nothing and keeps the pick', async () => {
const { c, h } = make({ isEncrypted: () => true });
c.pick([file('a')]);
await c.send();
expect(c.getState().phase).toEqual({
kind: 'done',
text: 'Not shared: this room is encrypted.',
});
expect(c.getState().files).toHaveLength(1);
expect(h.calls).toHaveLength(0);
});
it('is checked at Send, not at mount: a room that turns encrypted after the pick is refused', async () => {
let encrypted = false;
const { c, h } = make({ isEncrypted: () => encrypted });
c.pick([file('a')]);
encrypted = true;
await c.send();
expect(h.calls).toHaveLength(0);
expect(c.getState().phase).toMatchObject({
kind: 'done',
text: 'Not shared: this room is encrypted.',
});
});
});

View file

@ -0,0 +1,87 @@
// The share panel's state machine, apart from React and Matrix: what is picked, what phase the
// send is in, and the rules that keep a room to one send at a time. ShareSet.tsx draws it.
import { ShareResult, remainingAfter, resultNote } from './shareSet';
export type Picked = { name: string; type: string };
export type SharePhase =
| { kind: 'idle' }
| { kind: 'sending'; done: number }
| { kind: 'done'; text: string };
export type ShareState<F extends Picked> = {
files: F[];
title: string;
phase: SharePhase;
/** Bumps whenever the pick is emptied, so the file input can be remounted and re-pick the same files. */
epoch: number;
};
export const ENCRYPTED_NOTE = 'Not shared: this room is encrypted.';
export type ShareDeps<F extends Picked> = {
roomId: string;
isEncrypted: () => boolean;
/** Sends the files as one album; reports each settled file through onFile. */
run: (files: F[], title: string, onFile: () => void) => Promise<ShareResult>;
/** Called after a send that reached the room, whatever it left unsent. */
onSent?: () => void;
};
/** Rooms with a share in flight. Module-level: leaving the page unmounts the panel, not the send. */
const sending = new Set<string>();
export const createShareController = <F extends Picked>(deps: ShareDeps<F>) => {
let state: ShareState<F> = { files: [], title: '', phase: { kind: 'idle' }, epoch: 0 };
const listeners = new Set<() => void>();
const set = (patch: Partial<ShareState<F>>) => {
state = { ...state, ...patch };
listeners.forEach((l) => l());
};
const clear = () => set({ files: [], title: '', epoch: state.epoch + 1 });
const send = async () => {
if (sending.has(deps.roomId) || state.files.length === 0) return;
if (deps.isEncrypted()) {
set({ phase: { kind: 'done', text: ENCRYPTED_NOTE } });
return;
}
sending.add(deps.roomId);
const { files, title } = state;
set({ phase: { kind: 'sending', done: 0 } });
let settled = 0;
try {
const res = await deps.run(files, title, () => {
settled += 1;
set({ phase: { kind: 'sending', done: settled } });
});
deps.onSent?.();
const left = remainingAfter(files, res);
const phase: SharePhase = { kind: 'done', text: resultNote(res) };
// One notification: no state is ever drawn with the pick emptied and the send still running.
if (left.length === 0) set({ files: [], title: '', epoch: state.epoch + 1, phase });
else set({ files: left, phase });
} catch (e: any) {
set({ phase: { kind: 'done', text: `Could not share: ${e?.message ?? 'send failed'}` } });
} finally {
sending.delete(deps.roomId);
}
};
return {
getState: () => state,
subscribe: (l: () => void) => {
listeners.add(l);
return () => {
listeners.delete(l);
};
},
pick: (files: F[]) => set({ files, phase: { kind: 'idle' } }),
setTitle: (title: string) => set({ title }),
cancel: clear,
send,
};
};
export type ShareController<F extends Picked> = ReturnType<typeof createShareController<F>>;

View file

@ -0,0 +1,160 @@
import { describe, expect, it } from 'vitest';
import config from '../../../../config.json';
import { ZoneEvent, buildAlbums } from './buildAlbums';
import {
DEFAULT_TITLE,
ShareFile,
SharePort,
remainingAfter,
resultNote,
sendShareSet,
threadRelation,
} from './shareSet';
const kindOf = (f: ShareFile) => (f.mimeType.startsWith('audio') ? 'm.audio' : 'm.file');
/** A fake room: records what is sent, as the events Synapse would return from /relations. */
const room = (failOn: string[] = []) => {
const events: ZoneEvent[] = [];
const port: SharePort<ShareFile> = {
send: async (content) => {
const id = `$e${events.length + 1}`;
events.push({
event_id: id,
type: 'm.room.message',
sender: '@aqua:chat.gamecult.org',
origin_server_ts: 1000 + events.length,
content,
});
return id;
},
contentOf: async (f) => {
if (failOn.includes(f.name)) throw new Error('upload failed');
return {
msgtype: kindOf(f),
body: f.name,
filename: f.name,
url: `mxc://x/${f.name}`,
info: { mimetype: f.mimeType },
};
},
};
const album = () => {
const [root, ...replies] = events;
return buildAlbums([{ root, replies }], 'chat.gamecult.org')[0];
};
return { events, port, album };
};
const clips: ShareFile[] = [
{ name: 'a.ogg', mimeType: 'audio/ogg' },
{ name: 'b.ogg', mimeType: 'audio/ogg' },
{ name: 'c.txt', mimeType: 'text/plain' },
];
describe('sendShareSet', () => {
it('reads back as one album of the files in pick order', async () => {
const r = room();
const res = await sendShareSet(r.port, 'Take 4', clips);
expect(res).toEqual({ albumId: '$e1', sent: 3, failed: [], failedAt: [] });
const album = r.album();
expect(album.title).toBe('Take 4');
expect(album.files.map((f) => [f.name, f.kind])).toEqual([
['a.ogg', 'audio'],
['b.ogg', 'audio'],
['c.txt', 'file'],
]);
});
it('threads every file under the root, each falling back to the previous event', async () => {
const r = room();
await sendShareSet(r.port, 't', clips);
const rels = r.events.slice(1).map((e) => e.content?.['m.relates_to']);
expect(rels).toEqual([
threadRelation('$e1', '$e1'),
threadRelation('$e1', '$e2'),
threadRelation('$e1', '$e3'),
]);
expect(r.events[0].content?.['m.relates_to']).toBeUndefined();
});
it('titles an untitled share', async () => {
const r = room();
await sendShareSet(r.port, ' ', clips.slice(0, 1));
expect(r.album().title).toBe(DEFAULT_TITLE);
});
it('names a failed file and still sends the rest', async () => {
const r = room(['b.ogg']);
const res = await sendShareSet(r.port, 't', clips);
expect(res.failed).toEqual(['b.ogg']);
expect(res.sent).toBe(2);
expect(r.album().files.map((f) => f.name)).toEqual(['a.ogg', 'c.txt']);
// the next file falls back to the last event that exists, not to the failed one
expect(r.events[2].content?.['m.relates_to']).toEqual(threadRelation('$e1', '$e2'));
});
it('throws when the root cannot be sent, uploading nothing', async () => {
let uploads = 0;
const port: SharePort<ShareFile> = {
send: async () => {
throw new Error('M_FORBIDDEN');
},
contentOf: async () => {
uploads += 1;
return {};
},
};
await expect(sendShareSet(port, 't', clips)).rejects.toThrow('M_FORBIDDEN');
expect(uploads).toBe(0);
});
});
describe('the share flag', () => {
it('ships off', () => {
expect(config.fileZoneShare).toEqual({ enabled: false });
});
});
describe('after a send', () => {
it('keeps the failed one of two same-named files, not both', async () => {
const twins: ShareFile[] = [
{ name: 'a.ogg', mimeType: 'audio/ogg' },
{ name: 'a.ogg', mimeType: 'audio/ogg' },
];
let n = 0;
const r = room();
const port: SharePort<ShareFile> = {
...r.port,
contentOf: async (f) => {
n += 1;
if (n === 2) throw new Error('second upload failed');
return r.port.contentOf(f);
},
};
const res = await sendShareSet(port, 't', twins);
expect(res.failedAt).toEqual([1]);
expect(remainingAfter(twins, res)).toEqual([twins[1]]);
});
it('keeps only the failed files picked, so a retry is one tap', async () => {
const r = room(['b.ogg']);
const res = await sendShareSet(r.port, 't', clips);
expect(remainingAfter(clips, res).map((f) => f.name)).toEqual(['b.ogg']);
expect(resultNote(res)).toBe('Shared 2. Not sent: b.ogg');
});
it('keeps everything and says nothing was shared when every upload failed', async () => {
const r = room(clips.map((c) => c.name));
const res = await sendShareSet(r.port, 't', clips);
expect(remainingAfter(clips, res)).toEqual(clips);
expect(resultNote(res)).toBe('Nothing was shared. Not sent: a.ogg, b.ogg, c.txt');
});
it('clears the pick on full success', async () => {
const r = room();
const res = await sendShareSet(r.port, 't', clips);
expect(remainingAfter(clips, res)).toEqual([]);
expect(resultNote(res)).toBe('Shared 3 files.');
});
});

View file

@ -0,0 +1,83 @@
// Share a set of files at once: one album root (a text message) and one thread reply per file.
// That is exactly the shape buildAlbums reads, so a set shared here and a set a Mind's mouth posts
// are the same thing to every viewer. Ports are injected so the order and the failure rules are
// testable without a homeserver. See docs/file-zone.md, "Share a set".
export type ShareFile = { name: string; mimeType: string };
/** What sendShareSet needs from the outside world. */
export type SharePort<F extends ShareFile> = {
/** Send an event and return its id. */
send: (content: Record<string, unknown>) => Promise<string>;
/** Upload one file and build its message content (msgtype, url, info incl. sha256). */
contentOf: (file: F) => Promise<Record<string, unknown>>;
/** Called after each file settles, for progress. */
onFile?: (index: number, result: 'sent' | 'failed') => void;
};
export type ShareResult = {
/** The album root's event id; present as soon as the root is sent. */
albumId: string;
sent: number;
failed: string[];
/** Positions in the files array of those that failed; names can repeat, positions cannot. */
failedAt: number[];
};
/** The reply relation Matrix clients expect for a thread message: root, with the last event as fallback reply. */
export const threadRelation = (rootId: string, lastId: string) => ({
rel_type: 'm.thread',
event_id: rootId,
is_falling_back: true,
'm.in_reply_to': { event_id: lastId },
});
export const DEFAULT_TITLE = 'Shared files';
export const titleOrDefault = (title: string): string => title.trim() || DEFAULT_TITLE;
/**
* Files go one at a time, in the order given, so thread order is pick order. A file that fails
* to upload is reported by name and the rest still go; the root is already out, so stopping would
* leave an empty album and the sender nothing to retry from. A root that fails to send throws:
* nothing exists yet.
*/
export const sendShareSet = async <F extends ShareFile>(
port: SharePort<F>,
title: string,
files: F[]
): Promise<ShareResult> => {
const albumId = await port.send({ msgtype: 'm.text', body: titleOrDefault(title) });
const result: ShareResult = { albumId, sent: 0, failed: [], failedAt: [] };
let lastId = albumId;
for (let i = 0; i < files.length; i += 1) {
try {
// eslint-disable-next-line no-await-in-loop
const content = await port.contentOf(files[i]);
// eslint-disable-next-line no-await-in-loop
lastId = await port.send({ ...content, 'm.relates_to': threadRelation(albumId, lastId) });
result.sent += 1;
port.onFile?.(i, 'sent');
} catch {
result.failed.push(files[i].name);
result.failedAt.push(i);
port.onFile?.(i, 'failed');
}
}
return result;
};
/**
* What stays picked after a send: only the files that did not go, so a retry is one tap. A retry
* makes a second album (adding to an existing one is not built), and a share where nothing went
* leaves its root as a lone message the album list does not show (it lists threads with replies).
*/
export const remainingAfter = <T>(files: T[], result: ShareResult): T[] =>
files.filter((_, i) => result.failedAt.includes(i));
export const resultNote = (result: ShareResult): string => {
if (result.failed.length === 0)
return `Shared ${result.sent} ${result.sent === 1 ? 'file' : 'files'}.`;
if (result.sent === 0) return `Nothing was shared. Not sent: ${result.failed.join(', ')}`;
return `Shared ${result.sent}. Not sent: ${result.failed.join(', ')}`;
};

View file

@ -59,7 +59,7 @@ export const useAlbums = (room: Room): AlbumsState => {
)
).chunk ?? [],
}));
return buildAlbums(inputs);
return buildAlbums(inputs, mx.getDomain() ?? '');
},
retry: 1,
refetchInterval: REFRESH_MS,

View file

@ -0,0 +1,25 @@
import { useMemo, useSyncExternalStore } from 'react';
import { Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { FileReader, readersOf } from './fileReceipt';
import { receiptStoreOf } from './receiptStore';
/**
* Who has read a file, from the receipts in the room's loaded timeline: a plain room event or a thread reply, both
* count (docs/file-zone-mind-contract.md). `content` is the file event's content, which carries its `info.sha256`.
* The room's one shared subscription (receiptStore.ts) feeds every file row.
*/
export const useFileReaders = (
room: Room,
fileId: string,
content: Record<string, unknown>
): FileReader[] => {
const mx = useMatrixClient();
const server = mx.getDomain() ?? '';
const store = receiptStoreOf(room);
const receipts = useSyncExternalStore(store.subscribe, store.getSnapshot);
return useMemo(
() => readersOf(receipts, fileId, content, server),
[receipts, fileId, content, server]
);
};

View file

@ -5,6 +5,7 @@ import { StatusDivider } from './components';
import { CallEmbed, useCallControlState } from '../../plugins/call';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { callEmbedAtom } from '../../state/callEmbed';
import { PhoneHitSpread26 } from '../../styles/PhoneHit.css';
type MicrophoneButtonProps = {
enabled: boolean;
@ -14,7 +15,7 @@ type MicrophoneButtonProps = {
function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps) {
const [micState, toggleMic] = useAsyncCallback(onToggle);
const loading = micState.status === AsyncStatus.Loading;
return (
<TooltipProvider
position="Top"
@ -26,6 +27,7 @@ function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps
>
{(anchorRef) => (
<IconButton
className={PhoneHitSpread26}
ref={anchorRef}
variant={enabled ? 'Surface' : 'Warning'}
fill="Soft"
@ -59,6 +61,7 @@ function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
>
{(anchorRef) => (
<IconButton
className={PhoneHitSpread26}
ref={anchorRef}
variant={enabled ? 'Surface' : 'Warning'}
fill="Soft"
@ -99,6 +102,7 @@ function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
>
{(anchorRef) => (
<IconButton
className={PhoneHitSpread26}
ref={anchorRef}
variant={enabled ? 'Success' : 'Surface'}
fill="Soft"
@ -136,6 +140,7 @@ function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonPro
>
{(anchorRef) => (
<IconButton
className={PhoneHitSpread26}
ref={anchorRef}
variant={enabled ? 'Success' : 'Surface'}
fill="Soft"
@ -164,7 +169,10 @@ export function CallControl({
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
const setCallEmbed = useSetAtom(callEmbedAtom);
const handleMicrophoneToggle = useCallback(() => callEmbed.control.toggleMicrophone(), [callEmbed]);
const handleMicrophoneToggle = useCallback(
() => callEmbed.control.toggleMicrophone(),
[callEmbed]
);
const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]);
const [hangupState, hangup] = useAsyncCallback(
@ -195,11 +203,7 @@ export function CallControl({
disabled={!callJoined}
/>
{!compact && <StatusDivider />}
<VideoButton
enabled={video}
onToggle={handleVideoToggle}
disabled={!callJoined}
/>
<VideoButton enabled={video} onToggle={handleVideoToggle} disabled={!callJoined} />
{!compact && (
<ScreenShareButton
enabled={screenshare}

View file

@ -25,6 +25,7 @@ import {
SoundButton,
VideoButton,
} from './Controls';
import { PhoneHitSpread40 } from '../../styles/PhoneHit.css';
import { CallEmbed, useCallControlState } from '../../plugins/call';
import { useResizeObserver } from '../../hooks/useResizeObserver';
import { stopPropagation } from '../../utils/keyboard';
@ -71,7 +72,10 @@ export function CallControls({ callEmbed }: CallControlsProps) {
setCords(undefined);
};
const handleMicrophoneToggle = useCallback(() => callEmbed.control.toggleMicrophone(), [callEmbed]);
const handleMicrophoneToggle = useCallback(
() => callEmbed.control.toggleMicrophone(),
[callEmbed]
);
const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]);
const [hangupState, hangup] = useAsyncCallback(
@ -97,10 +101,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
>
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
<MicrophoneButton
enabled={microphone}
onToggle={handleMicrophoneToggle}
/>
<MicrophoneButton enabled={microphone} onToggle={handleMicrophoneToggle} />
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
</Box>
{!compact && <ControlDivider />}
@ -169,6 +170,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
}
>
<IconButton
className={PhoneHitSpread40}
variant="Surface"
fill="Soft"
radii="400"

View file

@ -4,6 +4,7 @@ import { useAtom } from 'jotai';
import * as css from './styles.css';
import { callChatAtom } from '../../state/callEmbed';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { PhoneHitSpread40 } from '../../styles/PhoneHit.css';
export function ControlDivider() {
return (
@ -18,7 +19,7 @@ type MicrophoneButtonProps = {
export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
const [micState, toggleMic] = useAsyncCallback(onToggle);
const loading = micState.status === AsyncStatus.Loading;
return (
<TooltipProvider
position="Top"
@ -31,6 +32,7 @@ export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
>
{(anchorRef) => (
<IconButton
className={PhoneHitSpread40}
ref={anchorRef}
variant={enabled ? 'Surface' : 'Warning'}
fill="Soft"
@ -64,6 +66,7 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
>
{(anchorRef) => (
<IconButton
className={PhoneHitSpread40}
ref={anchorRef}
variant={enabled ? 'Surface' : 'Warning'}
fill="Soft"
@ -103,6 +106,7 @@ export function VideoButton({ enabled, onToggle }: VideoButtonProps) {
>
{(anchorRef) => (
<IconButton
className={PhoneHitSpread40}
ref={anchorRef}
variant={enabled ? 'Success' : 'Surface'}
fill="Soft"
@ -140,6 +144,7 @@ export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps)
>
{(anchorRef) => (
<IconButton
className={PhoneHitSpread40}
ref={anchorRef}
variant={enabled ? 'Success' : 'Surface'}
fill="Soft"
@ -170,6 +175,7 @@ export function ChatButton() {
>
{(anchorRef) => (
<IconButton
className={PhoneHitSpread40}
ref={anchorRef}
variant={chat ? 'Success' : 'Surface'}
fill="Soft"

View file

@ -26,7 +26,6 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
const { microphone, video, sound, toggleMicrophone, toggleVideo, toggleSound } =
useCallPreferences();
const handleMicrophoneToggle = useCallback(async () => toggleMicrophone(), [toggleMicrophone]);
const handleVideoToggle = useCallback(async () => toggleVideo(), [toggleVideo]);

View file

@ -30,6 +30,7 @@ import {
AccountDataSubmitCallback,
} from '../../../components/AccountDataEditor';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type DeveloperToolsProps = {
requestClose: () => void;
@ -92,7 +93,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>

View file

@ -22,6 +22,7 @@ import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { syntaxErrorPosition } from '../../../utils/dom';
import { Cursor } from '../../../plugins/text-area';
import { PhoneHit } from '../../../styles/PhoneHit.css';
const EDITOR_INTENT_SPACE_COUNT = 2;
@ -118,7 +119,7 @@ export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventPro
</Chip>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>

View file

@ -31,6 +31,7 @@ import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { PhoneHit } from '../../../styles/PhoneHit.css';
const EDITOR_INTENT_SPACE_COUNT = 2;
@ -275,7 +276,7 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi
</Chip>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>

View file

@ -4,6 +4,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
import { ImagePack } from '../../../plugins/custom-emoji';
import { ImagePackView } from '../../../components/image-pack-view';
import { RoomPacks } from './RoomPacks';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type EmojisStickersProps = {
requestClose: () => void;
@ -29,7 +30,7 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) {
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>

View file

@ -40,6 +40,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useAlive } from '../../../hooks/useAlive';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type CreatePackTileProps = {
packs: ImagePack[];
@ -215,6 +216,7 @@ export function RoomPacks({ onViewPack }: RoomPacksProps) {
{canEdit &&
(removed ? (
<IconButton
className={PhoneHit}
size="300"
radii="Pill"
variant="Critical"
@ -225,6 +227,7 @@ export function RoomPacks({ onViewPack }: RoomPacksProps) {
</IconButton>
) : (
<IconButton
className={PhoneHit}
size="300"
radii="Pill"
variant="Secondary"

View file

@ -28,6 +28,7 @@ import { useRoom } from '../../../hooks/useRoom';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { stopPropagation } from '../../../utils/keyboard';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
import { PhoneHit } from '../../../styles/PhoneHit.css';
const ROOM_ENC_ALGO = 'm.megolm.v1.aes-sha2';
@ -123,7 +124,12 @@ export function RoomEncryption({ permissions }: RoomEncryptionProps) {
<Box grow="Yes">
<Text size="H4">Enable Encryption</Text>
</Box>
<IconButton size="300" onClick={() => setPrompt(false)} radii="300">
<IconButton
className={PhoneHit}
size="300"
onClick={() => setPrompt(false)}
radii="300"
>
<Icon src={Icons.Cross} />
</IconButton>
</Header>

View file

@ -39,6 +39,7 @@ import { useAlive } from '../../../hooks/useAlive';
import { creatorsSupported } from '../../../utils/matrix';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { BreakWord } from '../../../styles/Text.css';
import { PhoneHit } from '../../../styles/PhoneHit.css';
function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
const mx = useMatrixClient();
@ -105,7 +106,7 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
<Box grow="Yes">
<Text size="H4">{room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'}</Text>
</Box>
<IconButton size="300" onClick={requestClose} radii="300">
<IconButton className={PhoneHit} size="300" onClick={requestClose} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>

View file

@ -56,6 +56,7 @@ import { useSpaceOptionally } from '../../../hooks/useSpace';
import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../../hooks/useMemberPowerTag';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { getMouseEventCords } from '../../../utils/dom';
import { PhoneHit } from '../../../styles/PhoneHit.css';
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000,
@ -161,7 +162,7 @@ export function Members({ requestClose }: MembersProps) {
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
@ -277,6 +278,7 @@ export function Members({ requestClose }: MembersProps) {
anchorRef={scrollTopAnchorRef}
>
<IconButton
className={PhoneHit}
onClick={() => virtualizer.scrollToOffset(0)}
variant="Surface"
radii="Pill"

View file

@ -49,6 +49,7 @@ import { useAlive } from '../../../hooks/useAlive';
import { BetaNoticeBadge } from '../../../components/BetaNoticeBadge';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
import { creatorsSupported } from '../../../utils/matrix';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type EditPowerProps = {
maxPower: number;
@ -369,7 +370,7 @@ export function PowersEditor({ powerLevels, requestClose }: PowersEditorProps) {
</Chip>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>

View file

@ -24,6 +24,7 @@ import {
import { CreateRoomModalState } from '../../state/createRoomModal';
import { stopPropagation } from '../../utils/keyboard';
import { CreateRoomType } from '../../components/create-room/types';
import { PhoneHit } from '../../styles/PhoneHit.css';
type CreateRoomModalProps = {
state: CreateRoomModalState;
@ -63,7 +64,7 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
</Text>
</Box>
<Box shrink="No">
<IconButton size="300" radii="300" onClick={closeDialog}>
<IconButton className={PhoneHit} size="300" radii="300" onClick={closeDialog}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>

View file

@ -23,6 +23,7 @@ import {
} from '../../state/hooks/createSpaceModal';
import { CreateSpaceModalState } from '../../state/createSpaceModal';
import { stopPropagation } from '../../utils/keyboard';
import { PhoneHit } from '../../styles/PhoneHit.css';
type CreateSpaceModalProps = {
state: CreateSpaceModalState;
@ -61,7 +62,7 @@ function CreateSpaceModal({ state }: CreateSpaceModalProps) {
<Text size="H4">New Space</Text>
</Box>
<Box shrink="No">
<IconButton size="300" radii="300" onClick={closeDialog}>
<IconButton className={PhoneHit} size="300" radii="300" onClick={closeDialog}>
<Icon src={Icons.Cross} />
</IconButton>
</Box>

View file

@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
import { PhoneHit } from '../../styles/PhoneHit.css';
import { Page, PageContent, PageContentCenter, PageHeader } from '../../components/page';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { getHomePath } from '../../pages/pathUtils';
@ -33,7 +34,11 @@ export function Desk() {
<Box grow="Yes" alignItems="Center" gap="200">
<Box grow="Yes" basis="No">
{phone && (
<IconButton aria-label="Back to rooms" onClick={() => navigate(getHomePath())}>
<IconButton
className={PhoneHit}
aria-label="Back to rooms"
onClick={() => navigate(getHomePath())}
>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}

View file

@ -30,13 +30,29 @@ export const Title = style({
export const Fold = style({
all: 'unset',
cursor: 'pointer',
display: 'block',
width: '100%',
minHeight: 44,
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: config.space.S200,
// A folded row says it can be opened: a chevron at the end, turned down while the row is open.
selectors: {
'&::after': {
content: '"›"',
flexShrink: 0,
fontSize: '1.5em',
lineHeight: 1,
transition: 'transform 120ms',
},
'&[aria-expanded=true]::after': { transform: 'rotate(90deg)' },
},
});
export const Quiet = style({ opacity: config.opacity.P300, margin: `${config.space.S100} 0` });
// Quiet lines (age, who it waits on, helper text) were measured at 4.5 over the day sky and 3.9-4.0 at dusk and night
// with the theme's 0.75 (taps2/desk-REPORT.md); 0.9 clears 4.5 in all three.
export const Quiet = style({ opacity: 0.9, margin: `${config.space.S100} 0` });
export const Body = style({ whiteSpace: 'pre-wrap', margin: `${config.space.S200} 0` });
export const Options = style({ margin: 0, padding: 0, listStyle: 'none' });
export const Option = style({ padding: `${config.space.S100} 0` });
@ -50,8 +66,9 @@ export const Note = style({
font: 'inherit',
color: 'inherit',
background: 'none',
border: `1px solid ${color.Background.ContainerLine}`,
opacity: config.opacity.Disabled,
// The note is disabled (slice 1). Its border is a UI boundary (3:1 wanted; the faint line under the 0.38 disabled
// opacity measured 2.0), so it is drawn dashed in the full ink and not faded.
border: '1px dashed currentColor',
});
/** Sticky beside the rows on wide screens; a small fixed corner image on phones. */
@ -97,3 +114,9 @@ export const BoothLine = style({
},
},
});
/** The recorded rulings sit on the dark ridge in every sky; they carry their own ground (scrimStyle). */
export const Recorded = style({
marginTop: config.space.S400,
padding: `${config.space.S200} ${config.space.S300}`,
});

View file

@ -1,14 +1,19 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ScreenSize, ScreenSizeProvider } from '../../hooks/useScreenSize';
import { SkyInkContextProvider } from '../../hooks/useTheme';
import { SCRIM_GROUND, SCRIM_INK } from '../scene/skyInk';
import { DeskEntry } from './DeskEntry';
import { DeskView, SIGN_IN_NOTE } from './DeskView';
import { ForkList } from './forkContract';
import { STATES } from './fixtures';
import { setDeskSource } from './source';
// useTheme reads the stored settings when it loads.
vi.hoisted(() => vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined }));
const view = (
state: keyof typeof STATES | 'unpublished',
size: ScreenSize,
@ -66,6 +71,19 @@ describe('DeskView', () => {
expect(html).toContain('Question done (ruling:done-1)');
expect(html).not.toContain('Question shut');
});
it('gives the recorded rulings their own ground and ink while a scene runs, none without one', () => {
const withScene = renderToStaticMarkup(
<ScreenSizeProvider value={ScreenSize.Desktop}>
<SkyInkContextProvider value={{ state: 'dusk', phone: false }}>
<DeskView mind="void" label="Void" list={STATES.settled} unverified={new Set()} />
</SkyInkContextProvider>
</ScreenSizeProvider>
);
// renderToStaticMarkup writes the inline style as the browser would read it back
expect(withScene).toContain(SCRIM_GROUND.replace(/, /g, ', '));
expect(withScene).toContain(`color:${SCRIM_INK}`);
expect(view('settled', ScreenSize.Desktop)).not.toContain('background-color');
});
it('shows each outcome quietly, marks the counted answer, and renders a note only when sent', () => {
const html = view('answered', ScreenSize.Desktop);
expect(html).toContain(
@ -145,7 +163,15 @@ describe("a question's age", () => {
expect(html('2026-11-11T11:40:00Z')).toContain('raised just now');
});
it('shows no age when the card carries none, or an instant that does not parse or is ahead of the clock', () => {
['null', 'yesterday', '2027-01-01T00:00:00Z', '2026-11-06', 'Nov 6 2026', '0', '2026-02-30T00:00:00Z'].forEach((v) => {
[
'null',
'yesterday',
'2027-01-01T00:00:00Z',
'2026-11-06',
'Nov 6 2026',
'0',
'2026-02-30T00:00:00Z',
].forEach((v) => {
expect(html(v === 'null' ? null : v)).not.toContain('raised ');
});
});

View file

@ -4,6 +4,8 @@ import { PoseAvatar } from '../../components/message/pose/PoseAvatar';
import { MINDS } from '../../components/message/pose/poses';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { AnswerRow, Desk, buildDesk, DeskRow } from './buildDesk';
import { useSkyInk } from '../../hooks/useTheme';
import { scrimStyle } from '../scene/skyInk';
import { ForkList } from './forkContract';
import { deskMood } from './deskMood';
import { raisedAge } from './age';
@ -144,6 +146,7 @@ type DeskViewProps = {
/** One Mind's desk, read-only: a stream of rows, the recorded rulings, and the booth Persona. */
export function DeskView({ mind, label, list, unverified, now = Date.now() }: DeskViewProps) {
const desk = buildDesk(list, unverified);
const skyInk = useSkyInk();
// Slice 1 has no session, so nothing on the desk is the viewer's answer yet.
const mood = deskMood(desk.counts.open, 0);
return (
@ -166,7 +169,7 @@ export function DeskView({ mind, label, list, unverified, now = Date.now() }: De
)}
</div>
{desk.recorded.length > 0 && (
<>
<div className={css.Recorded} style={scrimStyle(skyInk)}>
<h3 className={css.Title}>Recorded as rulings</h3>
<ul className={css.Options}>
{desk.recorded.map((r) => (
@ -175,7 +178,7 @@ export function DeskView({ mind, label, list, unverified, now = Date.now() }: De
</li>
))}
</ul>
</>
</div>
)}
</section>
);

View file

@ -109,7 +109,10 @@ describe('v4 manifest against the parser', () => {
it("every voice file is Aqua's v4 render (sha256 prefix)", () => {
const hash = (file: string) =>
createHash('sha256').update(readFileSync(join(DINGS, file))).digest('hex').slice(0, 12);
createHash('sha256')
.update(readFileSync(join(DINGS, file)))
.digest('hex')
.slice(0, 12);
expect(Object.keys(V4_SHA).length).toBe(VOICES.length * MOTIFS.length);
Object.entries(V4_SHA).forEach(([k, want]) => expect(hash(`senders/${k}.ogg`)).toBe(want));
});

View file

@ -10,6 +10,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { allRoomsAtom } from '../../state/room-list/roomList';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { BackRouteHandler } from '../../components/BackRouteHandler';
import { PhoneHit } from '../../styles/PhoneHit.css';
type JoinBeforeNavigateProps = { roomIdOrAlias: string; eventId?: string; viaServers?: string[] };
export function JoinBeforeNavigate({
@ -38,7 +39,7 @@ export function JoinBeforeNavigate({
{screenSize === ScreenSize.Mobile && (
<BackRouteHandler>
{(onBack) => (
<IconButton onClick={onBack}>
<IconButton className={PhoneHit} onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}

View file

@ -30,6 +30,7 @@ import { IPowerLevels } from '../../hooks/usePowerLevels';
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { PhoneHitSpread24 } from '../../styles/PhoneHit.css';
type HierarchyItemWithParent = HierarchyItem & {
parentId: string;
@ -231,6 +232,7 @@ export function HierarchyItemMenu({
return (
<Box gap="200" alignItems="Center" shrink="No">
<IconButton
className={PhoneHitSpread24}
onClick={handleOpenMenu}
size="300"
variant="SurfaceVariant"

View file

@ -48,6 +48,7 @@ import { useGetRoom } from '../../hooks/useGetRoom';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
import { PhoneHit } from '../../styles/PhoneHit.css';
const useCanDropLobbyItem = (
space: Room,
@ -413,6 +414,7 @@ export function Lobby() {
onVisibilityChange={setOnTop}
>
<IconButton
className={PhoneHit}
onClick={() => virtualizer.scrollToOffset(0)}
variant="SurfaceVariant"
radii="Pill"

View file

@ -38,6 +38,7 @@ import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { PhoneHit } from '../../styles/PhoneHit.css';
type LobbyMenuProps = {
powerLevels: IPowerLevels;
@ -165,7 +166,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
<Box shrink="No">
<BackRouteHandler>
{(onBack) => (
<IconButton fill="None" onClick={onBack}>
<IconButton className={PhoneHit} fill="None" onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
@ -240,6 +241,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
>
{(triggerRef) => (
<IconButton
className={PhoneHit}
fill="None"
onClick={handleOpenMenu}
ref={triggerRef}

View file

@ -22,7 +22,9 @@ export function LobbyHero() {
const name = useRoomName(space);
const topic = useRoomTopic(space);
const avatarMxc = useRoomAvatar(space);
const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined : undefined;
const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
return (
<PageHero

View file

@ -23,6 +23,7 @@ import { SearchResultGroup } from './SearchResultGroup';
import { SearchInput } from './SearchInput';
import { SearchFilters } from './SearchFilters';
import { VirtualTile } from '../../components/virtualizer';
import { PhoneHit } from '../../styles/PhoneHit.css';
const useSearchPathSearchParams = (searchParams: URLSearchParams): _SearchPathSearchParams =>
useMemo(
@ -193,6 +194,7 @@ export function MessageSearch({
<Box direction="Column" gap="700">
<ScrollTopContainer scrollRef={scrollRef} anchorRef={scrollTopAnchorRef}>
<IconButton
className={PhoneHit}
onClick={() => virtualizer.scrollToOffset(0)}
variant="SurfaceVariant"
radii="Pill"

View file

@ -0,0 +1,72 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Switch, Text, color } from 'folds';
import { SettingTile } from '../../components/setting-tile';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getNotificationState, usePermissionState } from '../../hooks/usePermission';
import type { ActivePushConfig } from './config';
import { pushSessionToSW } from '../../../sw-session';
import { disablePush, enablePush, pushIsOn } from './pusher';
export const pushSupported = (): boolean =>
'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
const readyManager = async () => (await navigator.serviceWorker.ready).pushManager;
/** Background notifications through the installed app's service worker. The caller renders it only when the host's config fills the push block and the browser can. */
export function PushNotification({ cfg }: { cfg: ActivePushConfig }) {
const mx = useMatrixClient();
const permission = usePermissionState('notifications', getNotificationState());
const [on, setOn] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>();
useEffect(() => {
readyManager()
.then((m) => pushIsOn(mx, m, cfg))
.then(setOn)
.catch(() => undefined);
}, [cfg, mx]);
const toggle = useCallback(
async (enable: boolean) => {
setBusy(true);
setError(undefined);
try {
if (enable) {
if ((await window.Notification.requestPermission()) !== 'granted') {
throw new Error('Notification permission was not granted.');
}
await enablePush(mx, await readyManager(), cfg, navigator.userAgent.slice(0, 64));
} else {
await disablePush(mx, await readyManager(), cfg);
}
// The worker keeps the token only while a subscription exists: tell it the new state.
pushSessionToSW(mx.baseUrl, mx.getAccessToken() ?? undefined);
setOn(enable);
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not change background notifications.');
} finally {
setBusy(false);
}
},
[cfg, mx]
);
return (
<SettingTile
title="Background Notifications"
description={
error || permission === 'denied' ? (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
{error ?? 'Notification permission is blocked. Allow it from the browser address bar.'}
</Text>
) : (
<span>
Notify when the app is closed. Installing the app to the home screen helps on iOS.
</span>
)
}
after={<Switch disabled={busy || permission === 'denied'} value={on} onChange={toggle} />}
/>
);
}

View file

@ -0,0 +1,24 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { activePushConfig } from './config';
describe('activePushConfig', () => {
it('is off unless all three keys are filled', () => {
expect(activePushConfig(undefined)).toBeUndefined();
expect(activePushConfig({})).toBeUndefined();
expect(
activePushConfig({ vapidPublicKey: 'k', gatewayUrl: 'https://g/_matrix/push/v1/notify' })
).toBeUndefined();
expect(activePushConfig({ vapidPublicKey: 'k', gatewayUrl: 'u', appId: 'a' })).toEqual({
vapidPublicKey: 'k',
gatewayUrl: 'u',
appId: 'a',
});
});
it('the shipped config.json carries the block empty, so push is off', () => {
const cfg = JSON.parse(readFileSync(join(__dirname, '../../../../config.json'), 'utf8'));
expect(cfg.push).toEqual({ vapidPublicKey: '', gatewayUrl: '', appId: '' });
expect(activePushConfig(cfg.push)).toBeUndefined();
});
});

View file

@ -0,0 +1,12 @@
// Web Push is off unless the host's config.json fills all three keys (ships empty). The public key is not a secret;
// the gateway URL is the push gateway's notify endpoint the homeserver calls (`/_matrix/push/v1/notify`).
export type PushConfig = {
vapidPublicKey?: string;
gatewayUrl?: string;
appId?: string;
};
export type ActivePushConfig = Required<PushConfig>;
export const activePushConfig = (c?: PushConfig): ActivePushConfig | undefined =>
c?.vapidPublicKey && c.gatewayUrl && c.appId ? (c as ActivePushConfig) : undefined;

View file

@ -0,0 +1,29 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
const ROOT = join(__dirname, '../../../..');
const manifest = JSON.parse(readFileSync(join(ROOT, 'public/manifest.json'), 'utf8'));
describe('web app manifest', () => {
it('has a stable identity and scope, and no orientation lock', () => {
expect(manifest.id).toBe('/'); // what Chrome already derives from start_url "./" at the site root
expect(manifest.scope).toBe('./');
expect(manifest.start_url).toBe('./');
expect(manifest.display).toBe('standalone');
expect(manifest.orientation).toBeUndefined();
});
it('offers 192 and 512 icons for any use and a 512 maskable one, every file present', () => {
const icons = manifest.icons as { src: string; sizes: string; purpose?: string }[];
const any = icons.filter((i) => !i.purpose);
expect(any.map((i) => i.sizes)).toEqual(expect.arrayContaining(['192x192', '512x512']));
const maskable = icons.filter((i) => i.purpose === 'maskable');
expect(maskable.map((i) => i.sizes)).toEqual(['512x512']);
// the build copies public/res/android to public/android (vite.config.js)
icons.forEach((i) =>
expect(
existsSync(join(ROOT, i.src.replace('./public/android/', 'public/res/android/')))
).toBe(true)
);
});
});

View file

@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest';
import { describePush, GENERIC, parsePush } from './payload';
import { matrixNotify, mockHomeserver, SESSION } from './testkit';
describe('parsePush', () => {
it('reads the Matrix gateway body and the bare inner object', () => {
const a = parsePush(matrixNotify('!r:x', '$e'));
expect(a).toMatchObject({ roomId: '!r:x', eventId: '$e' });
expect(parsePush({ room_id: '!r:x', event_id: '$e' })).toMatchObject({ roomId: '!r:x' });
});
it('rejects a body with no room id', () => {
expect(parsePush({ notification: { event_id: '$e' } })).toBeUndefined();
expect(parsePush(undefined)).toBeUndefined();
expect(parsePush('x')).toBeUndefined();
});
it('carries text the gateway added', () => {
expect(
parsePush({
notification: {
room_id: '!r:x',
room_name: 'Lobby',
sender_display_name: 'Ann',
content: { body: 'hi' },
},
})
).toMatchObject({ roomName: 'Lobby', sender: 'Ann', text: 'hi' });
});
});
describe('describePush', () => {
const ids = { roomId: '!r:x', eventId: '$e' };
const event = {
type: 'm.room.message',
sender: '@ann:x',
content: { msgtype: 'm.text', body: 'hello there' },
};
it('resolves ids through the homeserver with the stored token', async () => {
const hs = mockHomeserver({
token: 'tok-1',
events: { $e: event },
roomNames: { '!r:x': 'Lobby' },
});
expect(await describePush(ids, SESSION, hs.fetchFn)).toEqual({
title: 'Lobby',
body: 'ann: hello there',
});
expect(hs.seen).toHaveLength(2);
expect(
hs.seen.every(
(r) => r.auth === 'Bearer tok-1' && r.url.startsWith('https://matrix.example/_matrix/')
)
).toBe(true);
expect(hs.seen[0].url).toContain('/rooms/!r%3Ax/');
});
it('shows the generic notification with no session, and asks nobody', async () => {
const hs = mockHomeserver({ token: 'tok-1' });
expect(await describePush(ids, undefined, hs.fetchFn)).toEqual({
title: 'Ratatoskr',
body: 'New message',
});
expect(hs.seen).toHaveLength(0);
});
it('falls back when the token is refused, the network is down, or the event is encrypted', async () => {
const stale = mockHomeserver({ token: 'other', events: { $e: event } });
expect(await describePush(ids, SESSION, stale.fetchFn)).toEqual(GENERIC);
const down = mockHomeserver({ token: 'tok-1', failAll: true });
expect(await describePush(ids, SESSION, down.fetchFn)).toEqual(GENERIC);
const enc = mockHomeserver({
token: 'tok-1',
events: {
$e: {
type: 'm.room.encrypted',
sender: '@ann:x',
content: { algorithm: 'm.megolm.v1.aes-sha2' },
},
},
roomNames: { '!r:x': 'Lobby' },
});
expect(await describePush(ids, SESSION, enc.fetchFn)).toEqual({
title: 'Lobby',
body: 'New message',
});
});
it('does not fetch what the push already carried', async () => {
const hs = mockHomeserver({ token: 'tok-1' });
const shown = await describePush(
{ ...ids, roomName: 'R', sender: 'Bo', text: 'yo' },
SESSION,
hs.fetchFn
);
expect(shown).toEqual({ title: 'R', body: 'Bo: yo' });
expect(hs.seen).toHaveLength(0);
});
it('clips a long body', async () => {
const hs = mockHomeserver({
token: 'tok-1',
events: { $e: { ...event, content: { body: 'x'.repeat(500) } } },
});
const shown = await describePush(ids, SESSION, hs.fetchFn);
expect(shown.body.length).toBeLessThanOrEqual(200);
expect(shown.body.endsWith('…')).toBe(true);
});
it('shows the generic notification when the push names no room', async () => {
const hs = mockHomeserver({ token: 'tok-1' });
expect(await describePush(undefined, SESSION, hs.fetchFn)).toEqual(GENERIC);
});
});

View file

@ -0,0 +1,95 @@
import type { SessionRecord } from './sessionStore';
/** What a push names. The homeserver sends ids only (`push.include_content: false`); a gateway may add text. */
export interface PushIds {
roomId: string;
eventId?: string;
roomName?: string;
sender?: string;
text?: string;
}
export interface Shown {
title: string;
body: string;
}
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
/** The Matrix push body is `{ notification: {...} }`; a gateway may forward the inner object. No room id: not ours to show. */
export function parsePush(raw: unknown): PushIds | undefined {
const top = (raw ?? {}) as Record<string, unknown>;
const n = (top.notification ?? top) as Record<string, unknown>;
const roomId = str(n.room_id);
if (!roomId) return undefined;
const content = (n.content ?? {}) as Record<string, unknown>;
return {
roomId,
eventId: str(n.event_id),
roomName: str(n.room_name),
sender: str(n.sender_display_name) ?? str(n.sender),
text: str(content.body),
};
}
export const GENERIC: Shown = { title: 'Ratatoskr', body: 'New message' };
const MAX_BODY = 200;
const clip = (s: string): string => (s.length > MAX_BODY ? `${s.slice(0, MAX_BODY - 1)}…` : s);
const localpart = (userId: string): string => userId.replace(/^@/, '').split(':')[0];
type Fetch = (url: string, init: RequestInit) => Promise<Response>;
async function getJson(
f: Fetch,
session: SessionRecord,
path: string,
timeoutMs: number
): Promise<Record<string, unknown> | undefined> {
try {
const res = await f(`${session.baseUrl.replace(/\/+$/, '')}${path}`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
signal: AbortSignal.timeout(timeoutMs),
});
return res.ok ? ((await res.json()) as Record<string, unknown>) : undefined;
} catch {
return undefined;
}
}
/**
* Title and body for a push. Text the push carried wins. Otherwise, with a session, the event and the room's name are
* fetched (clear-text rooms; an encrypted event shows the generic body). Every failure ends at the generic notification:
* a push must always show something.
*/
export async function describePush(
ids: PushIds | undefined,
session: SessionRecord | undefined,
f: Fetch,
timeoutMs = 4000
): Promise<Shown> {
if (!ids) return GENERIC;
let { roomName, sender, text } = ids;
if (session && (!text || !roomName)) {
const room = encodeURIComponent(ids.roomId);
const [event, name] = await Promise.all([
!text && ids.eventId
? getJson(
f,
session,
`/_matrix/client/v3/rooms/${room}/event/${encodeURIComponent(ids.eventId)}`,
timeoutMs
)
: undefined,
!roomName
? getJson(f, session, `/_matrix/client/v3/rooms/${room}/state/m.room.name`, timeoutMs)
: undefined,
]);
const content = (event?.content ?? {}) as Record<string, unknown>;
if (event?.type === 'm.room.message') text = str(content.body);
if (!sender && str(event?.sender)) sender = localpart(event?.sender as string);
roomName = roomName ?? str(name?.name);
}
let { body } = GENERIC;
if (text) body = sender ? `${sender}: ${text}` : text;
return { title: roomName ?? GENERIC.title, body: clip(body) };
}

View file

@ -0,0 +1,143 @@
import { describe, expect, it } from 'vitest';
import type { IPusherRequest } from 'matrix-js-sdk';
import {
disablePush,
enablePush,
PushManagerPort,
pushIsOn,
vapidKeyBytes,
webPushPusher,
} from './pusher';
const cfg = {
vapidPublicKey: 'BAUD',
gatewayUrl: 'https://matrix.example/_matrix/push/v1/notify',
appId: 'org.gamecult.ratatoskr.web',
};
const key = (...b: number[]) => Uint8Array.from(b).buffer;
function subscription(unsubscribed: string[], serverKey?: ArrayBuffer): PushSubscription {
return {
options: { applicationServerKey: serverKey ?? vapidKeyBytes(cfg.vapidPublicKey).buffer },
endpoint: 'https://push.example/send/abc',
getKey: (n: string) => (n === 'p256dh' ? key(251, 255, 1) : key(250, 2)),
unsubscribe: async () => {
unsubscribed.push('x');
return true;
},
} as unknown as PushSubscription;
}
function world() {
const calls: string[] = [];
const pushers: IPusherRequest[] = [];
let sub: PushSubscription | null = null;
const unsub: string[] = [];
const mx = {
setPusher: async (p: IPusherRequest) => {
calls.push(p.kind === null ? 'delete-pusher' : 'set-pusher');
pushers.push(p);
},
// The account's pushers as the homeserver would list them: what setPusher last wrote, minus deletions.
getPushers: async () => ({
pushers: pushers
.filter(
(p, i) =>
p.kind !== null &&
!pushers.slice(i + 1).some((q) => q.kind === null && q.pushkey === p.pushkey)
)
.map((p) => ({ app_id: p.app_id, pushkey: p.pushkey })),
}),
};
const push: PushManagerPort = {
getSubscription: async () => sub,
subscribe: async (o) => {
calls.push(`subscribe:${o.userVisibleOnly}:${o.applicationServerKey.length}`);
sub = subscription(unsub);
return sub;
},
};
const swapSub = (s: PushSubscription | null) => {
sub = s;
};
return { mx, push, calls, pushers, unsub, swapSub };
}
describe('web push pusher', () => {
it('decodes the URL-safe VAPID key', () => {
expect([...vapidKeyBytes('-_8')]).toEqual([251, 255]);
});
it('is the Sygnal webpush shape: app id, p256dh pushkey, subscription in data, ids only', () => {
const p = webPushPusher(cfg, subscription([]), 'phone') as unknown as Record<
string,
unknown
> & {
data: Record<string, unknown>;
};
expect(p).toMatchObject({
kind: 'http',
app_id: cfg.appId,
pushkey: '-_8B',
device_display_name: 'phone',
append: false,
});
expect(p.data).toMatchObject({
url: cfg.gatewayUrl,
format: 'event_id_only',
endpoint: 'https://push.example/send/abc',
auth: '-gI',
p256dh: '-_8B',
});
});
it('enable subscribes with userVisibleOnly, then registers the pusher; a second enable reuses the subscription', async () => {
const w = world();
await enablePush(w.mx, w.push, cfg, 'phone');
expect(w.calls).toEqual(['subscribe:true:3', 'set-pusher']);
await enablePush(w.mx, w.push, cfg, 'phone');
expect(w.calls).toEqual(['subscribe:true:3', 'set-pusher', 'set-pusher']);
});
it('disable deletes the pusher before the subscription, and does nothing when there is none', async () => {
const w = world();
await disablePush(w.mx, w.push, cfg);
expect(w.calls).toEqual([]);
await enablePush(w.mx, w.push, cfg, 'phone');
await disablePush(w.mx, w.push, cfg);
expect(w.calls.slice(-1)).toEqual(['delete-pusher']);
expect(w.pushers.at(-1)).toMatchObject({ kind: null, app_id: cfg.appId, pushkey: '-_8B' });
expect(w.unsub).toEqual(['x']);
});
it('a failed pusher write leaves the subscription alone', async () => {
const w = world();
await enablePush(w.mx, w.push, cfg, 'phone');
w.mx.setPusher = async () => {
throw new Error('homeserver said no');
};
await expect(disablePush(w.mx, w.push, cfg)).rejects.toThrow('homeserver said no');
expect(w.unsub).toEqual([]);
});
it('the toggle is on only with a subscription and the pusher of this account for it', async () => {
const w = world();
expect(await pushIsOn(w.mx, w.push, cfg)).toBe(false);
await enablePush(w.mx, w.push, cfg, 'phone');
expect(await pushIsOn(w.mx, w.push, cfg)).toBe(true);
// another account on the same browser: the subscription exists, its pusher does not
const other = { ...w.mx, getPushers: async () => ({ pushers: [] }) };
expect(await pushIsOn(other, w.push, cfg)).toBe(false);
await disablePush(w.mx, w.push, cfg);
expect(await pushIsOn(w.mx, w.push, cfg)).toBe(false);
});
it('a subscription made under another VAPID key is replaced, not reused', async () => {
const w = world();
const old = [] as string[];
w.swapSub(subscription(old, Uint8Array.from([9, 9, 9]).buffer));
await enablePush(w.mx, w.push, cfg, 'phone');
expect(old).toEqual(['x']);
expect(w.calls).toEqual(['subscribe:true:3', 'set-pusher']);
});
it('a failed pusher write after subscribing leaves the toggle off', async () => {
const w = world();
const fail = { ...w.mx, setPusher: async () => Promise.reject(new Error('no')) };
await expect(enablePush(fail, w.push, cfg, 'phone')).rejects.toThrow('no');
expect(await pushIsOn(w.mx, w.push, cfg)).toBe(false);
});
});

View file

@ -0,0 +1,107 @@
import type { IPusherRequest } from 'matrix-js-sdk';
import type { ActivePushConfig } from './config';
/** What the page needs of the homeserver client and the browser's push manager, so tests supply fakes. */
export interface PusherClient {
setPusher(p: IPusherRequest): Promise<unknown>;
getPushers(): Promise<{ pushers: { app_id: string; pushkey: string }[] }>;
}
export interface PushManagerPort {
getSubscription(): Promise<PushSubscription | null>;
subscribe(o: {
userVisibleOnly: boolean;
applicationServerKey: Uint8Array;
}): Promise<PushSubscription>;
}
export function vapidKeyBytes(b64url: string): Uint8Array {
const pad = '='.repeat((4 - (b64url.length % 4)) % 4);
const bin = atob((b64url + pad).replace(/-/g, '+').replace(/_/g, '/'));
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
}
const b64url = (buf: ArrayBuffer | null): string =>
btoa(String.fromCharCode(...new Uint8Array(buf ?? new ArrayBuffer(0))))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
/**
* The Matrix pusher for a browser subscription (Sygnal's `webpush` shape: the subscription's keys ride in `data`; the
* pushkey is the subscription's p256dh key, as Element Web does). `event_id_only` asks the homeserver for ids, which
* the worker resolves itself.
*/
export function webPushPusher(
cfg: ActivePushConfig,
sub: PushSubscription,
deviceName: string
): IPusherRequest {
const p256dh = b64url(sub.getKey('p256dh'));
return {
kind: 'http',
app_id: cfg.appId,
pushkey: p256dh,
app_display_name: 'Ratatoskr',
device_display_name: deviceName,
lang: navigator.language || 'en',
data: {
url: cfg.gatewayUrl,
format: 'event_id_only',
endpoint: sub.endpoint,
auth: b64url(sub.getKey('auth')),
p256dh,
},
append: false,
} as IPusherRequest;
}
const sameKey = (sub: PushSubscription, vapid: Uint8Array): boolean => {
const have = sub.options?.applicationServerKey;
return !have || b64url(have) === b64url(vapid.buffer as ArrayBuffer);
};
/** Subscribe (replacing a subscription made under another VAPID key), then register the pusher for this account. */
export async function enablePush(
mx: PusherClient,
push: PushManagerPort,
cfg: ActivePushConfig,
deviceName: string
): Promise<void> {
const key = vapidKeyBytes(cfg.vapidPublicKey);
let sub = await push.getSubscription();
if (sub && !sameKey(sub, key)) {
await sub.unsubscribe();
sub = null;
}
sub = sub ?? (await push.subscribe({ userVisibleOnly: true, applicationServerKey: key }));
await mx.setPusher(webPushPusher(cfg, sub, deviceName));
}
/** On only when this browser holds a subscription AND this account has a pusher for it (the two live in different places). */
export async function pushIsOn(
mx: PusherClient,
push: PushManagerPort,
cfg: ActivePushConfig
): Promise<boolean> {
const sub = await push.getSubscription();
if (!sub) return false;
const pushkey = b64url(sub.getKey('p256dh'));
const { pushers } = await mx.getPushers();
return pushers.some((p) => p.app_id === cfg.appId && p.pushkey === pushkey);
}
/** Delete this account's pusher, then the subscription. The order matters: a pusher with no subscription would keep failing at the gateway. */
export async function disablePush(
mx: PusherClient,
push: PushManagerPort,
cfg: ActivePushConfig
): Promise<void> {
const sub = await push.getSubscription();
if (!sub) return;
await mx.setPusher({
kind: null,
app_id: cfg.appId,
pushkey: b64url(sub.getKey('p256dh')),
} as unknown as IPusherRequest);
await sub.unsubscribe();
}

View file

@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest';
import { idbPort, RecordPort, SessionRecord, sessionStore } from './sessionStore';
const memory = (): RecordPort & { held?: unknown } => {
const m: RecordPort & { held?: unknown } = {
async read() {
return m.held;
},
async write(v) {
m.held = v;
},
};
return m;
};
describe('sessionStore', () => {
it('saves a valid session and loads it back', async () => {
const s = sessionStore(memory());
await s.save('https://hs/', 'tok');
expect(await s.load()).toEqual({ baseUrl: 'https://hs/', accessToken: 'tok' });
});
it('deletes the record on logout, and on anything that is not two strings', async () => {
const port = memory();
const s = sessionStore(port);
await s.save('https://hs/', 'tok');
await s.save(undefined, undefined);
expect(port.held).toBeUndefined();
await s.save('https://hs/', 'tok');
await s.save('https://hs/', 5);
expect(await s.load()).toBeUndefined();
});
it('keeps the token only while a push subscription exists', async () => {
const port = memory();
const s = sessionStore(port);
await s.saveIfSubscribed('https://hs/', 'tok', async () => false);
expect(port.held).toBeUndefined();
await s.saveIfSubscribed('https://hs/', 'tok', async () => true);
expect(await s.load()).toEqual({ baseUrl: 'https://hs/', accessToken: 'tok' });
await s.saveIfSubscribed('https://hs/', 'tok', async () => false);
expect(port.held).toBeUndefined();
await s.saveIfSubscribed('https://hs/', 'tok', async () => true);
await s.saveIfSubscribed('https://hs/', 'tok', () =>
Promise.reject(new Error('no push manager'))
);
expect(port.held).toBeUndefined();
});
it('ignores a record that is not a session', async () => {
const port = memory();
port.held = { baseUrl: 1 };
expect(await sessionStore(port).load()).toBeUndefined();
});
});
// Just enough of IndexedDB for the port: one database, object stores of key -> value, async request callbacks.
function fakeIndexedDB(): IDBFactory {
const dbs = new Map<string, Map<string, Map<string, unknown>>>();
const request = <T>(run: () => T) => {
const r = {} as IDBRequest<T>;
setTimeout(() => {
(r as { result: T }).result = run();
r.onsuccess?.(new Event('success'));
}, 0);
return r;
};
return {
open(name: string) {
const r = {} as IDBOpenDBRequest;
setTimeout(() => {
const fresh = !dbs.has(name);
const stores = dbs.get(name) ?? new Map();
dbs.set(name, stores);
const db = {
createObjectStore: (n: string) => stores.set(n, new Map()),
close: () => undefined,
transaction: (n: string) => {
const tx = {} as IDBTransaction;
const store = stores.get(n) as Map<string, unknown>;
const api = {
get: (k: string) => request(() => store.get(k)),
put: (v: unknown, k: string) => {
store.set(k, v);
setTimeout(() => tx.oncomplete?.(new Event('complete')), 0);
},
delete: (k: string) => {
store.delete(k);
setTimeout(() => tx.oncomplete?.(new Event('complete')), 0);
},
};
(tx as { objectStore: unknown }).objectStore = () => api;
return tx;
},
} as unknown as IDBDatabase;
(r as { result: IDBDatabase }).result = db;
if (fresh) r.onupgradeneeded?.(new Event('upgradeneeded') as IDBVersionChangeEvent);
r.onsuccess?.(new Event('success'));
}, 0);
return r;
},
} as unknown as IDBFactory;
}
describe('idbPort', () => {
it('round-trips the one record through IndexedDB and deletes it', async () => {
const s = sessionStore(idbPort(fakeIndexedDB()));
expect(await s.load()).toBeUndefined();
await s.save('https://hs/', 'tok');
expect(await s.load()).toEqual<SessionRecord>({ baseUrl: 'https://hs/', accessToken: 'tok' });
await s.save(undefined, undefined);
expect(await s.load()).toBeUndefined();
});
});

View file

@ -0,0 +1,88 @@
// The one thing a push needs that a closed app cannot give: the access token, to fetch the event a push names.
// Stored: `{ baseUrl, accessToken }`, one record, in this origin's IndexedDB `ratatoskr-sw` (not reachable by other
// origins; the token is already in the page's own storage). Written when the page hands the worker its session
// while this browser holds a push subscription; deleted when the page hands it none (logout) or the subscription is
// gone. A browser that never turned push on stores nothing. Nothing else reads it but the push handler.
export interface SessionRecord {
baseUrl: string;
accessToken: string;
}
/** The two operations the store needs from a key-value backend, so tests run without IndexedDB. */
export interface RecordPort {
read(): Promise<unknown>;
write(value: SessionRecord | undefined): Promise<void>;
}
const DB = 'ratatoskr-sw';
const STORE = 'session';
const KEY = 'session';
const done = <T>(req: IDBRequest<T>): Promise<T> =>
new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
export function idbPort(factory: IDBFactory): RecordPort {
const open = (): Promise<IDBDatabase> => {
const req = factory.open(DB, 1);
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
return done(req);
};
return {
async read() {
const db = await open();
try {
return await done(db.transaction(STORE).objectStore(STORE).get(KEY));
} finally {
db.close();
}
},
async write(value) {
const db = await open();
try {
const tx = db.transaction(STORE, 'readwrite');
const store = tx.objectStore(STORE);
const finished = new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
if (value) store.put(value, KEY);
else store.delete(KEY);
await finished;
} finally {
db.close();
}
},
};
}
export function sessionStore(port: RecordPort) {
return {
/** A valid session replaces the record; anything else (logout) deletes it. */
async save(baseUrl: unknown, accessToken: unknown): Promise<void> {
await port.write(
typeof baseUrl === 'string' && typeof accessToken === 'string' && baseUrl && accessToken
? { baseUrl, accessToken }
: undefined
);
},
/** The token is kept only while this browser holds a push subscription; without one the record is deleted. */
async saveIfSubscribed(
baseUrl: unknown,
accessToken: unknown,
subscribed: () => Promise<boolean>
): Promise<void> {
const keep = await subscribed().catch(() => false);
await this.save(keep ? baseUrl : undefined, keep ? accessToken : undefined);
},
async load(): Promise<SessionRecord | undefined> {
const v = (await port.read()) as Partial<SessionRecord> | undefined;
return v && typeof v.baseUrl === 'string' && typeof v.accessToken === 'string'
? { baseUrl: v.baseUrl, accessToken: v.accessToken }
: undefined;
},
};
}

View file

@ -0,0 +1,41 @@
import type { SessionRecord } from './sessionStore';
/** A mock homeserver: answers the two reads the push handler makes, and records every request it saw. */
export function mockHomeserver(opts: {
token: string;
events?: Record<string, unknown>;
roomNames?: Record<string, string>;
failAll?: boolean;
}) {
const seen: { url: string; auth: string | null }[] = [];
const fetchFn = async (url: string, init: RequestInit): Promise<Response> => {
const auth = new Headers(init.headers).get('Authorization');
seen.push({ url, auth });
if (opts.failAll) throw new Error('network down');
if (auth !== `Bearer ${opts.token}`) return new Response('{}', { status: 401 });
const ev = /\/rooms\/([^/]+)\/event\/([^/]+)$/.exec(url);
if (ev) {
const e = opts.events?.[decodeURIComponent(ev[2])];
return e ? new Response(JSON.stringify(e)) : new Response('{}', { status: 404 });
}
const nm = /\/rooms\/([^/]+)\/state\/m\.room\.name$/.exec(url);
if (nm) {
const name = opts.roomNames?.[decodeURIComponent(nm[1])];
return name ? new Response(JSON.stringify({ name })) : new Response('{}', { status: 404 });
}
return new Response('{}', { status: 404 });
};
return { fetchFn, seen };
}
export const SESSION: SessionRecord = { baseUrl: 'https://matrix.example/', accessToken: 'tok-1' };
/** The body a homeserver posts to a push gateway for `format: event_id_only` (Matrix push gateway API). */
export const matrixNotify = (roomId: string, eventId: string) => ({
notification: {
event_id: eventId,
room_id: roomId,
counts: { unread: 1 },
devices: [{ app_id: 'org.gamecult.ratatoskr.web', pushkey: 'k', data: {} }],
},
});

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