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
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
156 changed files with 4190 additions and 336 deletions

View file

@ -28,6 +28,11 @@
"fileZoneShare": {
"enabled": false
},
"push": {
"vapidPublicKey": "",
"gatewayUrl": "",
"appId": ""
},
"registration": {
"enabled": false,
"url": ""

View file

@ -47,6 +47,10 @@ handoff states which config differs and the installer decides. From Yggdrasil th
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:

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

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

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

@ -299,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',
@ -309,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

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

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

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

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

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

@ -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: {} }],
},
});

View file

@ -0,0 +1,33 @@
import { useEffect } from 'react';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { OPEN_ROOM, PUSH_EVENT_PARAM, PUSH_ROOM_PARAM } from './worker';
/** Opens the room a push notification named: from the worker's message (app open) or the start URL (app was closed). */
export function usePushOpen(): void {
const { navigateRoom } = useRoomNavigate();
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const roomId = params.get(PUSH_ROOM_PARAM);
if (roomId) {
navigateRoom(roomId, params.get(PUSH_EVENT_PARAM) ?? undefined);
params.delete(PUSH_ROOM_PARAM);
params.delete(PUSH_EVENT_PARAM);
const rest = params.toString();
window.history.replaceState(
null,
'',
`${window.location.pathname}${rest ? `?${rest}` : ''}${window.location.hash}`
);
}
const onMessage = (ev: MessageEvent) => {
if (ev.data?.type === OPEN_ROOM && typeof ev.data.roomId === 'string') {
navigateRoom(
ev.data.roomId,
typeof ev.data.eventId === 'string' ? ev.data.eventId : undefined
);
}
};
navigator.serviceWorker?.addEventListener('message', onMessage);
return () => navigator.serviceWorker?.removeEventListener('message', onMessage);
}, [navigateRoom]);
}

View file

@ -0,0 +1,108 @@
import { describe, expect, it, vi } from 'vitest';
import { handleClick, handlePush, OPEN_ROOM, WorkerPort } from './worker';
import { matrixNotify, mockHomeserver, SESSION } from './testkit';
type Shown = { title: string; options: NotificationOptions & { renotify?: boolean } };
function port(over: Partial<WorkerPort> = {}) {
const shown: Shown[] = [];
const opened: string[] = [];
const w: WorkerPort = {
windows: async () => [],
openWindow: async (u) => {
opened.push(u);
},
show: async (title, options) => {
shown.push({ title, options });
},
loadSession: async () => SESSION,
fetch: mockHomeserver({ token: 'tok-1' }).fetchFn,
scope: 'https://chat.example/',
...over,
};
return { w, shown, opened };
}
const msg = {
type: 'm.room.message',
sender: '@ann:x',
content: { msgtype: 'm.text', body: 'hello' },
};
describe('push against a mock gateway and homeserver', () => {
it('a push the homeserver sent for event_id_only becomes a notification naming room, sender and text', async () => {
const hs = mockHomeserver({
token: 'tok-1',
events: { $e: msg },
roomNames: { '!r:x': 'Lobby' },
});
const { w, shown } = port({ fetch: hs.fetchFn });
await handlePush(matrixNotify('!r:x', '$e'), w);
expect(shown).toHaveLength(1);
expect(shown[0].title).toBe('Lobby');
expect(shown[0].options.body).toBe('ann: hello');
expect(shown[0].options.tag).toBe('!r:x');
expect(shown[0].options.renotify).toBe(true);
expect(shown[0].options.data).toEqual({ roomId: '!r:x', eventId: '$e' });
});
it('with no stored session it still shows (a push must always show) and sends the token nowhere', async () => {
const hs = mockHomeserver({ token: 'tok-1', events: { $e: msg } });
const { w, shown } = port({ fetch: hs.fetchFn, loadSession: async () => undefined });
await handlePush(matrixNotify('!r:x', '$e'), w);
expect(shown[0]).toMatchObject({ title: 'Ratatoskr', options: { body: 'New message' } });
expect(hs.seen).toHaveLength(0);
});
it('shows the generic notification for a body it cannot read', async () => {
const { w, shown } = port();
await handlePush(undefined, w);
expect(shown[0].title).toBe('Ratatoskr');
expect(shown[0].options.tag).toBeUndefined();
expect(shown[0].options.data).toBeUndefined();
});
it('shows even when the window list cannot be read', async () => {
const { w, shown } = port({ windows: () => Promise.reject(new Error('no clients')) });
await handlePush(matrixNotify('!r:x', '$e'), w);
expect(shown).toHaveLength(1);
});
it('shows nothing while a window is visible and focused, and shows when the window is hidden', async () => {
const win = (visibilityState: string, focused: boolean) => ({
visibilityState,
focused,
focus: async () => undefined,
postMessage: () => undefined,
});
const a = port({ windows: async () => [win('visible', true)] });
await handlePush(matrixNotify('!r:x', '$e'), a.w);
expect(a.shown).toHaveLength(0);
const b = port({ windows: async () => [win('hidden', false), win('visible', false)] });
await handlePush(matrixNotify('!r:x', '$e'), b.w);
expect(b.shown).toHaveLength(1);
});
});
describe('notification click', () => {
it('focuses the open window and tells it the room', async () => {
const focus = vi.fn(async () => undefined);
const postMessage = vi.fn();
const { w, opened } = port({
windows: async () => [{ visibilityState: 'hidden', focused: false, focus, postMessage }],
});
await handleClick({ roomId: '!r:x', eventId: '$e' }, w);
expect(focus).toHaveBeenCalled();
expect(postMessage).toHaveBeenCalledWith({ type: OPEN_ROOM, roomId: '!r:x', eventId: '$e' });
expect(opened).toHaveLength(0);
});
it('with no window, starts the app with the room in the URL', async () => {
const { w, opened } = port();
await handleClick({ roomId: '!r:x', eventId: '$e' }, w);
const u = new URL(opened[0]);
expect(u.origin + u.pathname).toBe('https://chat.example/');
expect(u.searchParams.get('pushRoom')).toBe('!r:x');
expect(u.searchParams.get('pushEvent')).toBe('$e');
});
it('with no window and no target, just opens the app', async () => {
const { w, opened } = port();
await handleClick(undefined, w);
expect(opened).toEqual(['https://chat.example/']);
});
});

