Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-12 21:25:50 +00:00
92a98664c9 Merge pull request 'composer: persist the draft on input, keep unconfirmed sends in a durable outbox' (#122) from void/draft-persist into dev
Some checks failed
Rat dev preview / Build dev preview (push) Has been cancelled
2026-11-12 21:17:30 +00:00
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
135 changed files with 2610 additions and 403 deletions

View file

@ -47,6 +47,10 @@ handoff states which config differs and the installer decides. From Yggdrasil th
not resolve: pass `LIVE_CURL_ARGS="--resolve chat.gamecult.org:443:127.0.0.1"`.
The folder, with the three pins (`SHA256SUMS` sha256, `BUILD-INFO` sha256, lockfile sha256) and
the node and npm versions, is what the install request names.
Every build stamps its commit into `index.html` (`scripts/buildSha.js`): `<meta name="build-sha" content="<40-hex>">`,
with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. Where git governs the tree it decides: an explicit `BUILD_SHA` naming a different commit than HEAD stamps `<sha>-mismatch`, a git failure stamps `unknown`. A full-sha `BUILD_SHA` stands only for a tree git does not govern (a source archive) or cannot read (unusable git, for example a dubious-ownership refusal). The handoff
build refuses a dist whose stamp differs from the sha it was asked for, and prints the stamp in its block.
To read it from anywhere: `curl -s https://chat.gamecult.org/ | grep -o 'build-sha" content="[^"]*'`.
Before handing off:

56
scripts/buildSha.js Normal file
View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,6 +3,7 @@ import React, { ReactNode, useCallback, useRef, useState } from 'react';
import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { Range } from 'react-range';
import { PhoneHitSpread24 } from '../../../styles/PhoneHit.css';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { IAudioInfo } from '../../../../types/matrix/common';
@ -159,6 +160,7 @@ export function AudioContent({
variant="SurfaceVariant"
size="300"
radii="Pill"
className={PhoneHitSpread24}
onClick={() => setMute(!mute)}
aria-pressed={mute}
>

View file

@ -299,7 +299,10 @@ export const BubbleLeftArrow = style({
});
// Phone tap target: a sender name is 23 px tall, so the button grows 11 px above and below (44+ px to tap) and the
// negative margin takes the growth back out of the layout. Hit area only: no background, border or outline moves.
// negative margin takes the growth back out of the layout. Sideways it cannot borrow from the layout the
// same way (a negative inline margin makes the button shrink inside its shrink-to-fit parent and clip the name), so
// a name under 44 px is 44 px wide, text kept at the start: real growth of at most 16 px, in a row with room for it.
// No background, border or outline moves.
const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
export const Username = style({
overflow: 'hidden',
@ -309,7 +312,7 @@ export const Username = style({
'button&': {
cursor: 'pointer',
'@media': {
[SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11 },
[SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11, minWidth: 44, textAlign: 'start' },
},
},
'button&:hover, button&:focus-visible': {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -28,7 +28,8 @@ import {
config,
toRem,
} from 'folds';
import { RoomOutbox, sendTracked } from './RoomOutbox';
import { RoomOutbox } from './RoomOutbox';
import { sendTracked } from '../../state/room/outboxSend';
import { clearDraft, loadDraft, saveDraft } from '../../state/room/composerStore';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@ -115,6 +116,7 @@ import { useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { useComposingCheck } from '../../hooks/useComposingCheck';
import { PhoneHitSpread24, PhoneHitSpread32 } from '../../styles/PhoneHit.css';
interface RoomInputProps {
editor: Editor;
@ -333,7 +335,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
});
handleCancelUpload(uploads);
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
contents.forEach((content) => mx.sendMessage(roomId, content as any));
contents.forEach((content) => sendTracked(mx, roomId, content as Record<string, unknown>));
};
const submit = useCallback(() => {
@ -492,11 +494,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
await getImageUrlBlob(stickerUrl)
);
mx.sendEvent(roomId, EventType.Sticker, {
body: label,
url: mxc,
info,
});
sendTracked(mx, roomId, { body: label, url: mxc, info }, EventType.Sticker);
};
return (
@ -608,6 +606,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
>
<IconButton
className={PhoneHitSpread24}
onClick={() => setReplyDraft(undefined)}
variant="SurfaceVariant"
size="300"
@ -641,6 +640,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}
before={
<IconButton
className={PhoneHitSpread32}
onClick={() => pickFile('*')}
variant="SurfaceVariant"
size="300"
@ -652,6 +652,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
after={
<>
<IconButton
className={PhoneHitSpread32}
variant="SurfaceVariant"
size="300"
radii="300"
@ -694,6 +695,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
>
{!hideStickerBtn && (
<IconButton
className={PhoneHitSpread32}
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
variant="SurfaceVariant"
@ -707,6 +709,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</IconButton>
)}
<IconButton
className={PhoneHitSpread32}
ref={emojiBtnRef}
aria-pressed={
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
@ -726,7 +729,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</PopOut>
)}
</UseStateProvider>
<IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300">
<IconButton
className={PhoneHitSpread32}
onClick={submit}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.Send} />
</IconButton>
</>

View file

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

View file

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

View file

@ -1,57 +1,28 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { loadOutbox } from '../../state/room/composerStore';
import { sendTracked } from './RoomOutbox';
import { describe, expect, it } from 'vitest';
import { SLOW_SEND_MS, outboxView } from './RoomOutbox';
const stubLocalStorage = () => {
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);
},
});
};
const fakeClient = (send: (...a: unknown[]) => Promise<unknown>) =>
({ getUserId: () => '@a:x', makeTxnId: () => 'txn-1', sendMessage: send } as never);
describe('sendTracked', () => {
beforeEach(stubLocalStorage);
afterEach(() => {
vi.unstubAllGlobals();
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,
});
});
const content = { msgtype: 'm.text', body: 'typed before reload' };
it('holds the message in the outbox while the send is in flight, so a reload keeps it', async () => {
let finish!: () => void;
const mx = fakeClient(
() =>
new Promise((r) => {
finish = () => r({ event_id: '$e' });
})
);
const done = sendTracked(mx, '!r:x', content);
expect(loadOutbox('@a:x')).toMatchObject([{ txnId: 'txn-1', status: 'pending', content }]);
finish();
await done;
expect(loadOutbox('@a:x')).toEqual([]);
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('a failed send stays, marked failed, and is not cleared', async () => {
const mx = fakeClient(() => Promise.reject(new Error('ECONNRESET')));
await sendTracked(mx, '!r:x', content);
expect(loadOutbox('@a:x')).toMatchObject([
{ txnId: 'txn-1', status: 'failed', error: 'ECONNRESET', content },
]);
});
it('sends under the stored txn id, so a resend after an unknown outcome deduplicates', async () => {
const send = vi.fn().mockResolvedValue({});
await sendTracked(fakeClient(send), '!r:x', content);
expect(send).toHaveBeenCalledWith('!r:x', content, 'txn-1');
it('an entry older than this page load reads as interrupted', () => {
expect(outboxView({ status: 'pending', ts: 5 }, 6, 10).label).toMatch(/Interrupted/);
});
});

View file

@ -3,32 +3,44 @@ import { MatrixClient } from 'matrix-js-sdk';
import { Box, Chip, Text, color, config } from 'folds';
import {
OutboxEntry,
enqueueOutbox,
failOutbox,
loadOutbox,
removeOutbox,
retryOutbox,
subscribeOutbox,
} from '../../state/room/composerStore';
import { PAGE_START, resendTracked } from '../../state/room/outboxSend';
const attempt = (mx: MatrixClient, userId: string, entry: OutboxEntry): Promise<void> =>
mx
.sendMessage(entry.roomId, entry.content as never, entry.txnId)
.then(() => removeOutbox(userId, entry.txnId))
.catch((e: unknown) =>
failOutbox(userId, entry.txnId, e instanceof Error ? e.message : String(e))
);
/** A send still unconfirmed after this long is offered Resend: the request may be hung. */
export const SLOW_SEND_MS = 30_000;
/** Send a message, keeping it in the durable outbox until the server confirms it. */
export const sendTracked = (
mx: MatrixClient,
roomId: string,
content: Record<string, unknown>
): Promise<void> => {
const userId = mx.getUserId() ?? '';
const entry = { roomId, content, txnId: mx.makeTxnId() };
enqueueOutbox(userId, entry);
return attempt(mx, userId, { ...entry, status: 'pending', ts: Date.now() });
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[] => {
@ -42,19 +54,28 @@ export const useRoomOutbox = (userId: string, roomId: string): OutboxEntry[] =>
return entries;
};
/** Entries still 'pending' that predate this page load lost their promise in a reload: outcome unknown. */
const pageStart = Date.now();
/** 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: still sending, or failed.
* They also appear here after a reload, since the outbox is durable. Resending reuses the
* original txn id, so a send that did land the first time is not duplicated.
* 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 (
@ -64,40 +85,32 @@ export function RoomOutbox({ mx, roomId }: RoomOutboxProps) {
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
>
{entries.map((entry) => {
const failed = entry.status === 'failed';
const interrupted = !failed && entry.ts < pageStart;
const canResend = failed || interrupted;
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: failed ? color.Critical.Main : color.Surface.OnContainer }}
style={{ color: view.failed ? color.Critical.Main : color.Surface.OnContainer }}
>
{(() => {
if (failed) return `Not sent${entry.error ? `: ${entry.error}` : ''}`;
return interrupted ? 'Interrupted by a reload: may not have been sent' : 'Sending…';
})()}
{view.label}
</Text>
<Text size="T300" truncate>
{String(entry.content.body ?? '')}
</Text>
</Box>
{canResend && (
{view.canResend && (
<>
<Chip
variant="Surface"
radii="Pill"
onClick={() => {
retryOutbox(userId, entry.txnId);
attempt(mx, userId, entry);
}}
>
<Text size="B300">Resend</Text>
</Chip>
<Chip variant="Surface" radii="Pill" onClick={() => removeOutbox(userId, entry.txnId)}>
<Text size="B300">Discard</Text>
</Chip>
<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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -26,6 +26,7 @@ import {
} from '../../../hooks/useSecretStorage';
import { useCrossSigningActive } from '../../../hooks/useCrossSigning';
import { BackupRestoreTile } from '../../../components/BackupRestore';
import { PhoneHit } from '../../../styles/PhoneHit.css';
function DevicesPlaceholder() {
return (
@ -74,7 +75,7 @@ export function Devices({ requestClose }: DevicesProps) {
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>

View file

@ -35,6 +35,7 @@ import { stopPropagation } from '../../../utils/keyboard';
import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
import { withSearchParam } from '../../../pages/pathUtils';
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type VerificationStatusBadgeProps = {
verificationStatus: VerificationStatus;
@ -288,6 +289,7 @@ export function DeviceVerificationOptions() {
return (
<>
<IconButton
className={PhoneHit}
aria-pressed={!!menuCords}
variant="SurfaceVariant"
size="300"

View file

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

View file

@ -43,6 +43,7 @@ import { allRoomsAtom } from '../../../state/room-list/roomList';
import { AccountDataEvent } from '../../../../types/matrix/accountData';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { stopPropagation } from '../../../utils/keyboard';
import { PhoneHit } from '../../../styles/PhoneHit.css';
function GlobalPackSelector({
packs,
@ -368,6 +369,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
<Box alignItems="Center" gap="300">
{removed ? (
<IconButton
className={PhoneHit}
size="300"
radii="Pill"
variant="Critical"
@ -378,6 +380,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
</IconButton>
) : (
<IconButton
className={PhoneHit}
size="300"
radii="Pill"
variant="Secondary"

View file

@ -50,6 +50,7 @@ import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
import { useDateFormatItems } from '../../../hooks/useDateFormat';
import { SequenceCardStyle } from '../styles.css';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type ThemeSelectorProps = {
themeNames: Record<string, string>;
@ -497,6 +498,7 @@ function DateHint({ hasChanges, handleReset }: DateHintProps) {
>
{hasChanges ? (
<IconButton
className={PhoneHit}
tabIndex={-1}
onClick={handleReset}
type="reset"
@ -508,6 +510,7 @@ function DateHint({ hasChanges, handleReset }: DateHintProps) {
</IconButton>
) : (
<IconButton
className={PhoneHit}
tabIndex={-1}
onClick={handleOpenMenu}
type="button"
@ -1015,7 +1018,7 @@ export function General({ requestClose }: GeneralProps) {
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>

View file

@ -15,6 +15,7 @@ import {
} from '../../../hooks/useNotificationMode';
import { NotificationModeSwitcher } from './NotificationModeSwitcher';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { PhoneHit } from '../../../styles/PhoneHit.css';
const NOTIFY_MODE_OPS: NotificationModeOptions = {
highlight: true,
@ -75,6 +76,7 @@ function KeywordInput() {
keyword &&
!addingKeyword && (
<IconButton
className={PhoneHit}
type="reset"
onClick={handleReset}
size="300"
@ -118,7 +120,14 @@ function KeywordCross({ pushRule }: PushRulesProps) {
const removing = removeState.status === AsyncStatus.Loading;
return (
<IconButton onClick={remove} size="300" radii="Pill" variant="Secondary" disabled={removing}>
<IconButton
className={PhoneHit}
onClick={remove}
size="300"
radii="Pill"
variant="Secondary"
disabled={removing}
>
{removing ? <Spinner size="100" /> : <Icon src={Icons.Cross} size="100" />}
</IconButton>
);

View file

@ -8,6 +8,7 @@ import { KeywordMessagesNotifications } from './KeywordMessages';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type NotificationsProps = {
requestClose: () => void;
@ -23,7 +24,7 @@ export function Notifications({ requestClose }: NotificationsProps) {
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>

View file

@ -17,6 +17,7 @@ import { Members } from '../common-settings/members';
import { DeveloperTools } from '../common-settings/developer-tools';
import { General } from './general';
import { Permissions } from './permissions';
import { PhoneHit } from '../../styles/PhoneHit.css';
type SpaceSettingsMenuItem = {
page: SpaceSettingsPage;
@ -117,7 +118,7 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps)
</Box>
<Box shrink="No">
{screenSize === ScreenSize.Mobile && (
<IconButton onClick={requestClose} variant="Background">
<IconButton className={PhoneHit} onClick={requestClose} variant="Background">
<Icon src={Icons.Cross} />
</IconButton>
)}

View file

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

View file

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

View file

@ -23,6 +23,7 @@ import FocusTrap from 'focus-trap-react';
import { useDebounce } from '../../hooks/useDebounce';
import { stopPropagation } from '../../utils/keyboard';
import { PhoneHit } from '../../styles/PhoneHit.css';
export function ServerPicker({
server,
@ -129,6 +130,7 @@ export function ServerPicker({
}
>
<IconButton
className={PhoneHit}
onClick={handleOpenServerMenu}
variant={allowCustomServer ? 'Background' : 'Surface'}
size="300"

View file

@ -39,6 +39,7 @@ import { loginErrorMessage } from '../authMessages';
import { ForgotLink } from '../styles.css';
import { getResetPasswordPath } from '../../pathUtils';
import { stopPropagation } from '../../../utils/keyboard';
import { PhoneHit } from '../../../styles/PhoneHit.css';
function UsernameHint({ server }: { server: string }) {
const [anchor, setAnchor] = useState<RectCords>();
@ -94,6 +95,7 @@ function UsernameHint({ server }: { server: string }) {
}
>
<IconButton
className={PhoneHit}
tabIndex={-1}
onClick={handleOpenMenu}
type="button"

View file

@ -36,6 +36,7 @@ import { SyncStatus } from './SyncStatus';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession } from '../../state/sessions';
import { AutoDiscovery } from './AutoDiscovery';
import { PhoneHit } from '../../styles/PhoneHit.css';
function ClientRootLoading() {
return (
@ -61,6 +62,7 @@ function ClientRootOptions({ mx }: { mx?: MatrixClient }) {
return (
<IconButton
className={PhoneHit}
style={{
position: 'absolute',
top: config.space.S100,

View file

@ -17,6 +17,7 @@ import {
} from '../../../components/page';
import { BackRouteHandler } from '../../../components/BackRouteHandler';
import { CreateChat } from '../../../features/create-chat';
import { PhoneHit } from '../../../styles/PhoneHit.css';
export function DirectCreate() {
const mx = useMatrixClient();
@ -44,7 +45,7 @@ export function DirectCreate() {
<Box grow="Yes" alignItems="Center" gap="200">
<BackRouteHandler>
{(onBack) => (
<IconButton onClick={onBack}>
<IconButton className={PhoneHit} onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}

View file

@ -37,6 +37,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
import { stopPropagation } from '../../../utils/keyboard';
import { PhoneHit } from '../../../styles/PhoneHit.css';
export function AddServer() {
const mx = useMatrixClient();
@ -96,7 +97,12 @@ export function AddServer() {
<Box grow="Yes">
<Text size="H4">Add Server</Text>
</Box>
<IconButton size="300" onClick={() => setDialog(false)} radii="300">
<IconButton
className={PhoneHit}
size="300"
onClick={() => setDialog(false)}
radii="300"
>
<Icon src={Icons.Cross} />
</IconButton>
</Header>

View file

@ -18,6 +18,7 @@ import * as css from './style.css';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { BackRouteHandler } from '../../../components/BackRouteHandler';
import { PhoneHit } from '../../../styles/PhoneHit.css';
export function FeaturedRooms() {
const { featuredCommunities } = useClientConfig();
@ -33,7 +34,7 @@ export function FeaturedRooms() {
<Box shrink="No">
<BackRouteHandler>
{(onBack) => (
<IconButton onClick={onBack}>
<IconButton className={PhoneHit} onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}

View file

@ -45,6 +45,7 @@ import { getMxIdServer } from '../../../utils/matrix';
import { stopPropagation } from '../../../utils/keyboard';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { BackRouteHandler } from '../../../components/BackRouteHandler';
import { PhoneHit } from '../../../styles/PhoneHit.css';
const useServerSearchParams = (searchParams: URLSearchParams): ExploreServerPathSearchParams =>
useMemo(
@ -499,7 +500,7 @@ export function PublicRooms() {
{screenSize === ScreenSize.Mobile && (
<BackRouteHandler>
{(onBack) => (
<IconButton onClick={onBack}>
<IconButton className={PhoneHit} onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}

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