Compare commits

..
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": { "fileZoneShare": {
"enabled": false "enabled": false
}, },
"push": {
"vapidPublicKey": "",
"gatewayUrl": "",
"appId": ""
},
"registration": { "registration": {
"enabled": false, "enabled": false,
"url": "" "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"`. 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 folder, with the three pins (`SHA256SUMS` sha256, `BUILD-INFO` sha256, lockfile sha256) and
the node and npm versions, is what the install request names. 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: Before handing off:

View file

@ -1,12 +1,13 @@
{ {
"name": "Ratatoskr", "name": "Ratatoskr",
"short_name": "Ratatoskr", "short_name": "Ratatoskr",
"id": "/",
"description": "GameCult's Matrix client", "description": "GameCult's Matrix client",
"dir": "auto", "dir": "auto",
"lang": "en-US", "lang": "en-US",
"display": "standalone", "display": "standalone",
"orientation": "portrait",
"start_url": "./", "start_url": "./",
"scope": "./",
"background_color": "#07111a", "background_color": "#07111a",
"theme_color": "#07111a", "theme_color": "#07111a",
"icons": [ "icons": [
@ -54,6 +55,12 @@
"src": "./public/android/android-chrome-512x512.png", "src": "./public/android/android-chrome-512x512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png" "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'; } from 'folds';
import { MatrixError } from 'matrix-js-sdk'; import { MatrixError } from 'matrix-js-sdk';
import { Cursor } from '../plugins/text-area'; import { Cursor } from '../plugins/text-area';
import { PhoneHit } from '../styles/PhoneHit.css';
import { syntaxErrorPosition } from '../utils/dom'; import { syntaxErrorPosition } from '../utils/dom';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { Page, PageHeader } from './page'; import { Page, PageHeader } from './page';
@ -294,7 +295,7 @@ export function AccountDataEditor({
</Chip> </Chip>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton onClick={requestClose} variant="Surface"> <IconButton onClick={requestClose} variant="Surface" className={PhoneHit}>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>

View file

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

View file

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

View file

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

View file

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

View file

@ -13,6 +13,7 @@ import {
IconButton, IconButton,
} from 'folds'; } from 'folds';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { PhoneHit } from '../styles/PhoneHit.css';
export type UIAFlowOverlayProps = { export type UIAFlowOverlayProps = {
currentStep: number; currentStep: number;
@ -53,6 +54,7 @@ export function UIAFlowOverlay({
> >
{(anchorRef) => ( {(anchorRef) => (
<IconButton <IconButton
className={PhoneHit}
ref={anchorRef} ref={anchorRef}
variant="Critical" variant="Critical"
size="300" 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 { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { PhoneHit, PhoneHitRoom36, PhoneHitSpread36 } from '../../styles/PhoneHit.css';
function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) { function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) {
return ( return (
@ -72,6 +73,7 @@ export function MarkButton({ format, icon, tooltip }: MarkButtonProps) {
<TooltipProvider tooltip={tooltip} delay={500}> <TooltipProvider tooltip={tooltip} delay={500}>
{(triggerRef) => ( {(triggerRef) => (
<IconButton <IconButton
className={PhoneHitSpread36}
ref={triggerRef} ref={triggerRef}
variant="SurfaceVariant" variant="SurfaceVariant"
onClick={handleClick} onClick={handleClick}
@ -104,6 +106,7 @@ export function BlockButton({ format, icon, tooltip }: BlockButtonProps) {
<TooltipProvider tooltip={tooltip} delay={500}> <TooltipProvider tooltip={tooltip} delay={500}>
{(triggerRef) => ( {(triggerRef) => (
<IconButton <IconButton
className={PhoneHitSpread36}
ref={triggerRef} ref={triggerRef}
variant="SurfaceVariant" variant="SurfaceVariant"
onClick={handleClick} onClick={handleClick}
@ -163,6 +166,7 @@ export function HeadingBlockButton() {
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton <IconButton
className={PhoneHitRoom36}
ref={triggerRef} ref={triggerRef}
onClick={() => handleMenuSelect(1)} onClick={() => handleMenuSelect(1)}
size="400" size="400"
@ -178,6 +182,7 @@ export function HeadingBlockButton() {
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton <IconButton
className={PhoneHitRoom36}
ref={triggerRef} ref={triggerRef}
onClick={() => handleMenuSelect(2)} onClick={() => handleMenuSelect(2)}
size="400" size="400"
@ -193,6 +198,7 @@ export function HeadingBlockButton() {
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton <IconButton
className={PhoneHitRoom36}
ref={triggerRef} ref={triggerRef}
onClick={() => handleMenuSelect(3)} onClick={() => handleMenuSelect(3)}
size="400" size="400"
@ -208,6 +214,7 @@ export function HeadingBlockButton() {
} }
> >
<IconButton <IconButton
className={PhoneHit}
style={{ width: 'unset' }} style={{ width: 'unset' }}
variant="SurfaceVariant" variant="SurfaceVariant"
onClick={handleMenuOpen} onClick={handleMenuOpen}
@ -239,6 +246,7 @@ export function ExitFormatting({ tooltip }: ExitFormattingProps) {
<TooltipProvider tooltip={tooltip} delay={500}> <TooltipProvider tooltip={tooltip} delay={500}>
{(triggerRef) => ( {(triggerRef) => (
<IconButton <IconButton
className={PhoneHit}
ref={triggerRef} ref={triggerRef}
variant="SurfaceVariant" variant="SurfaceVariant"
onClick={handleClick} onClick={handleClick}
@ -344,6 +352,7 @@ export function Toolbar() {
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton <IconButton
className={PhoneHit}
ref={triggerRef} ref={triggerRef}
variant="SurfaceVariant" variant="SurfaceVariant"
onClick={() => setIsMarkdown(!isMarkdown)} onClick={() => setIsMarkdown(!isMarkdown)}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -20,6 +20,7 @@ import { MatrixError } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { PhoneHit } from '../../styles/PhoneHit.css';
type LeaveSpacePromptProps = { type LeaveSpacePromptProps = {
roomId: string; roomId: string;
@ -68,7 +69,7 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4">Leave Space</Text> <Text size="H4">Leave Space</Text>
</Box> </Box>
<IconButton size="300" onClick={onCancel} radii="300"> <IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </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 React, { ReactNode, useCallback } from 'react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import FileSaver from 'file-saver'; import FileSaver from 'file-saver';
import { PhoneHit } from '../../styles/PhoneHit.css';
import { mimeTypeToExt } from '../../utils/mimeTypes'; import { mimeTypeToExt } from '../../utils/mimeTypes';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@ -48,6 +49,7 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
variant={hasError ? 'Critical' : 'SurfaceVariant'} variant={hasError ? 'Critical' : 'SurfaceVariant'}
size="300" size="300"
radii="300" radii="300"
className={PhoneHit}
> >
{downloading ? ( {downloading ? (
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} /> <Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />

View file

@ -1,6 +1,9 @@
import { createVar, style } from '@vanilla-extract/css'; import { createVar, style } from '@vanilla-extract/css';
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds'; 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 Container = createVar();
const ContainerHover = createVar(); const ContainerHover = createVar();
const ContainerActive = createVar(); const ContainerActive = createVar();
@ -25,6 +28,14 @@ export const Reaction = style([
selectors: { selectors: {
'button&': { 'button&': {
cursor: 'pointer', 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]': { '&[aria-pressed=true]': {
vars: { 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 { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { Range } from 'react-range'; import { Range } from 'react-range';
import { PhoneHitSpread24 } from '../../../styles/PhoneHit.css';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { IAudioInfo } from '../../../../types/matrix/common'; import { IAudioInfo } from '../../../../types/matrix/common';
@ -159,6 +160,7 @@ export function AudioContent({
variant="SurfaceVariant" variant="SurfaceVariant"
size="300" size="300"
radii="Pill" radii="Pill"
className={PhoneHitSpread24}
onClick={() => setMute(!mute)} onClick={() => setMute(!mute)}
aria-pressed={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 // 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)`; const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
export const Username = style({ export const Username = style({
overflow: 'hidden', overflow: 'hidden',
@ -309,7 +312,7 @@ export const Username = style({
'button&': { 'button&': {
cursor: 'pointer', cursor: 'pointer',
'@media': { '@media': {
[SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11 }, [SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11, minWidth: 44, textAlign: 'start' },
}, },
}, },
'button&:hover, button&:focus-visible': { 'button&:hover, button&:focus-visible': {

View file

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

View file

@ -4,6 +4,7 @@ import classNames from 'classnames';
import Linkify from 'linkify-react'; import Linkify from 'linkify-react';
import * as css from './style.css'; import * as css from './style.css';
import { LINKIFY_OPTS, scaleSystemEmoji } from '../../plugins/react-custom-html-parser'; import { LINKIFY_OPTS, scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
import { PhoneHit } from '../../styles/PhoneHit.css';
export const RoomTopicViewer = as< export const RoomTopicViewer = as<
'div', 'div',
@ -26,7 +27,7 @@ export const RoomTopicViewer = as<
{name} {name}
</Text> </Text>
</Box> </Box>
<IconButton size="300" onClick={requestClose} radii="300"> <IconButton className={PhoneHit} size="300" onClick={requestClose} radii="300">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </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 { ErrorBoundary } from 'react-error-boundary';
import * as css from './TextViewer.css'; import * as css from './TextViewer.css';
import { copyToClipboard } from '../../utils/dom'; import { copyToClipboard } from '../../utils/dom';
import { PhoneHit } from '../../styles/PhoneHit.css';
const ReactPrism = lazy(() => import('../../plugins/react-prism/ReactPrism')); const ReactPrism = lazy(() => import('../../plugins/react-prism/ReactPrism'));
@ -53,7 +54,7 @@ export const TextViewer = as<'div', TextViewerProps>(
> >
<Header className={css.TextViewerHeader} size="400"> <Header className={css.TextViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200"> <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} /> <Icon size="50" src={Icons.ArrowLeft} />
</IconButton> </IconButton>
<Text size="T300" truncate> <Text size="T300" truncate>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,6 +5,7 @@ import { StatusDivider } from './components';
import { CallEmbed, useCallControlState } from '../../plugins/call'; import { CallEmbed, useCallControlState } from '../../plugins/call';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { callEmbedAtom } from '../../state/callEmbed'; import { callEmbedAtom } from '../../state/callEmbed';
import { PhoneHitSpread26 } from '../../styles/PhoneHit.css';
type MicrophoneButtonProps = { type MicrophoneButtonProps = {
enabled: boolean; enabled: boolean;
@ -26,6 +27,7 @@ function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps
> >
{(anchorRef) => ( {(anchorRef) => (
<IconButton <IconButton
className={PhoneHitSpread26}
ref={anchorRef} ref={anchorRef}
variant={enabled ? 'Surface' : 'Warning'} variant={enabled ? 'Surface' : 'Warning'}
fill="Soft" fill="Soft"
@ -59,6 +61,7 @@ function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
> >
{(anchorRef) => ( {(anchorRef) => (
<IconButton <IconButton
className={PhoneHitSpread26}
ref={anchorRef} ref={anchorRef}
variant={enabled ? 'Surface' : 'Warning'} variant={enabled ? 'Surface' : 'Warning'}
fill="Soft" fill="Soft"
@ -99,6 +102,7 @@ function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
> >
{(anchorRef) => ( {(anchorRef) => (
<IconButton <IconButton
className={PhoneHitSpread26}
ref={anchorRef} ref={anchorRef}
variant={enabled ? 'Success' : 'Surface'} variant={enabled ? 'Success' : 'Surface'}
fill="Soft" fill="Soft"
@ -136,6 +140,7 @@ function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonPro
> >
{(anchorRef) => ( {(anchorRef) => (
<IconButton <IconButton
className={PhoneHitSpread26}
ref={anchorRef} ref={anchorRef}
variant={enabled ? 'Success' : 'Surface'} variant={enabled ? 'Success' : 'Surface'}
fill="Soft" fill="Soft"

View file

@ -25,6 +25,7 @@ import {
SoundButton, SoundButton,
VideoButton, VideoButton,
} from './Controls'; } from './Controls';
import { PhoneHitSpread40 } from '../../styles/PhoneHit.css';
import { CallEmbed, useCallControlState } from '../../plugins/call'; import { CallEmbed, useCallControlState } from '../../plugins/call';
import { useResizeObserver } from '../../hooks/useResizeObserver'; import { useResizeObserver } from '../../hooks/useResizeObserver';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
@ -169,6 +170,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
} }
> >
<IconButton <IconButton
className={PhoneHitSpread40}
variant="Surface" variant="Surface"
fill="Soft" fill="Soft"
radii="400" radii="400"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -23,6 +23,7 @@ import { SearchResultGroup } from './SearchResultGroup';
import { SearchInput } from './SearchInput'; import { SearchInput } from './SearchInput';
import { SearchFilters } from './SearchFilters'; import { SearchFilters } from './SearchFilters';
import { VirtualTile } from '../../components/virtualizer'; import { VirtualTile } from '../../components/virtualizer';
import { PhoneHit } from '../../styles/PhoneHit.css';
const useSearchPathSearchParams = (searchParams: URLSearchParams): _SearchPathSearchParams => const useSearchPathSearchParams = (searchParams: URLSearchParams): _SearchPathSearchParams =>
useMemo( useMemo(
@ -193,6 +194,7 @@ export function MessageSearch({
<Box direction="Column" gap="700"> <Box direction="Column" gap="700">
<ScrollTopContainer scrollRef={scrollRef} anchorRef={scrollTopAnchorRef}> <ScrollTopContainer scrollRef={scrollRef} anchorRef={scrollTopAnchorRef}>
<IconButton <IconButton
className={PhoneHit}
onClick={() => virtualizer.scrollToOffset(0)} onClick={() => virtualizer.scrollToOffset(0)}
variant="SurfaceVariant" variant="SurfaceVariant"
radii="Pill" 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 { livekitSupport } from '../../hooks/useLivekitSupport';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
import { webRTCSupported } from '../../utils/rtc'; import { webRTCSupported } from '../../utils/rtc';
import { PhoneHitSpread24 } from '../../styles/PhoneHit.css';
type RoomNavItemMenuProps = { type RoomNavItemMenuProps = {
room: Room; room: Room;
@ -229,6 +230,7 @@ function CallChatToggle() {
return ( return (
<IconButton <IconButton
className={PhoneHitSpread24}
onClick={() => setChat(!chat)} onClick={() => setChat(!chat)}
aria-pressed={chat} aria-pressed={chat}
aria-label="Toggle Chat" aria-label="Toggle Chat"
@ -436,6 +438,7 @@ export function RoomNavItem({
} }
> >
<IconButton <IconButton
className={PhoneHitSpread24}
onClick={handleOpenMenu} onClick={handleOpenMenu}
aria-pressed={!!menuAnchor} aria-pressed={!!menuAnchor}
aria-controls={`menu-${room.roomId}`} aria-controls={`menu-${room.roomId}`}

View file

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

View file

@ -15,6 +15,7 @@ import {
} from '../../common-settings/general'; } from '../../common-settings/general';
import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type GeneralProps = { type GeneralProps = {
requestClose: () => void; requestClose: () => void;
@ -35,7 +36,7 @@ export function General({ requestClose }: GeneralProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton onClick={requestClose} variant="Surface"> <IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>

View file

@ -9,6 +9,7 @@ import { usePermissionGroups } from './usePermissionItems';
import { PermissionGroups, Powers, PowersEditor } from '../../common-settings/permissions'; import { PermissionGroups, Powers, PowersEditor } from '../../common-settings/permissions';
import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type PermissionsProps = { type PermissionsProps = {
requestClose: () => void; requestClose: () => void;
@ -45,7 +46,7 @@ export function Permissions({ requestClose }: PermissionsProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton onClick={requestClose} variant="Surface"> <IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>

View file

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

View file

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

View file

@ -28,6 +28,9 @@ import {
config, config,
toRem, toRem,
} from 'folds'; } 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 { useMatrixClient } from '../../hooks/useMatrixClient';
import { import {
@ -113,6 +116,7 @@ import { useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { useComposingCheck } from '../../hooks/useComposingCheck'; import { useComposingCheck } from '../../hooks/useComposingCheck';
import { PhoneHitSpread24, PhoneHitSpread32 } from '../../styles/PhoneHit.css';
interface RoomInputProps { interface RoomInputProps {
editor: Editor; editor: Editor;
@ -221,22 +225,66 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
useCallback((width) => setHideStickerBtn(width < 500), []) 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(() => { useEffect(() => {
Transforms.insertFragment(editor, msgDraft); Transforms.insertFragment(editor, msgDraft);
}, [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( useEffect(
() => () => { () => () => {
clearTimeout(draftSaveTimer.current);
draftSaveTimer.current = undefined;
if (!isEmptyEditor(editor)) { if (!isEmptyEditor(editor)) {
const parsedDraft = JSON.parse(JSON.stringify(editor.children)); const parsedDraft = JSON.parse(JSON.stringify(editor.children));
setMsgDraft(parsedDraft); setMsgDraft(parsedDraft);
saveDraft(userId, roomId, parsedDraft);
} else { } else {
setMsgDraft([]); setMsgDraft([]);
clearDraft(userId, roomId);
} }
resetEditor(editor); resetEditor(editor);
resetEditorHistory(editor); resetEditorHistory(editor);
}, },
[roomId, editor, setMsgDraft] [roomId, editor, setMsgDraft, userId]
); );
const handleFileMetadata = useCallback( const handleFileMetadata = useCallback(
@ -287,7 +335,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}); });
handleCancelUpload(uploads); handleCancelUpload(uploads);
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); 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(() => { const submit = useCallback(() => {
@ -366,12 +414,25 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
content['m.relates_to'].is_falling_back = false; 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); resetEditor(editor);
resetEditorHistory(editor); resetEditorHistory(editor);
setReplyDraft(undefined); setReplyDraft(undefined);
sendTypingStatus(false); sendTypingStatus(false);
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]); }, [
mx,
roomId,
userId,
editor,
replyDraft,
sendTypingStatus,
setReplyDraft,
isMarkdown,
commands,
]);
const handleKeyDown: KeyboardEventHandler = useCallback( const handleKeyDown: KeyboardEventHandler = useCallback(
(evt) => { (evt) => {
@ -433,11 +494,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
await getImageUrlBlob(stickerUrl) await getImageUrlBlob(stickerUrl)
); );
mx.sendEvent(roomId, EventType.Sticker, { sendTracked(mx, roomId, { body: label, url: mxc, info }, EventType.Sticker);
body: label,
url: mxc,
info,
});
}; };
return ( return (
@ -537,47 +594,53 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp} onKeyUp={handleKeyUp}
onPaste={handlePaste} onPaste={handlePaste}
onChange={handleEditorChange}
top={ top={
replyDraft && ( <>
<div> <RoomOutbox mx={mx} roomId={roomId} />
<Box {replyDraft && (
alignItems="Center" <div>
gap="300" <Box
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }} alignItems="Center"
> gap="300"
<IconButton style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
onClick={() => setReplyDraft(undefined)}
variant="SurfaceVariant"
size="300"
radii="300"
> >
<Icon src={Icons.Cross} size="50" /> <IconButton
</IconButton> className={PhoneHitSpread24}
<Box direction="Row" gap="200" alignItems="Center"> onClick={() => setReplyDraft(undefined)}
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />} variant="SurfaceVariant"
<ReplyLayout size="300"
userColor={replyUsernameColor} radii="300"
username={
<Text size="T300" truncate>
<b>
{getMemberDisplayName(room, replyDraft.userId) ??
getMxIdLocalPart(replyDraft.userId) ??
replyDraft.userId}
</b>
</Text>
}
> >
<Text size="T300" truncate> <Icon src={Icons.Cross} size="50" />
{trimReplyFromBody(replyDraft.body)} </IconButton>
</Text> <Box direction="Row" gap="200" alignItems="Center">
</ReplyLayout> {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>
</Box> </div>
</div> )}
) </>
} }
before={ before={
<IconButton <IconButton
className={PhoneHitSpread32}
onClick={() => pickFile('*')} onClick={() => pickFile('*')}
variant="SurfaceVariant" variant="SurfaceVariant"
size="300" size="300"
@ -589,6 +652,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
after={ after={
<> <>
<IconButton <IconButton
className={PhoneHitSpread32}
variant="SurfaceVariant" variant="SurfaceVariant"
size="300" size="300"
radii="300" radii="300"
@ -631,6 +695,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
> >
{!hideStickerBtn && ( {!hideStickerBtn && (
<IconButton <IconButton
className={PhoneHitSpread32}
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker} aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)} onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
variant="SurfaceVariant" variant="SurfaceVariant"
@ -644,6 +709,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</IconButton> </IconButton>
)} )}
<IconButton <IconButton
className={PhoneHitSpread32}
ref={emojiBtnRef} ref={emojiBtnRef}
aria-pressed={ aria-pressed={
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
@ -663,7 +729,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</PopOut> </PopOut>
)} )}
</UseStateProvider> </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} /> <Icon src={Icons.Send} />
</IconButton> </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 { isKeyHotkey } from 'is-hotkey';
import { Opts as LinkifyOpts } from 'linkifyjs'; import { Opts as LinkifyOpts } from 'linkifyjs';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { isUnconfirmedLocalEcho } from './localEcho';
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix'; import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator'; import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
@ -389,6 +390,19 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void)
}, [room, onArrive]); }, [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) => { const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => {
useEffect(() => { useEffect(() => {
const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r) => { 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( const handleOpenEvent = useCallback(
async ( async (
evtId: string, evtId: string,
@ -1646,6 +1672,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
if (mEvent.isRedacted() && !showHiddenEvents) { if (mEvent.isRedacted() && !showHiddenEvents) {
return null; return null;
} }
if (isUnconfirmedLocalEcho(mEvent)) return null;
if (!newDivider && readUptoEventIdRef.current) { if (!newDivider && readUptoEventIdRef.current) {
newDivider = prevEvent?.getId() === 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 { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { useLivekitSupport } from '../../hooks/useLivekitSupport'; import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc'; import { webRTCSupported } from '../../utils/rtc';
import { PhoneHitSpread40 } from '../../styles/PhoneHit.css';
type RoomMenuProps = { type RoomMenuProps = {
room: Room; room: Room;
@ -336,6 +337,7 @@ function CallButton() {
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton <IconButton
className={PhoneHitSpread40}
variant="Surface" variant="Surface"
fill="None" fill="None"
ref={triggerRef} ref={triggerRef}
@ -455,7 +457,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
<BackRouteHandler> <BackRouteHandler>
{(onBack) => ( {(onBack) => (
<Box shrink="No" alignItems="Center"> <Box shrink="No" alignItems="Center">
<IconButton fill="None" onClick={onBack}> <IconButton className={PhoneHitSpread40} fill="None" onClick={onBack}>
<Icon src={Icons.ArrowLeft} /> <Icon src={Icons.ArrowLeft} />
</IconButton> </IconButton>
</Box> </Box>
@ -531,7 +533,12 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
} }
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton fill="None" ref={triggerRef} onClick={handleSearchClick}> <IconButton
className={PhoneHitSpread40}
fill="None"
ref={triggerRef}
onClick={handleSearchClick}
>
<Icon size="400" src={Icons.Search} /> <Icon size="400" src={Icons.Search} />
</IconButton> </IconButton>
)} )}
@ -548,6 +555,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton <IconButton
className={PhoneHitSpread40}
fill="None" fill="None"
style={{ position: 'relative' }} style={{ position: 'relative' }}
onClick={handleOpenPinMenu} onClick={handleOpenPinMenu}
@ -612,7 +620,12 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
} }
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton fill="None" ref={triggerRef} onClick={handleMemberToggle}> <IconButton
className={PhoneHitSpread40}
fill="None"
ref={triggerRef}
onClick={handleMemberToggle}
>
<Icon size="400" src={Icons.User} /> <Icon size="400" src={Icons.User} />
</IconButton> </IconButton>
)} )}
@ -631,6 +644,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton <IconButton
className={PhoneHitSpread40}
fill="None" fill="None"
onClick={handleOpenMenu} onClick={handleOpenMenu}
ref={triggerRef} ref={triggerRef}

View file

@ -10,6 +10,7 @@ import { getMxIdLocalPart } from '../../utils/matrix';
import * as css from './RoomViewTyping.css'; import * as css from './RoomViewTyping.css';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers'; import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
import { PhoneHit } from '../../styles/PhoneHit.css';
export type RoomViewTypingProps = { export type RoomViewTypingProps = {
room: Room; room: Room;
@ -111,7 +112,13 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
</> </>
)} )}
</Text> </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} /> <Icon size="50" src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>

View file

@ -31,6 +31,7 @@ import { getToday, getYesterday, timeDayMonthYear, timeHourMinute } from '../../
import { DatePicker, TimePicker } from '../../../components/time-date'; import { DatePicker, TimePicker } from '../../../components/time-date';
import { useSetting } from '../../../state/hooks/settings'; import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings'; import { settingsAtom } from '../../../state/settings';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type JumpToTimeProps = { type JumpToTimeProps = {
onCancel: () => void; onCancel: () => void;
@ -108,7 +109,7 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4">Jump to Time</Text> <Text size="H4">Jump to Time</Text>
</Box> </Box>
<IconButton size="300" onClick={onCancel} radii="300"> <IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </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 { useSkyInk } from '../../../hooks/useTheme';
import { userColorInk } from '../../scene/skyInk'; import { userColorInk } from '../../scene/skyInk';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag'; import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
import { PhoneHitRoom26, PhoneHitSpread32 } from '../../../styles/PhoneHit.css';
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void; export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
@ -111,7 +112,7 @@ export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
{recentEmojis.map((emoji) => ( {recentEmojis.map((emoji) => (
<IconButton <IconButton
key={emoji.unicode} key={emoji.unicode}
className={css.MessageQuickReaction} className={classNames(css.MessageQuickReaction, PhoneHitSpread32)}
size="300" size="300"
variant="SurfaceVariant" variant="SurfaceVariant"
radii="Pill" radii="Pill"
@ -459,7 +460,12 @@ export const MessageDeleteItem = as<
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4">Delete Message</Text> <Text size="H4">Delete Message</Text>
</Box> </Box>
<IconButton size="300" onClick={handleClose} radii="300"> <IconButton
className={PhoneHitSpread32}
size="300"
onClick={handleClose}
radii="300"
>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>
@ -589,7 +595,12 @@ export const MessageReportItem = as<
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4">Report Message</Text> <Text size="H4">Report Message</Text>
</Box> </Box>
<IconButton size="300" onClick={handleClose} radii="300"> <IconButton
className={PhoneHitSpread32}
size="300"
onClick={handleClose}
radii="300"
>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>
@ -950,6 +961,7 @@ export const Message = as<'div', MessageProps>(
} }
> >
<IconButton <IconButton
className={PhoneHitRoom26}
onClick={handleOpenEmojiBoard} onClick={handleOpenEmojiBoard}
variant="SurfaceVariant" variant="SurfaceVariant"
size="300" size="300"
@ -961,6 +973,7 @@ export const Message = as<'div', MessageProps>(
</PopOut> </PopOut>
)} )}
<IconButton <IconButton
className={PhoneHitRoom26}
onClick={onReplyClick} onClick={onReplyClick}
data-event-id={mEvent.getId()} data-event-id={mEvent.getId()}
variant="SurfaceVariant" variant="SurfaceVariant"
@ -971,6 +984,7 @@ export const Message = as<'div', MessageProps>(
</IconButton> </IconButton>
{!isThreadedMessage && ( {!isThreadedMessage && (
<IconButton <IconButton
className={PhoneHitRoom26}
onClick={(ev) => onReplyClick(ev, true)} onClick={(ev) => onReplyClick(ev, true)}
data-event-id={mEvent.getId()} data-event-id={mEvent.getId()}
variant="SurfaceVariant" variant="SurfaceVariant"
@ -982,6 +996,7 @@ export const Message = as<'div', MessageProps>(
)} )}
{canEditEvent(mx, mEvent) && onEditId && ( {canEditEvent(mx, mEvent) && onEditId && (
<IconButton <IconButton
className={PhoneHitRoom26}
onClick={() => onEditId(mEvent.getId())} onClick={() => onEditId(mEvent.getId())}
variant="SurfaceVariant" variant="SurfaceVariant"
size="300" size="300"
@ -1147,6 +1162,7 @@ export const Message = as<'div', MessageProps>(
} }
> >
<IconButton <IconButton
className={PhoneHitRoom26}
variant="SurfaceVariant" variant="SurfaceVariant"
size="300" size="300"
radii="300" radii="300"
@ -1320,6 +1336,7 @@ export const Event = as<'div', EventProps>(
} }
> >
<IconButton <IconButton
className={PhoneHitRoom26}
variant="SurfaceVariant" variant="SurfaceVariant"
size="300" size="300"
radii="300" radii="300"

View file

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

View file

@ -28,6 +28,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile'; import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../../hooks/useSpace'; import { useSpaceOptionally } from '../../../hooks/useSpace';
import { getMouseEventCords } from '../../../utils/dom'; import { getMouseEventCords } from '../../../utils/dom';
import { PhoneHit } from '../../../styles/PhoneHit.css';
export type ReactionViewerProps = { export type ReactionViewerProps = {
room: Room; room: Room;
@ -100,7 +101,7 @@ export const ReactionViewer = as<'div', ReactionViewerProps>(
<Box grow="Yes"> <Box grow="Yes">
<Text size="H3" truncate>{`Reacted with :${selectedShortcode}:`}</Text> <Text size="H3" truncate>{`Reacted with :${selectedShortcode}:`}</Text>
</Box> </Box>
<IconButton size="300" onClick={requestClose}> <IconButton className={PhoneHit} size="300" onClick={requestClose}>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>

View file

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

View file

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

View file

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

View file

@ -8,6 +8,7 @@ import { LogoMark } from '../../../components/logo/logoMark.css';
import RatatoskrSVG from '../../../../../public/res/svg/ratatoskr.svg'; import RatatoskrSVG from '../../../../../public/res/svg/ratatoskr.svg';
import { clearCacheAndReload } from '../../../../client/initMatrix'; import { clearCacheAndReload } from '../../../../client/initMatrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type AboutProps = { type AboutProps = {
requestClose: () => void; requestClose: () => void;
@ -25,7 +26,7 @@ export function About({ requestClose }: AboutProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton onClick={requestClose} variant="Surface"> <IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>

View file

@ -5,6 +5,7 @@ import { MatrixId } from './MatrixId';
import { Profile } from './Profile'; import { Profile } from './Profile';
import { ContactInformation } from './ContactInfo'; import { ContactInformation } from './ContactInfo';
import { IgnoredUserList } from './IgnoredUserList'; import { IgnoredUserList } from './IgnoredUserList';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type AccountProps = { type AccountProps = {
requestClose: () => void; requestClose: () => void;
@ -20,7 +21,7 @@ export function Account({ requestClose }: AccountProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton onClick={requestClose} variant="Surface"> <IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>

View file

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

View file

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

View file

@ -13,6 +13,7 @@ import {
} from '../../../components/AccountDataEditor'; } from '../../../components/AccountDataEditor';
import { copyToClipboard } from '../../../utils/dom'; import { copyToClipboard } from '../../../utils/dom';
import { AccountData } from './AccountData'; import { AccountData } from './AccountData';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type DeveloperToolsProps = { type DeveloperToolsProps = {
requestClose: () => void; requestClose: () => void;
@ -51,7 +52,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton onClick={requestClose} variant="Surface"> <IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>

View file

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

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