View file

@ -0,0 +1,62 @@
import { describePush, parsePush } from './payload';
import type { SessionRecord } from './sessionStore';
/** The slice of the service worker's scope the handlers use, so they run in tests without a worker. */
export interface WorkerPort {
windows(): Promise<
{
visibilityState: string;
focused: boolean;
focus(): Promise<unknown>;
postMessage(m: unknown): void;
}[]
>;
openWindow(url: string): Promise<unknown>;
show(title: string, options: NotificationOptions): Promise<void>;
loadSession(): Promise<SessionRecord | undefined>;
fetch(url: string, init: RequestInit): Promise<Response>;
/** The worker's scope URL: where a cold click opens the app. */
scope: string;
}
export interface PushTarget {
roomId: string;
eventId?: string;
}
/** A push shows a notification, unless the app is open and focused (browsers allow that, and the page notifies itself). */
export async function handlePush(raw: unknown, w: WorkerPort): Promise<void> {
const ids = parsePush(raw);
const open = await w.windows().catch(() => []);
if (open.some((c) => c.visibilityState === 'visible' && c.focused)) return;
const shown = await describePush(ids, await w.loadSession(), w.fetch);
await w.show(shown.title, {
body: shown.body,
// One notification per room, replaced (and re-alerted) by the next message there.
tag: ids?.roomId,
renotify: !!ids?.roomId,
icon: './public/android/android-chrome-192x192.png',
data: ids ? ({ roomId: ids.roomId, eventId: ids.eventId } satisfies PushTarget) : undefined,
} as NotificationOptions);
}
export const OPEN_ROOM = 'openRoom';
export const PUSH_ROOM_PARAM = 'pushRoom';
export const PUSH_EVENT_PARAM = 'pushEvent';
/** A click focuses an open window and tells it which room; with none open it starts the app with the room in the URL. */
export async function handleClick(target: PushTarget | undefined, w: WorkerPort): Promise<void> {
const windows = await w.windows();
const open = windows[0];
if (open) {
await open.focus();
if (target?.roomId) open.postMessage({ type: OPEN_ROOM, ...target });
return;
}
const url = new URL(w.scope);
if (target?.roomId) {
url.searchParams.set(PUSH_ROOM_PARAM, target.roomId);
if (target.eventId) url.searchParams.set(PUSH_EVENT_PARAM, target.eventId);
}
await w.openWindow(url.href);
}

View file

@ -66,6 +66,7 @@ import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
import { livekitSupport } from '../../hooks/useLivekitSupport';
import { StateEvent } from '../../../types/matrix/room';
import { webRTCSupported } from '../../utils/rtc';
import { PhoneHitSpread24 } from '../../styles/PhoneHit.css';
type RoomNavItemMenuProps = {
room: Room;
@ -229,6 +230,7 @@ function CallChatToggle() {
return (
<IconButton
className={PhoneHitSpread24}
onClick={() => setChat(!chat)}
aria-pressed={chat}
aria-label="Toggle Chat"
@ -436,6 +438,7 @@ export function RoomNavItem({
}
>
<IconButton
className={PhoneHitSpread24}
onClick={handleOpenMenu}
aria-pressed={!!menuAnchor}
aria-controls={`menu-${room.roomId}`}

View file

@ -17,6 +17,7 @@ import { Permissions } from './permissions';
import { RoomSettingsPage } from '../../state/roomSettings';
import { useRoom } from '../../hooks/useRoom';
import { DeveloperTools } from '../common-settings/developer-tools';
import { PhoneHit } from '../../styles/PhoneHit.css';
type RoomSettingsMenuItem = {
page: RoomSettingsPage;
@ -117,7 +118,7 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
</Box>
<Box shrink="No">
{screenSize === ScreenSize.Mobile && (
<IconButton onClick={requestClose} variant="Background">
<IconButton className={PhoneHit} onClick={requestClose} variant="Background">
<Icon src={Icons.Cross} />
</IconButton>
)}

View file

@ -15,6 +15,7 @@ import {
} from '../../common-settings/general';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type GeneralProps = {
requestClose: () => void;
@ -35,7 +36,7 @@ export function General({ requestClose }: GeneralProps) {
</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

@ -9,6 +9,7 @@ import { usePermissionGroups } from './usePermissionItems';
import { PermissionGroups, Powers, PowersEditor } from '../../common-settings/permissions';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type PermissionsProps = {
requestClose: () => void;
@ -45,7 +46,7 @@ export function Permissions({ requestClose }: PermissionsProps) {
</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

@ -6,6 +6,7 @@ import { Page, PageHeader } from '../../components/page';
import { callChatAtom } from '../../state/callEmbed';
import { RoomView } from './RoomView';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { PhoneHit } from '../../styles/PhoneHit.css';
export function CallChatView() {
const { eventId } = useParams();
@ -41,7 +42,12 @@ export function CallChatView() {
}
>
{(triggerRef) => (
<IconButton ref={triggerRef} variant="Surface" onClick={handleClose}>
<IconButton
className={PhoneHit}
ref={triggerRef}
variant="Surface"
onClick={handleClose}
>
<Icon src={Icons.Cross} />
</IconButton>
)}

View file

@ -59,6 +59,7 @@ import { useSpaceOptionally } from '../../hooks/useSpace';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { PhoneHit } from '../../styles/PhoneHit.css';
type MemberDrawerHeaderProps = {
room: Room;
@ -87,6 +88,7 @@ function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) {
>
{(triggerRef) => (
<IconButton
className={PhoneHit}
ref={triggerRef}
variant="Background"
onClick={() => setPeopleDrawer(false)}
@ -359,6 +361,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
<ScrollTopContainer scrollRef={scrollRef} anchorRef={scrollTopAnchorRef}>
<IconButton
className={PhoneHit}
onClick={() => virtualizer.scrollToOffset(0)}
variant="Surface"
radii="Pill"

View file

@ -28,6 +28,9 @@ import {
config,
toRem,
} from 'folds';
import { RoomOutbox } from './RoomOutbox';
import { sendTracked } from '../../state/room/outboxSend';
import { clearDraft, loadDraft, saveDraft } from '../../state/room/composerStore';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import {
@ -113,6 +116,7 @@ import { useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { useComposingCheck } from '../../hooks/useComposingCheck';
import { PhoneHitSpread24, PhoneHitSpread32 } from '../../styles/PhoneHit.css';
interface RoomInputProps {
editor: Editor;
@ -221,22 +225,66 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
useCallback((width) => setHideStickerBtn(width < 500), [])
);
const userId = mx.getUserId() ?? '';
const draftSaveTimer = useRef<ReturnType<typeof setTimeout>>();
// The atom is only a cache: after a reload it is empty and the stored draft is the truth.
useEffect(() => {
if (msgDraft.length === 0) {
const stored = loadDraft(userId, roomId);
if (stored.length > 0) setMsgDraft(stored);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId, roomId]);
useEffect(() => {
Transforms.insertFragment(editor, msgDraft);
}, [editor, msgDraft]);
const persistDraft = useCallback(() => {
clearTimeout(draftSaveTimer.current);
draftSaveTimer.current = undefined;
saveDraft(userId, roomId, editor.children);
}, [userId, roomId, editor]);
// Write on input (debounced), and flush when the page is hidden or unloading, which is
// all a mobile reload gives us.
const handleEditorChange = useCallback(() => {
clearTimeout(draftSaveTimer.current);
draftSaveTimer.current = setTimeout(persistDraft, 400);
}, [persistDraft]);
useEffect(() => {
const flush = () => {
if (draftSaveTimer.current !== undefined) persistDraft();
};
const onVisibility = () => {
if (document.visibilityState === 'hidden') flush();
};
window.addEventListener('pagehide', flush);
document.addEventListener('visibilitychange', onVisibility);
return () => {
window.removeEventListener('pagehide', flush);
document.removeEventListener('visibilitychange', onVisibility);
};
}, [persistDraft]);
useEffect(
() => () => {
clearTimeout(draftSaveTimer.current);
draftSaveTimer.current = undefined;
if (!isEmptyEditor(editor)) {
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
setMsgDraft(parsedDraft);
saveDraft(userId, roomId, parsedDraft);
} else {
setMsgDraft([]);
clearDraft(userId, roomId);
}
resetEditor(editor);
resetEditorHistory(editor);
},
[roomId, editor, setMsgDraft]
[roomId, editor, setMsgDraft, userId]
);
const handleFileMetadata = useCallback(
@ -287,7 +335,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
});
handleCancelUpload(uploads);
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
contents.forEach((content) => mx.sendMessage(roomId, content as any));
contents.forEach((content) => sendTracked(mx, roomId, content as Record<string, unknown>));
};
const submit = useCallback(() => {
@ -366,12 +414,25 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
content['m.relates_to'].is_falling_back = false;
}
}
mx.sendMessage(roomId, content as any);
sendTracked(mx, roomId, content);
clearTimeout(draftSaveTimer.current);
draftSaveTimer.current = undefined;
clearDraft(userId, roomId);
resetEditor(editor);
resetEditorHistory(editor);
setReplyDraft(undefined);
sendTypingStatus(false);
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
}, [
mx,
roomId,
userId,
editor,
replyDraft,
sendTypingStatus,
setReplyDraft,
isMarkdown,
commands,
]);
const handleKeyDown: KeyboardEventHandler = useCallback(
(evt) => {
@ -433,11 +494,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
await getImageUrlBlob(stickerUrl)
);
mx.sendEvent(roomId, EventType.Sticker, {
body: label,
url: mxc,
info,
});
sendTracked(mx, roomId, { body: label, url: mxc, info }, EventType.Sticker);
};
return (
@ -537,47 +594,53 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onPaste={handlePaste}
onChange={handleEditorChange}
top={
replyDraft && (
<div>
<Box
alignItems="Center"
gap="300"
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
>
<IconButton
onClick={() => setReplyDraft(undefined)}
variant="SurfaceVariant"
size="300"
radii="300"
<>
<RoomOutbox mx={mx} roomId={roomId} />
{replyDraft && (
<div>
<Box
alignItems="Center"
gap="300"
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
>
<Icon src={Icons.Cross} size="50" />
</IconButton>
<Box direction="Row" gap="200" alignItems="Center">
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
<ReplyLayout
userColor={replyUsernameColor}
username={
<Text size="T300" truncate>
<b>
{getMemberDisplayName(room, replyDraft.userId) ??
getMxIdLocalPart(replyDraft.userId) ??
replyDraft.userId}
</b>
</Text>
}
<IconButton
className={PhoneHitSpread24}
onClick={() => setReplyDraft(undefined)}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Text size="T300" truncate>
{trimReplyFromBody(replyDraft.body)}
</Text>
</ReplyLayout>
<Icon src={Icons.Cross} size="50" />
</IconButton>
<Box direction="Row" gap="200" alignItems="Center">
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
<ReplyLayout
userColor={replyUsernameColor}
username={
<Text size="T300" truncate>
<b>
{getMemberDisplayName(room, replyDraft.userId) ??
getMxIdLocalPart(replyDraft.userId) ??
replyDraft.userId}
</b>
</Text>
}
>
<Text size="T300" truncate>
{trimReplyFromBody(replyDraft.body)}
</Text>
</ReplyLayout>
</Box>
</Box>
</Box>
</div>
)
</div>
)}
</>
}
before={
<IconButton
className={PhoneHitSpread32}
onClick={() => pickFile('*')}
variant="SurfaceVariant"
size="300"
@ -589,6 +652,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
after={
<>
<IconButton
className={PhoneHitSpread32}
variant="SurfaceVariant"
size="300"
radii="300"
@ -631,6 +695,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
>
{!hideStickerBtn && (
<IconButton
className={PhoneHitSpread32}
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
variant="SurfaceVariant"
@ -644,6 +709,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</IconButton>
)}
<IconButton
className={PhoneHitSpread32}
ref={emojiBtnRef}
aria-pressed={
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
@ -663,7 +729,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</PopOut>
)}
</UseStateProvider>
<IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300">
<IconButton
className={PhoneHitSpread32}
onClick={submit}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.Send} />
</IconButton>
</>

View file

@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { enqueueOutbox, failOutbox, loadOutbox } from '../../state/room/composerStore';
import { RoomOutbox } from './RoomOutbox';
// The component's hooks are held still (state is its initial value, effects do not run) so the element tree
// it returns can be read and its click handlers called without a DOM.
vi.mock('react', async (importOriginal) => {
const actual = await importOriginal<typeof import('react')>();
const useState = (init: unknown) => [
typeof init === 'function' ? (init as () => unknown)() : init,
() => undefined,
];
return {
...actual,
default: { ...actual, useState, useEffect: () => undefined },
useState,
useEffect: () => undefined,
};
});
type El = { props?: { children?: unknown; onClick?: () => void } };
const flatten = (node: unknown): El[] => {
if (Array.isArray(node)) return node.flatMap(flatten);
if (node && typeof node === 'object') {
const el = node as El;
return [el, ...flatten(el.props?.children)];
}
return [];
};
const textOf = (node: unknown): string => {
if (typeof node === 'string' || typeof node === 'number') return String(node);
if (Array.isArray(node)) return node.map(textOf).join('');
if (node && typeof node === 'object') return textOf((node as El).props?.children);
return '';
};
/** The onClick of the chip whose label is `label`. */
const clickOf = (tree: unknown, label: string) => {
const chip = flatten(tree).find((e) => e.props?.onClick && textOf(e) === label);
if (!chip?.props?.onClick) throw new Error(`no ${label} chip`);
return chip.props.onClick;
};
describe('RoomOutbox actions', () => {
beforeEach(() => {
const m = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (k: string) => m.get(k) ?? null,
setItem: (k: string, v: string) => {
m.set(k, v);
},
removeItem: (k: string) => {
m.delete(k);
},
});
enqueueOutbox('@a:x', { txnId: 'keep', roomId: '!r:x', content: { body: 'stay' } });
enqueueOutbox('@a:x', { txnId: 'bad', roomId: '!r:x', content: { body: 'did not go' } });
failOutbox('@a:x', 'bad', 'ECONNRESET');
});
afterEach(() => {
vi.unstubAllGlobals();
});
const mount = () => {
const mx = {
getUserId: () => '@a:x',
makeTxnId: () => 'txn-new',
getRoom: () => undefined,
sendEvent: vi.fn().mockResolvedValue({ event_id: '$e' }),
};
const tree = (RoomOutbox as unknown as (p: unknown) => unknown)({ mx, roomId: '!r:x' });
return { mx, tree };
};
it('Discard removes that message from the outbox and no other', () => {
const { mx, tree } = mount();
// both entries are shown; the failed one is the one with Discard
clickOf(tree, 'Discard')();
expect(loadOutbox('@a:x').map((e) => e.txnId)).toEqual(['keep']);
expect(mx.sendEvent).not.toHaveBeenCalled();
});
it('Resend sends that message again and does not discard it first', async () => {
const { mx, tree } = mount();
// the server has not answered yet: the resend is in flight
mx.sendEvent.mockReturnValue(
new Promise<never>(() => {
// never settles
})
);
clickOf(tree, 'Resend')();
await Promise.resolve();
expect(mx.sendEvent).toHaveBeenCalledTimes(1);
expect(mx.sendEvent).toHaveBeenCalledWith(
'!r:x',
'm.room.message',
{ body: 'did not go' },
'bad'
);
// still in the outbox, pending again under the same id, until the server confirms it
expect(loadOutbox('@a:x').find((e) => e.txnId === 'bad')).toMatchObject({ status: 'pending' });
});
});

View file

@ -0,0 +1,64 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { enqueueOutbox, failOutbox } from '../../state/room/composerStore';
import { RoomOutbox } from './RoomOutbox';
// What the person sees under the timeline for sends the server has not confirmed.
const text = (html: string) => html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ');
describe('RoomOutbox as rendered', () => {
beforeEach(() => {
const m = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (k: string) => m.get(k) ?? null,
setItem: (k: string, v: string) => {
m.set(k, v);
},
removeItem: (k: string) => {
m.delete(k);
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
const mx = { getUserId: () => '@a:x' } as never;
const entry = (txnId: string, roomId: string, body: string) => ({
txnId,
roomId,
content: { body },
});
it('renders nothing when the room has no unconfirmed sends', () => {
enqueueOutbox('@a:x', entry('t1', '!other:x', 'elsewhere'));
expect(renderToStaticMarkup(<RoomOutbox mx={mx} roomId="!r:x" />)).toBe('');
});
it('shows a fresh send as Sending with its text and no Resend', () => {
enqueueOutbox('@a:x', entry('t1', '!r:x', 'on its way'), Date.now() + 1000);
const html = renderToStaticMarkup(<RoomOutbox mx={mx} roomId="!r:x" />);
expect(html).toContain('data-outbox-status="pending"');
expect(text(html)).toContain('Sending…');
expect(text(html)).toContain('on its way');
expect(text(html)).not.toContain('Resend');
});
it('shows a failed send with its error, Resend and Discard', () => {
enqueueOutbox('@a:x', entry('t2', '!r:x', 'did not go'));
failOutbox('@a:x', 't2', 'ECONNRESET');
const html = renderToStaticMarkup(<RoomOutbox mx={mx} roomId="!r:x" />);
expect(html).toContain('data-outbox-status="failed"');
expect(text(html)).toContain('Not sent: ECONNRESET');
expect(text(html)).toContain('Resend');
expect(text(html)).toContain('Discard');
});
it('shows a send left over from before this page load as interrupted, resendable', () => {
enqueueOutbox('@a:x', entry('t3', '!r:x', 'lost to a reload'), 5);
const html = renderToStaticMarkup(<RoomOutbox mx={mx} roomId="!r:x" />);
expect(text(html)).toContain('Interrupted by a reload');
expect(text(html)).toContain('Resend');
});
});

View file

@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { SLOW_SEND_MS, outboxView } from './RoomOutbox';
describe('outboxView', () => {
it('walks sending -> still sending (resendable) -> failed', () => {
expect(outboxView({ status: 'pending', ts: 100 }, 100, 0)).toMatchObject({
label: 'Sending…',
canResend: false,
});
expect(outboxView({ status: 'pending', ts: 100 }, 100 + SLOW_SEND_MS, 0)).toMatchObject({
label: expect.stringMatching(/^Still sending/),
canResend: true,
});
expect(outboxView({ status: 'failed', ts: 100, error: 'x' }, 100, 0)).toMatchObject({
failed: true,
canResend: true,
});
});
it('offers Resend to a pending send at 30 s and not a millisecond before', () => {
const at = (ms: number) => outboxView({ status: 'pending', ts: 100 }, 100 + ms, 0).canResend;
expect([at(29_999), at(30_000)]).toEqual([false, true]);
});
it('an entry older than this page load reads as interrupted', () => {
expect(outboxView({ status: 'pending', ts: 5 }, 6, 10).label).toMatch(/Interrupted/);
});
});

View file

@ -0,0 +1,121 @@
import React, { useEffect, useState } from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import { Box, Chip, Text, color, config } from 'folds';
import {
OutboxEntry,
loadOutbox,
removeOutbox,
subscribeOutbox,
} from '../../state/room/composerStore';
import { PAGE_START, resendTracked } from '../../state/room/outboxSend';
/** A send still unconfirmed after this long is offered Resend: the request may be hung. */
export const SLOW_SEND_MS = 30_000;
export type OutboxView = { label: string; failed: boolean; canResend: boolean };
export const outboxView = (
entry: Pick<OutboxEntry, 'status' | 'ts' | 'error'>,
now: number,
since: number = PAGE_START
): OutboxView => {
if (entry.status === 'failed') {
return {
label: `Not sent${entry.error ? `: ${entry.error}` : ''}`,
failed: true,
canResend: true,
};
}
if (entry.ts < since) {
return {
label: 'Interrupted by a reload: may not have been sent',
failed: false,
canResend: true,
};
}
if (now - entry.ts >= SLOW_SEND_MS) {
return {
label: 'Still sending: slow connection (Resend may duplicate it if it lands later)',
failed: false,
canResend: true,
};
}
return { label: 'Sending…', failed: false, canResend: false };
};
export const useRoomOutbox = (userId: string, roomId: string): OutboxEntry[] => {
const read = () => loadOutbox(userId).filter((e) => e.roomId === roomId);
const [entries, setEntries] = useState(read);
useEffect(() => {
setEntries(read());
return subscribeOutbox(() => setEntries(read()));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId, roomId]);
return entries;
};
/** Re-render every few seconds while anything is waiting, so a slow send turns resendable. */
const useNow = (active: boolean): number => {
const [now, setNow] = useState(Date.now());
useEffect(() => {
if (!active) return undefined;
setNow(Date.now());
const id = setInterval(() => setNow(Date.now()), 5000);
return () => clearInterval(id);
}, [active]);
return now;
};
type RoomOutboxProps = { mx: MatrixClient; roomId: string };
/**
* Messages this client sent that the server has not confirmed: sending, slow, interrupted by a
* reload, or failed, each with Resend where one makes sense and Discard.
*/
export function RoomOutbox({ mx, roomId }: RoomOutboxProps) {
const userId = mx.getUserId() ?? '';
const entries = useRoomOutbox(userId, roomId);
const now = useNow(entries.length > 0);
if (entries.length === 0) return null;
return (
<Box
direction="Column"
gap="100"
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
>
{entries.map((entry) => {
const view = outboxView(entry, now);
return (
<Box key={entry.txnId} alignItems="Center" gap="200" data-outbox-status={entry.status}>
<Box grow="Yes" direction="Column">
<Text
size="T200"
style={{ color: view.failed ? color.Critical.Main : color.Surface.OnContainer }}
>
{view.label}
</Text>
<Text size="T300" truncate>
{String(entry.content.body ?? '')}
</Text>
</Box>
{view.canResend && (
<>
<Chip variant="Surface" radii="Pill" onClick={() => resendTracked(mx, entry)}>
<Text size="B300">Resend</Text>
</Chip>
<Chip
variant="Surface"
radii="Pill"
onClick={() => removeOutbox(userId, entry.txnId)}
>
<Text size="B300">Discard</Text>
</Chip>
</>
)}
</Box>
);
})}
</Box>
);
}

View file

@ -47,6 +47,7 @@ import {
import { isKeyHotkey } from 'is-hotkey';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { useTranslation } from 'react-i18next';
import { isUnconfirmedLocalEcho } from './localEcho';
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
@ -389,6 +390,19 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void)
}, [room, onArrive]);
};
/** A local echo changes status (or becomes its remote echo) without a Timeline event: redraw. */
const useLocalEchoRefresh = (room: Room, onUpdate: () => void) => {
useEffect(() => {
const handle: RoomEventHandlerMap[RoomEvent.LocalEchoUpdated] = (_evt, r) => {
if (r.roomId === room.roomId) onUpdate();
};
room.on(RoomEvent.LocalEchoUpdated, handle);
return () => {
room.removeListener(RoomEvent.LocalEchoUpdated, handle);
};
}, [room, onUpdate]);
};
const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => {
useEffect(() => {
const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r) => {
@ -648,6 +662,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
)
);
useLocalEchoRefresh(
room,
useCallback(() => {
// the echo was hidden while unconfirmed, so its arrival scrolled to nothing: scroll now
if (atBottomRef.current) {
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = true;
}
setTimeline((ct) => ({ ...ct }));
}, [])
);
const handleOpenEvent = useCallback(
async (
evtId: string,
@ -1646,6 +1672,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
if (mEvent.isRedacted() && !showHiddenEvents) {
return null;
}
if (isUnconfirmedLocalEcho(mEvent)) return null;
if (!newDivider && readUptoEventIdRef.current) {
newDivider = prevEvent?.getId() === readUptoEventIdRef.current;

View file

@ -72,6 +72,7 @@ import { RoomSettingsPage } from '../../state/roomSettings';
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc';
import { PhoneHitSpread40 } from '../../styles/PhoneHit.css';
type RoomMenuProps = {
room: Room;
@ -336,6 +337,7 @@ function CallButton() {
>
{(triggerRef) => (
<IconButton
className={PhoneHitSpread40}
variant="Surface"
fill="None"
ref={triggerRef}
@ -455,7 +457,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
<BackRouteHandler>
{(onBack) => (
<Box shrink="No" alignItems="Center">
<IconButton fill="None" onClick={onBack}>
<IconButton className={PhoneHitSpread40} fill="None" onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
</Box>
@ -531,7 +533,12 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
}
>
{(triggerRef) => (
<IconButton fill="None" ref={triggerRef} onClick={handleSearchClick}>
<IconButton
className={PhoneHitSpread40}
fill="None"
ref={triggerRef}
onClick={handleSearchClick}
>
<Icon size="400" src={Icons.Search} />
</IconButton>
)}
@ -548,6 +555,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
>
{(triggerRef) => (
<IconButton
className={PhoneHitSpread40}
fill="None"
style={{ position: 'relative' }}
onClick={handleOpenPinMenu}
@ -612,7 +620,12 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
}
>
{(triggerRef) => (
<IconButton fill="None" ref={triggerRef} onClick={handleMemberToggle}>
<IconButton
className={PhoneHitSpread40}
fill="None"
ref={triggerRef}
onClick={handleMemberToggle}
>
<Icon size="400" src={Icons.User} />
</IconButton>
)}
@ -631,6 +644,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
>
{(triggerRef) => (
<IconButton
className={PhoneHitSpread40}
fill="None"
onClick={handleOpenMenu}
ref={triggerRef}

View file

@ -10,6 +10,7 @@ import { getMxIdLocalPart } from '../../utils/matrix';
import * as css from './RoomViewTyping.css';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
import { PhoneHit } from '../../styles/PhoneHit.css';
export type RoomViewTypingProps = {
room: Room;
@ -111,7 +112,13 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
</>
)}
</Text>
<IconButton title="Drop Typing Status" size="300" radii="Pill" onClick={handleDropAll}>
<IconButton
className={PhoneHit}
title="Drop Typing Status"
size="300"
radii="Pill"
onClick={handleDropAll}
>
<Icon size="50" src={Icons.Cross} />
</IconButton>
</Box>

View file

@ -31,6 +31,7 @@ import { getToday, getYesterday, timeDayMonthYear, timeHourMinute } from '../../
import { DatePicker, TimePicker } from '../../../components/time-date';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type JumpToTimeProps = {
onCancel: () => void;
@ -108,7 +109,7 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
<Box grow="Yes">
<Text size="H4">Jump to Time</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

@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { EventStatus } from 'matrix-js-sdk';
import { isUnconfirmedLocalEcho } from './localEcho';
describe('isUnconfirmedLocalEcho', () => {
it.each([
EventStatus.ENCRYPTING,
EventStatus.QUEUED,
EventStatus.SENDING,
EventStatus.NOT_SENT,
EventStatus.CANCELLED,
])('hides %s', (status) => {
expect(isUnconfirmedLocalEcho({ status })).toBe(true);
});
it('shows a server-confirmed event and one the server has accepted (SENT)', () => {
expect(isUnconfirmedLocalEcho({ status: null })).toBe(false);
expect(isUnconfirmedLocalEcho({ status: EventStatus.SENT })).toBe(false);
});
});

View file

@ -0,0 +1,10 @@
import { EventStatus, MatrixEvent } from 'matrix-js-sdk';
/**
* A local echo the server has not accepted: the SDK adds it to the live timeline the moment
* sendMessage is called, marks it NOT_SENT on failure, and drops it on reload. Rendered like
* any message it looks delivered and then vanishes. The outbox (RoomOutbox) is the one place
* that shows unconfirmed sends, so the timeline hides these until the status is SENT.
*/
export const isUnconfirmedLocalEcho = (mEvent: Pick<MatrixEvent, 'status'>): boolean =>
mEvent.status !== null && mEvent.status !== undefined && mEvent.status !== EventStatus.SENT;

View file

@ -86,6 +86,7 @@ import colorMXID from '../../../../util/colorMXID';
import { useSkyInk } from '../../../hooks/useTheme';
import { userColorInk } from '../../scene/skyInk';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
import { PhoneHitRoom26, PhoneHitSpread32 } from '../../../styles/PhoneHit.css';
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
@ -111,7 +112,7 @@ export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
{recentEmojis.map((emoji) => (
<IconButton
key={emoji.unicode}
className={css.MessageQuickReaction}
className={classNames(css.MessageQuickReaction, PhoneHitSpread32)}
size="300"
variant="SurfaceVariant"
radii="Pill"
@ -459,7 +460,12 @@ export const MessageDeleteItem = as<
<Box grow="Yes">
<Text size="H4">Delete Message</Text>
</Box>
<IconButton size="300" onClick={handleClose} radii="300">
<IconButton
className={PhoneHitSpread32}
size="300"
onClick={handleClose}
radii="300"
>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@ -589,7 +595,12 @@ export const MessageReportItem = as<
<Box grow="Yes">
<Text size="H4">Report Message</Text>
</Box>
<IconButton size="300" onClick={handleClose} radii="300">
<IconButton
className={PhoneHitSpread32}
size="300"
onClick={handleClose}
radii="300"
>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@ -950,6 +961,7 @@ export const Message = as<'div', MessageProps>(
}
>
<IconButton
className={PhoneHitRoom26}
onClick={handleOpenEmojiBoard}
variant="SurfaceVariant"
size="300"
@ -961,6 +973,7 @@ export const Message = as<'div', MessageProps>(
</PopOut>
)}
<IconButton
className={PhoneHitRoom26}
onClick={onReplyClick}
data-event-id={mEvent.getId()}
variant="SurfaceVariant"
@ -971,6 +984,7 @@ export const Message = as<'div', MessageProps>(
</IconButton>
{!isThreadedMessage && (
<IconButton
className={PhoneHitRoom26}
onClick={(ev) => onReplyClick(ev, true)}
data-event-id={mEvent.getId()}
variant="SurfaceVariant"
@ -982,6 +996,7 @@ export const Message = as<'div', MessageProps>(
)}
{canEditEvent(mx, mEvent) && onEditId && (
<IconButton
className={PhoneHitRoom26}
onClick={() => onEditId(mEvent.getId())}
variant="SurfaceVariant"
size="300"
@ -1147,6 +1162,7 @@ export const Message = as<'div', MessageProps>(
}
>
<IconButton
className={PhoneHitRoom26}
variant="SurfaceVariant"
size="300"
radii="300"
@ -1320,6 +1336,7 @@ export const Event = as<'div', EventProps>(
}
>
<IconButton
className={PhoneHitRoom26}
variant="SurfaceVariant"
size="300"
radii="300"

View file

@ -54,6 +54,7 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room';
import { mobileOrTablet } from '../../../utils/user-agent';
import { useComposingCheck } from '../../../hooks/useComposingCheck';
import { PhoneHitSpread32 } from '../../../styles/PhoneHit.css';
type MessageEditorProps = {
roomId: string;
@ -289,6 +290,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
</Box>
<Box gap="Inherit">
<IconButton
className={PhoneHitSpread32}
variant="SurfaceVariant"
size="300"
radii="300"
@ -322,6 +324,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
}
>
<IconButton
className={PhoneHitSpread32}
aria-pressed={anchor !== undefined}
onClick={
((evt) =>

View file

@ -28,6 +28,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 ReactionViewerProps = {
room: Room;
@ -100,7 +101,7 @@ export const ReactionViewer = as<'div', ReactionViewerProps>(
<Box grow="Yes">
<Text size="H3" truncate>{`Reacted with :${selectedShortcode}:`}</Text>
</Box>
<IconButton size="300" onClick={requestClose}>
<IconButton className={PhoneHit} size="300" onClick={requestClose}>
<Icon src={Icons.Cross} />
</IconButton>
</Header>

View file

@ -86,6 +86,7 @@ import {
useGetMemberPowerTag,
} from '../../../hooks/useMemberPowerTag';
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
import { PhoneHit, PhoneHitSpread26 } from '../../../styles/PhoneHit.css';
type PinnedMessageProps = {
room: Room;
@ -146,6 +147,7 @@ function PinnedMessage({
</Chip>
{canPinEvent && (
<IconButton
className={PhoneHitSpread26}
data-event-id={eventId}
variant="Secondary"
size="300"
@ -457,7 +459,7 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
<Text size="H5">Pinned Messages</Text>
</Box>
<Box shrink="No">
<IconButton size="300" onClick={requestClose} radii="300">
<IconButton className={PhoneHit} size="300" onClick={requestClose} radii="300">
<Icon src={Icons.Cross} size="400" />
</IconButton>
</Box>

View file

@ -28,6 +28,7 @@ import {
setSceneStyle,
type ServedSceneStyle,
} from './sceneStyle';
import { PhoneHit } from '../../styles/PhoneHit.css';
const STYLE_LABEL: Record<ServedSceneStyle, string> = { terrain: 'Terrain', ridge: 'Ridges' };
@ -149,6 +150,7 @@ export function SkyStateControl() {
}
>
<IconButton
className={PhoneHit}
variant="Background"
aria-label={`Sky: ${LABEL[state]}`}
title={`Sky: ${LABEL[state]}`}

View file

@ -33,6 +33,7 @@ import { About } from './about';
import { UseStateProvider } from '../../components/UseStateProvider';
import { stopPropagation } from '../../utils/keyboard';
import { LogoutDialog } from '../../components/LogoutDialog';
import { PhoneHit } from '../../styles/PhoneHit.css';
export enum SettingsPages {
GeneralPage,
@ -141,7 +142,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
</Box>
<Box shrink="No">
{screenSize === ScreenSize.Mobile && (
<IconButton onClick={requestClose} variant="Background">
<IconButton className={PhoneHit} onClick={requestClose} variant="Background">
<Icon src={Icons.Cross} />
</IconButton>
)}

View file

@ -8,6 +8,7 @@ import { LogoMark } from '../../../components/logo/logoMark.css';
import RatatoskrSVG from '../../../../../public/res/svg/ratatoskr.svg';
import { clearCacheAndReload } from '../../../../client/initMatrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type AboutProps = {
requestClose: () => void;
@ -25,7 +26,7 @@ export function About({ requestClose }: AboutProps) {
</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

@ -5,6 +5,7 @@ import { MatrixId } from './MatrixId';
import { Profile } from './Profile';
import { ContactInformation } from './ContactInfo';
import { IgnoredUserList } from './IgnoredUserList';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type AccountProps = {
requestClose: () => void;
@ -20,7 +21,7 @@ export function Account({ requestClose }: AccountProps) {
</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

@ -8,6 +8,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { isUserId } from '../../../utils/matrix';
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
import { useAlive } from '../../../hooks/useAlive';
import { PhoneHit } from '../../../styles/PhoneHit.css';
function IgnoreUserInput({ userList }: { userList: string[] }) {
const mx = useMatrixClient();
@ -67,6 +68,7 @@ function IgnoreUserInput({ userList }: { userList: string[] }) {
userId &&
!ignoring && (
<IconButton
className={PhoneHit}
type="reset"
onClick={handleReset}
size="300"

View file

@ -43,6 +43,7 @@ import { ModalWide } from '../../../styles/Modal.css';
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { useCapabilities } from '../../../hooks/useCapabilities';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type ProfileProps = {
profile: UserProfile;
@ -185,7 +186,12 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
<Box grow="Yes">
<Text size="H4">Remove Avatar</Text>
</Box>
<IconButton size="300" onClick={() => setAlertRemove(false)} radii="300">
<IconButton
className={PhoneHit}
size="300"
onClick={() => setAlertRemove(false)}
radii="300"
>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@ -273,6 +279,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
hasChanges &&
!changingDisplayName && (
<IconButton
className={PhoneHit}
type="reset"
onClick={handleReset}
size="300"

View file

@ -13,6 +13,7 @@ import {
} from '../../../components/AccountDataEditor';
import { copyToClipboard } from '../../../utils/dom';
import { AccountData } from './AccountData';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type DeveloperToolsProps = {
requestClose: () => void;
@ -51,7 +52,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

@ -29,6 +29,7 @@ import { LogoutDialog } from '../../../components/LogoutDialog';
import { stopPropagation } from '../../../utils/keyboard';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { PhoneHit } from '../../../styles/PhoneHit.css';
export function DeviceTilePlaceholder() {
return (
@ -288,6 +289,7 @@ export function DeviceTile({
<SettingTile
before={
<IconButton
className={PhoneHit}
variant={deleted ? 'Critical' : 'Secondary'}
outlined={deleted}
radii="300"

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