From f7395f12e3522077c74a111ec70fb902295e55c2 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 01:39:58 +0000 Subject: [PATCH 01/74] Phone: icon buttons, reaction chips and short sender names get a 44 px hit area Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/message/Reaction.css.ts | 11 +++++ .../components/message/layout/layout.css.ts | 7 ++- .../password-input/PasswordInput.tsx | 2 + src/app/features/room/RoomInput.tsx | 14 +++++- src/app/features/room/RoomViewHeader.tsx | 20 +++++++-- src/app/features/room/message/Message.tsx | 7 +++ src/app/features/scene/SkyStateControl.tsx | 2 + .../pages/auth/login/PasswordLoginForm.tsx | 2 + src/app/phoneTapTargets.test.ts | 40 +++++++++++++++++ src/app/styles/PhoneHit.css.ts | 45 +++++++++++++++++++ 10 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 src/app/styles/PhoneHit.css.ts diff --git a/src/app/components/message/Reaction.css.ts b/src/app/components/message/Reaction.css.ts index f020080e..08003f0e 100644 --- a/src/app/components/message/Reaction.css.ts +++ b/src/app/components/message/Reaction.css.ts @@ -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: { diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 3d1a9c83..39a40aa8 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -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': { diff --git a/src/app/components/password-input/PasswordInput.tsx b/src/app/components/password-input/PasswordInput.tsx index 4ffe0d83..6aa0d4f3 100644 --- a/src/app/components/password-input/PasswordInput.tsx +++ b/src/app/components/password-input/PasswordInput.tsx @@ -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, 'type' | 'size'> & { size: '400' | '500'; @@ -23,6 +24,7 @@ export const PasswordInput = forwardRef( <> {after} setVisible(!visible)} type="button" variant={visible ? 'Warning' : variant} diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 03926da9..41f67d03 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -116,6 +116,7 @@ import { useTheme } from '../../hooks/useTheme'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { useComposingCheck } from '../../hooks/useComposingCheck'; +import { PhoneHitSpread32 } from '../../styles/PhoneHit.css'; interface RoomInputProps { editor: Editor; @@ -605,6 +606,7 @@ export const RoomInput = forwardRef( style={{ padding: `${config.space.S200} ${config.space.S300} 0` }} > setReplyDraft(undefined)} variant="SurfaceVariant" size="300" @@ -638,6 +640,7 @@ export const RoomInput = forwardRef( } before={ pickFile('*')} variant="SurfaceVariant" size="300" @@ -649,6 +652,7 @@ export const RoomInput = forwardRef( after={ <> ( > {!hideStickerBtn && ( setEmojiBoardTab(EmojiBoardTab.Sticker)} variant="SurfaceVariant" @@ -704,6 +709,7 @@ export const RoomInput = forwardRef( )} ( )} - + diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 8089b5ef..3132c77a 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -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) => ( {(onBack) => ( - + @@ -531,7 +533,12 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { } > {(triggerRef) => ( - + )} @@ -548,6 +555,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { > {(triggerRef) => ( {(triggerRef) => ( - + )} @@ -631,6 +644,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { > {(triggerRef) => ( void; @@ -950,6 +951,7 @@ export const Message = as<'div', MessageProps>( } > ( )} ( {!isThreadedMessage && ( onReplyClick(ev, true)} data-event-id={mEvent.getId()} variant="SurfaceVariant" @@ -982,6 +986,7 @@ export const Message = as<'div', MessageProps>( )} {canEditEvent(mx, mEvent) && onEditId && ( onEditId(mEvent.getId())} variant="SurfaceVariant" size="300" @@ -1147,6 +1152,7 @@ export const Message = as<'div', MessageProps>( } > ( } > = { terrain: 'Terrain', ridge: 'Ridges' }; @@ -149,6 +150,7 @@ export function SkyStateControl() { } > (); @@ -94,6 +95,7 @@ function UsernameHint({ server }: { server: string }) { } > { expect(phone.match(/min-height: 2\.75rem/g)?.length).toBeGreaterThanOrEqual(2); }); }); + +describe('phone icon-button hit areas', () => { + const stripPhone = (css: string) => css.replace(/@media [^{]+ \{\n[\s\S]*?\n\}(?:\n|$)/g, ''); + + it('draw a 44 x 44 box around the button on a phone and nothing on any other screen', async () => { + const css = await emitted('styles/PhoneHit.css.ts'); + const phone = [...css.matchAll(/@media ([^{]+) \{\n([\s\S]*?\n)\}(?:\n|$)/g)]; + expect(phone.map((m) => m[1])).toEqual([PHONE]); + expect(css).toMatch(/::after \{[^}]*width: max\(100%, 44px\);[^}]*height: max\(100%, 44px\);/); + expect(css).toMatch(/::after \{[^}]*position: absolute;/); + expect(stripPhone(css).trim()).toBe(''); + }); + + it('open a row up by half the shortfall, so neighbouring hit areas meet and do not overlap', async () => { + const css = await emitted('styles/PhoneHit.css.ts'); + const margins = (selector: RegExp) => + rulesFor(css, selector, PHONE).map((d) => d['margin-inline']); + // 32 px composer buttons, 40 px header buttons, 26 px message-option buttons (which also open vertically, + // the menu around them clips a hit area drawn outside its box) + expect(margins(/PhoneHit_spread__/)).toEqual(expect.arrayContaining([9, 6, 2])); + expect(rulesFor(css, /PhoneHit_PhoneHitRoom26__/, PHONE)).toEqual([{ 'margin-block': 9 }]); + }); + + it("make a message's reaction chip 44 px to tap by opening 9 px above and below it", async () => { + const css = await emitted('components/message/Reaction.css.ts'); + const [chip] = rulesFor(css, /^button\.Reaction_Reaction__\w+$/, PHONE); + expect(chip['margin-block']).toBe((TAP - 26) / 2); + expect(css).toMatch(/::after \{[^}]*inset: -9px 0;/); + rulesFor(css, /^button\.Reaction_Reaction__\w+$/, undefined).forEach((d) => + expect(Object.keys(d)).not.toContain('margin-block') + ); + }); + + it('give a sender name a 44 px wide button too, text kept at its start', async () => { + const css = await emitted('components/message/layout/layout.css.ts'); + const [phone] = rulesFor(css, /^button\.layout_Username__/, PHONE); + expect(phone['min-width']).toBe(TAP); + expect(css).toMatch(/text-align: start;/); + }); +}); diff --git a/src/app/styles/PhoneHit.css.ts b/src/app/styles/PhoneHit.css.ts new file mode 100644 index 00000000..14ff375c --- /dev/null +++ b/src/app/styles/PhoneHit.css.ts @@ -0,0 +1,45 @@ +import { style } from '@vanilla-extract/css'; +import { MOBILE_BREAKPOINT } from '../hooks/useScreenSize'; + +// Phone tap target for an icon button: on a phone the button's hit area is 44 x 44 px centred on the drawn +// button (a ::after the button's own click reaches), so nothing drawn changes size. A button with neighbours +// 44 px closer than that would take their taps, so the `Spread` classes also give it horizontal margin of half +// the shortfall: the row grows by that, on phones only, and the hit areas meet instead of overlapping. +// Vertical room is cheaper than horizontal on a phone, so only the horizontal axis pays. +export const PHONE_TAP = 44; +const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; + +const hitArea = { + '@media': { + [PHONE]: { + content: '""', + position: 'absolute', + top: '50%', + left: '50%', + width: `max(100%, ${PHONE_TAP}px)`, + height: `max(100%, ${PHONE_TAP}px)`, + transform: 'translate(-50%, -50%)', + }, + }, +} as const; + +/** An icon button on its own: the hit area grows, nothing else moves. */ +export const PhoneHit = style({ + '@media': { [PHONE]: { position: 'relative' } }, + selectors: { '&::after': hitArea }, +}); + +/** An icon button in a row, `visual` px wide: the row opens up so its 44 px hit area meets its neighbours'. */ +const spread = (visual: number) => + style([PhoneHit, { '@media': { [PHONE]: { marginInline: (PHONE_TAP - visual) / 2 } } }]); + +const PhoneHitSpread26 = spread(26); +export const PhoneHitSpread32 = spread(32); +export const PhoneHitSpread40 = spread(40); + +/** For a button in a clipping container (a Menu's overflow hidden eats a hit area drawn outside the box): the row + * opens up on both axes, so the 44 px sits inside the container. */ +export const PhoneHitRoom26 = style([ + PhoneHitSpread26, + { '@media': { [PHONE]: { marginBlock: (PHONE_TAP - 26) / 2 } } }, +]); From 856096e215c794aa29eb81cb1043082521563173 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 01:41:33 +0000 Subject: [PATCH 02/74] Desk back button: phone hit area Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/desk/Desk.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app/features/desk/Desk.tsx b/src/app/features/desk/Desk.tsx index f1695716..a09501e7 100644 --- a/src/app/features/desk/Desk.tsx +++ b/src/app/features/desk/Desk.tsx @@ -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() { {phone && ( - navigate(getHomePath())}> + navigate(getHomePath())} + > )} From ee55c322146ddfc135e68772b12449e404e0bc1a Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 03:27:14 +0000 Subject: [PATCH 03/74] Phone: quick-reaction emoji buttons and delete/report dialog close buttons get a 44 px hit area Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room/message/Message.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 8ad1e958..a1bc17c9 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -86,7 +86,7 @@ import colorMXID from '../../../../util/colorMXID'; import { useSkyInk } from '../../../hooks/useTheme'; import { userColorInk } from '../../scene/skyInk'; import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag'; -import { PhoneHitRoom26 } from '../../../styles/PhoneHit.css'; +import { PhoneHitRoom26, PhoneHitSpread32 } from '../../../styles/PhoneHit.css'; export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void; @@ -112,7 +112,7 @@ export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>( {recentEmojis.map((emoji) => ( Delete Message - + @@ -590,7 +595,12 @@ export const MessageReportItem = as< Report Message - + From 21ff16717711a0d576b04de4b67a94771a115511 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 03:48:09 +0000 Subject: [PATCH 04/74] phoneTapTargets: every IconButton in Message, RoomInput, RoomViewHeader carries a PhoneHit class Co-Authored-By: Claude Sonnet 5.5 --- src/app/phoneTapTargets.test.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index 5dcf2958..b50ec02d 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'fs'; import { describe, expect, it } from 'vitest'; import zlib from 'zlib'; import { compile, processVanillaFile } from '@vanilla-extract/integration'; @@ -156,3 +157,32 @@ describe('phone icon-button hit areas', () => { expect(css).toMatch(/text-align: start;/); }); }); + +describe('icon buttons in the room view', () => { + // The opening tag of every outside any {} expression. + const iconButtonTags = (src: string) => { + const tags: string[] = []; + for (let i = src.indexOf('' && depth === 0) break; + } + tags.push(src.slice(i, j + 1)); + } + return tags; + }; + + it.each([ + 'features/room/message/Message.tsx', + 'features/room/RoomInput.tsx', + 'features/room/RoomViewHeader.tsx', + ])('%s: every IconButton carries a PhoneHit class', (file) => { + const tags = iconButtonTags(readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8')); + expect(tags.length).toBeGreaterThan(0); + const bare = tags.filter((t) => !/PhoneHit/.test(t)); + expect(bare).toEqual([]); + }); +}); From 0e3115809ea05f64c327170e57e1ebabfa550f39 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 04:02:30 +0000 Subject: [PATCH 05/74] phoneTapTargets: pin the exact PhoneHit variant of each IconButton Co-Authored-By: Claude Sonnet 5.5 --- src/app/phoneTapTargets.test.ts | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index b50ec02d..b190e643 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -175,14 +175,24 @@ describe('icon buttons in the room view', () => { return tags; }; - it.each([ - 'features/room/message/Message.tsx', - 'features/room/RoomInput.tsx', - 'features/room/RoomViewHeader.tsx', - ])('%s: every IconButton carries a PhoneHit class', (file) => { - const tags = iconButtonTags(readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8')); - expect(tags.length).toBeGreaterThan(0); - const bare = tags.filter((t) => !/PhoneHit/.test(t)); - expect(bare).toEqual([]); - }); + // The class each IconButton must carry, in source order: the variant is chosen by the button's drawn size + // and whether a clipping container or neighbours surround it, so a swapped variant is a wrong hit area. + const expected: Record = { + 'features/room/message/Message.tsx': [ + 'PhoneHitSpread32', // quick-reaction emoji buttons + 'PhoneHitSpread32', // delete dialog close + 'PhoneHitSpread32', // report dialog close + ...Array(6).fill('PhoneHitRoom26'), // message options bar, inside a clipping menu + ], + 'features/room/RoomInput.tsx': Array(6).fill('PhoneHitSpread32'), + 'features/room/RoomViewHeader.tsx': Array(6).fill('PhoneHitSpread40'), + }; + + it.each(Object.entries(expected))( + '%s: each IconButton carries its own PhoneHit class', + (file, want) => { + const tags = iconButtonTags(readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8')); + expect(tags.map((t) => t.match(/\bPhoneHit\w+/)?.[0] ?? null)).toEqual(want); + } + ); }); From bd2d9800235054f27c995c15b377254b63b23d12 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 04:10:46 +0000 Subject: [PATCH 06/74] phoneTapTargets: pin top, left and transform of the hit-area box Co-Authored-By: Claude Sonnet 5.5 --- src/app/phoneTapTargets.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index b190e643..d4e31772 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -127,6 +127,10 @@ describe('phone icon-button hit areas', () => { expect(phone.map((m) => m[1])).toEqual([PHONE]); expect(css).toMatch(/::after \{[^}]*width: max\(100%, 44px\);[^}]*height: max\(100%, 44px\);/); expect(css).toMatch(/::after \{[^}]*position: absolute;/); + // centred on the button: top and left at half, pulled back by half its own size + expect(css).toMatch(/::after \{[^}]*top: 50%;/); + expect(css).toMatch(/::after \{[^}]*left: 50%;/); + expect(css).toMatch(/::after \{[^}]*transform: translate\(-50%, -50%\);/); expect(stripPhone(css).trim()).toBe(''); }); From fc92809eb91192b2f238c6e210840c17108295cc Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 05:40:21 +0000 Subject: [PATCH 07/74] Phone: 44 px hit areas for the chat path's remaining icon buttons; pin variant, import and ::after content 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 --- src/app/features/room-nav/RoomNavItem.tsx | 3 +++ src/app/features/room/CallChatView.tsx | 8 +++++- src/app/features/room/MembersDrawer.tsx | 3 +++ src/app/features/room/RoomViewTyping.tsx | 9 ++++++- .../features/room/jump-to-time/JumpToTime.tsx | 3 ++- .../features/room/message/MessageEditor.tsx | 3 +++ .../room/reaction-viewer/ReactionViewer.tsx | 3 ++- .../room/room-pin-menu/RoomPinMenu.tsx | 4 ++- src/app/phoneTapTargets.test.ts | 26 +++++++++++++++++-- 9 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index e8a349f0..842d7146 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -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 { PhoneHitSpread32 } from '../../styles/PhoneHit.css'; type RoomNavItemMenuProps = { room: Room; @@ -229,6 +230,7 @@ function CallChatToggle() { return ( setChat(!chat)} aria-pressed={chat} aria-label="Toggle Chat" @@ -436,6 +438,7 @@ export function RoomNavItem({ } > {(triggerRef) => ( - + )} diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index d9205076..3162eff0 100644 --- a/src/app/features/room/MembersDrawer.tsx +++ b/src/app/features/room/MembersDrawer.tsx @@ -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) => ( setPeopleDrawer(false)} @@ -359,6 +361,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) { virtualizer.scrollToOffset(0)} variant="Surface" radii="Pill" diff --git a/src/app/features/room/RoomViewTyping.tsx b/src/app/features/room/RoomViewTyping.tsx index 1142a3a8..9e0266e9 100644 --- a/src/app/features/room/RoomViewTyping.tsx +++ b/src/app/features/room/RoomViewTyping.tsx @@ -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>( )} - + diff --git a/src/app/features/room/jump-to-time/JumpToTime.tsx b/src/app/features/room/jump-to-time/JumpToTime.tsx index 223c6cf6..4b231856 100644 --- a/src/app/features/room/jump-to-time/JumpToTime.tsx +++ b/src/app/features/room/jump-to-time/JumpToTime.tsx @@ -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) { Jump to Time - + diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index 2d7f2097..45d79eb7 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -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>( ( } > diff --git a/src/app/features/room/reaction-viewer/ReactionViewer.tsx b/src/app/features/room/reaction-viewer/ReactionViewer.tsx index 6c686bc5..b1a674d9 100644 --- a/src/app/features/room/reaction-viewer/ReactionViewer.tsx +++ b/src/app/features/room/reaction-viewer/ReactionViewer.tsx @@ -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>( {`Reacted with :${selectedShortcode}:`} - + diff --git a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx index 9986849f..53f8d073 100644 --- a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx +++ b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx @@ -86,6 +86,7 @@ import { useGetMemberPowerTag, } from '../../../hooks/useMemberPowerTag'; import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type PinnedMessageProps = { room: Room; @@ -146,6 +147,7 @@ function PinnedMessage({ {canPinEvent && ( ( Pinned Messages - + diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index d4e31772..be4bceb6 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -126,6 +126,8 @@ describe('phone icon-button hit areas', () => { const phone = [...css.matchAll(/@media ([^{]+) \{\n([\s\S]*?\n)\}(?:\n|$)/g)]; expect(phone.map((m) => m[1])).toEqual([PHONE]); expect(css).toMatch(/::after \{[^}]*width: max\(100%, 44px\);[^}]*height: max\(100%, 44px\);/); + // no content, no box: the pseudo-element is not rendered at all + expect(css).toMatch(/::after \{[^}]*content: "";/); expect(css).toMatch(/::after \{[^}]*position: absolute;/); // centred on the button: top and left at half, pulled back by half its own size expect(css).toMatch(/::after \{[^}]*top: 50%;/); @@ -190,13 +192,33 @@ describe('icon buttons in the room view', () => { ], 'features/room/RoomInput.tsx': Array(6).fill('PhoneHitSpread32'), 'features/room/RoomViewHeader.tsx': Array(6).fill('PhoneHitSpread40'), + // The chat path's other buttons. Two neighbours in a row: Spread32. One on its own: the plain PhoneHit. + 'features/room/message/MessageEditor.tsx': ['PhoneHitSpread32', 'PhoneHitSpread32'], // toolbar, emoji + 'features/room-nav/RoomNavItem.tsx': ['PhoneHitSpread32', 'PhoneHitSpread32'], // chat toggle, options + 'features/room/MembersDrawer.tsx': ['PhoneHit', 'PhoneHit'], // close, scroll to top + 'features/room/reaction-viewer/ReactionViewer.tsx': ['PhoneHit'], + 'features/room/room-pin-menu/RoomPinMenu.tsx': ['PhoneHit', 'PhoneHit'], // unpin, close + 'features/room/RoomViewTyping.tsx': ['PhoneHit'], + 'features/room/CallChatView.tsx': ['PhoneHit'], + 'features/room/jump-to-time/JumpToTime.tsx': ['PhoneHit'], + // Buttons that were fixed with #125 and had no test. + 'components/password-input/PasswordInput.tsx': ['PhoneHit'], + 'features/desk/Desk.tsx': ['PhoneHit'], + 'features/scene/SkyStateControl.tsx': ['PhoneHit'], + 'pages/auth/login/PasswordLoginForm.tsx': ['PhoneHit'], }; it.each(Object.entries(expected))( '%s: each IconButton carries its own PhoneHit class', (file, want) => { - const tags = iconButtonTags(readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8')); - expect(tags.map((t) => t.match(/\bPhoneHit\w+/)?.[0] ?? null)).toEqual(want); + const src = readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8'); + expect(iconButtonTags(src).map((t) => t.match(/\bPhoneHit\w*/)?.[0] ?? null)).toEqual(want); + // the name is the one PhoneHit.css.ts exports, not a local of the same name + [...new Set(want)].forEach((name) => + expect(src).toMatch( + new RegExp(`import \\{[^}]*\\b${name}\\b[^}]*\\} from '(\\.\\./)+styles/PhoneHit\\.css'`) + ) + ); } ); }); From 446306b1e72b8c5103c04c7ffc54df4266650008 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 05:46:21 +0000 Subject: [PATCH 08/74] PhoneHit: Spread24 for the nav row's 24 px buttons, Spread26 for the unpin button; tests pin position, ::after declarations, className and import alias 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 --- src/app/features/room-nav/RoomNavItem.tsx | 6 ++--- .../room/room-pin-menu/RoomPinMenu.tsx | 4 ++-- src/app/phoneTapTargets.test.ts | 24 +++++++++++++++---- src/app/styles/PhoneHit.css.ts | 3 ++- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 842d7146..8d53ce7f 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -66,7 +66,7 @@ import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo'; import { livekitSupport } from '../../hooks/useLivekitSupport'; import { StateEvent } from '../../../types/matrix/room'; import { webRTCSupported } from '../../utils/rtc'; -import { PhoneHitSpread32 } from '../../styles/PhoneHit.css'; +import { PhoneHitSpread24 } from '../../styles/PhoneHit.css'; type RoomNavItemMenuProps = { room: Room; @@ -230,7 +230,7 @@ function CallChatToggle() { return ( setChat(!chat)} aria-pressed={chat} aria-label="Toggle Chat" @@ -438,7 +438,7 @@ export function RoomNavItem({ } > {canPinEvent && ( { expect(css).toMatch(/::after \{[^}]*top: 50%;/); expect(css).toMatch(/::after \{[^}]*left: 50%;/); expect(css).toMatch(/::after \{[^}]*transform: translate\(-50%, -50%\);/); + // the box anchors to its own button, receives the tap, and sits above nothing it should not + expect(css).toMatch(/@media [^{]+ \{\n\s*\.PhoneHit_PhoneHit__\w+ \{\n\s*position: relative;/); + const afterDecls = [...css.matchAll(/::after \{\n([^}]*)\}/g)].flatMap((m) => + m[1] + .split('\n') + .map((l) => l.trim().split(':')[0]) + .filter(Boolean) + ); + expect(afterDecls).not.toContain('pointer-events'); + expect(afterDecls).not.toContain('z-index'); expect(stripPhone(css).trim()).toBe(''); }); @@ -142,7 +152,7 @@ describe('phone icon-button hit areas', () => { rulesFor(css, selector, PHONE).map((d) => d['margin-inline']); // 32 px composer buttons, 40 px header buttons, 26 px message-option buttons (which also open vertically, // the menu around them clips a hit area drawn outside its box) - expect(margins(/PhoneHit_spread__/)).toEqual(expect.arrayContaining([9, 6, 2])); + expect(margins(/PhoneHit_spread__/)).toEqual(expect.arrayContaining([10, 9, 6, 2])); expect(rulesFor(css, /PhoneHit_PhoneHitRoom26__/, PHONE)).toEqual([{ 'margin-block': 9 }]); }); @@ -194,10 +204,10 @@ describe('icon buttons in the room view', () => { 'features/room/RoomViewHeader.tsx': Array(6).fill('PhoneHitSpread40'), // The chat path's other buttons. Two neighbours in a row: Spread32. One on its own: the plain PhoneHit. 'features/room/message/MessageEditor.tsx': ['PhoneHitSpread32', 'PhoneHitSpread32'], // toolbar, emoji - 'features/room-nav/RoomNavItem.tsx': ['PhoneHitSpread32', 'PhoneHitSpread32'], // chat toggle, options + 'features/room-nav/RoomNavItem.tsx': ['PhoneHitSpread24', 'PhoneHitSpread24'], // chat toggle, options: 16 px icon + 8 padding = 24 px, 0 gap 'features/room/MembersDrawer.tsx': ['PhoneHit', 'PhoneHit'], // close, scroll to top 'features/room/reaction-viewer/ReactionViewer.tsx': ['PhoneHit'], - 'features/room/room-pin-menu/RoomPinMenu.tsx': ['PhoneHit', 'PhoneHit'], // unpin, close + 'features/room/room-pin-menu/RoomPinMenu.tsx': ['PhoneHitSpread26', 'PhoneHit'], // unpin (26 px, 8 px from the Open chip), close 'features/room/RoomViewTyping.tsx': ['PhoneHit'], 'features/room/CallChatView.tsx': ['PhoneHit'], 'features/room/jump-to-time/JumpToTime.tsx': ['PhoneHit'], @@ -212,11 +222,15 @@ describe('icon buttons in the room view', () => { '%s: each IconButton carries its own PhoneHit class', (file, want) => { const src = readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8'); - expect(iconButtonTags(src).map((t) => t.match(/\bPhoneHit\w*/)?.[0] ?? null)).toEqual(want); + expect( + iconButtonTags(src).map((t) => t.match(/className=\{[^}]*?\b(PhoneHit\w*)\b/)?.[1] ?? null) + ).toEqual(want); // the name is the one PhoneHit.css.ts exports, not a local of the same name [...new Set(want)].forEach((name) => expect(src).toMatch( - new RegExp(`import \\{[^}]*\\b${name}\\b[^}]*\\} from '(\\.\\./)+styles/PhoneHit\\.css'`) + new RegExp( + `import \\{[^}]*\\b${name}\\b(?!\\s+as\\b)[^}]*\\} from '(\\.\\./)+styles/PhoneHit\\.css'` + ) ) ); } diff --git a/src/app/styles/PhoneHit.css.ts b/src/app/styles/PhoneHit.css.ts index 14ff375c..4ebc8370 100644 --- a/src/app/styles/PhoneHit.css.ts +++ b/src/app/styles/PhoneHit.css.ts @@ -33,7 +33,8 @@ export const PhoneHit = style({ const spread = (visual: number) => style([PhoneHit, { '@media': { [PHONE]: { marginInline: (PHONE_TAP - visual) / 2 } } }]); -const PhoneHitSpread26 = spread(26); +export const PhoneHitSpread24 = spread(24); +export const PhoneHitSpread26 = spread(26); export const PhoneHitSpread32 = spread(32); export const PhoneHitSpread40 = spread(40); From 0c8474231e778da10b2c6cdb0a258e91fffba902 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 05:50:10 +0000 Subject: [PATCH 09/74] phoneTapTargets: each Spread class owns its margin, ::after declaration set is exact, no phone rule moves position, import must not be aliased 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 --- src/app/phoneTapTargets.test.ts | 22 ++++++++++++++++++---- src/app/styles/PhoneHit.css.ts | 5 ++++- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index cf7a6b27..a6459ade 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -141,8 +141,20 @@ describe('phone icon-button hit areas', () => { .map((l) => l.trim().split(':')[0]) .filter(Boolean) ); - expect(afterDecls).not.toContain('pointer-events'); - expect(afterDecls).not.toContain('z-index'); + expect([...afterDecls].sort()).toEqual([ + 'content', + 'height', + 'left', + 'position', + 'top', + 'transform', + 'width', + ]); + // nothing on a phone moves a button off its own anchor: only relative (the button) and absolute (the box) + expect([...css.matchAll(/position: (\w+);/g)].map((m) => m[1]).sort()).toEqual([ + 'absolute', + 'relative', + ]); expect(stripPhone(css).trim()).toBe(''); }); @@ -152,7 +164,9 @@ describe('phone icon-button hit areas', () => { rulesFor(css, selector, PHONE).map((d) => d['margin-inline']); // 32 px composer buttons, 40 px header buttons, 26 px message-option buttons (which also open vertically, // the menu around them clips a hit area drawn outside its box) - expect(margins(/PhoneHit_spread__/)).toEqual(expect.arrayContaining([10, 9, 6, 2])); + // each class owns its own margin: (44 - drawn size) / 2 + const own = (size: number) => margins(new RegExp(`PhoneHit_PhoneHitSpread${size}__`)); + expect([own(24), own(26), own(32), own(40)]).toEqual([[10], [9], [6], [2]]); expect(rulesFor(css, /PhoneHit_PhoneHitRoom26__/, PHONE)).toEqual([{ 'margin-block': 9 }]); }); @@ -229,7 +243,7 @@ describe('icon buttons in the room view', () => { [...new Set(want)].forEach((name) => expect(src).toMatch( new RegExp( - `import \\{[^}]*\\b${name}\\b(?!\\s+as\\b)[^}]*\\} from '(\\.\\./)+styles/PhoneHit\\.css'` + `import \\{(?:[^}]*,)?\\s*${name}\\s*(?:,[^}]*)?\\} from '(\\.\\./)+styles/PhoneHit\\.css'` ) ) ); diff --git a/src/app/styles/PhoneHit.css.ts b/src/app/styles/PhoneHit.css.ts index 4ebc8370..8403c126 100644 --- a/src/app/styles/PhoneHit.css.ts +++ b/src/app/styles/PhoneHit.css.ts @@ -31,7 +31,10 @@ export const PhoneHit = style({ /** An icon button in a row, `visual` px wide: the row opens up so its 44 px hit area meets its neighbours'. */ const spread = (visual: number) => - style([PhoneHit, { '@media': { [PHONE]: { marginInline: (PHONE_TAP - visual) / 2 } } }]); + style( + [PhoneHit, { '@media': { [PHONE]: { marginInline: (PHONE_TAP - visual) / 2 } } }], + `PhoneHitSpread${visual}` + ); export const PhoneHitSpread24 = spread(24); export const PhoneHitSpread26 = spread(26); From c45cdc7bd7bca9729c7d565b3d84ab8ec97c0521 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 06:52:32 +0000 Subject: [PATCH 10/74] buildSha: BUILD_SHA no longer masks a dirty tree; the vite temp-file exclusion is untracked-only 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 --- docs/gamecult/client-build-install.md | 3 +- scripts/buildSha.js | 22 +++++++---- scripts/buildSha.test.js | 56 +++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 9 deletions(-) diff --git a/docs/gamecult/client-build-install.md b/docs/gamecult/client-build-install.md index d89541da..03bd3adc 100644 --- a/docs/gamecult/client-build-install.md +++ b/docs/gamecult/client-build-install.md @@ -48,7 +48,8 @@ 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`): ``, -with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. The handoff +with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. An explicit `BUILD_SHA` names the commit where there is no git; where git +sees a changed tree it is suffixed `-dirty` too, so it never vouches for code it does not hold. 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="[^"]*'`. diff --git a/scripts/buildSha.js b/scripts/buildSha.js index d6c4870f..fc025643 100644 --- a/scripts/buildSha.js +++ b/scripts/buildSha.js @@ -1,7 +1,8 @@ // The commit a bundle was built from, stamped into its index.html as , so a built // folder says what it is without a build log: `grep -o 'build-sha" content="[^"]*' dist/index.html`. -// BUILD_SHA wins when it is a full sha (a build from a source archive has no git); otherwise git's HEAD, -// suffixed -dirty when the tree has uncommitted changes; otherwise 'unknown'. +// BUILD_SHA names the commit when it is a full sha (a build from a source archive has no git), otherwise git's +// HEAD does; either way -dirty is appended when git can see uncommitted changes, so an explicit sha never +// vouches for a tree that differs from it. No git and no BUILD_SHA: 'unknown'. import { execFileSync } from 'child_process'; import { dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; @@ -9,8 +10,9 @@ 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 it must not mark the tree dirty. Any other untracked file still does. -const VITE_TEMP = ':(exclude,glob)**/vite.config.*.timestamp-*.mjs'; +// 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\.[^/]*\.timestamp-[^/]*\.mjs$/; export const gitIn = (cwd) => @@ -19,13 +21,17 @@ export const gitIn = export const readBuildSha = (env = process.env, run = gitIn(REPO_DIR)) => { const fromEnv = (env.BUILD_SHA ?? '').trim(); - if (/^[0-9a-f]{40}$/.test(fromEnv)) return fromEnv; + const explicit = /^[0-9a-f]{40}$/.test(fromEnv) ? fromEnv : null; try { const head = run('rev-parse', 'HEAD').trim(); - if (!/^[0-9a-f]{40}$/.test(head)) return 'unknown'; - return run('status', '--porcelain', '--', '.', VITE_TEMP).trim() === '' ? head : `${head}-dirty`; + const sha = explicit ?? head; + if (!/^[0-9a-f]{40}$/.test(sha)) return 'unknown'; + const dirty = run('status', '--porcelain', '-uall', '--', '.') + .split('\n') + .some((l) => l.trim() !== '' && !VITE_TEMP.test(l)); + return dirty ? `${sha}-dirty` : sha; } catch { - return 'unknown'; + return explicit ?? 'unknown'; } }; diff --git a/scripts/buildSha.test.js b/scripts/buildSha.test.js index c45c8747..b7bd85e7 100644 --- a/scripts/buildSha.test.js +++ b/scripts/buildSha.test.js @@ -23,6 +23,8 @@ describe('readBuildSha', () => { throw new Error('not a git repository'); }; expect(readBuildSha({ BUILD_SHA: SHA }, noGit)).toBe(SHA); + // an explicit sha with a clean tree is just itself + expect(readBuildSha({ BUILD_SHA: SHA }, gitAt('c'.repeat(40)))).toBe(SHA); expect(readBuildSha({ BUILD_SHA: 'main' }, noGit)).toBe('unknown'); }); it("says 'unknown' rather than failing the build when git is absent", () => { @@ -60,6 +62,60 @@ describe('readBuildSha against a real repository', () => { }); }); +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 } = repo2(); + const run = gitIn(dir); + expect(readBuildSha({ BUILD_SHA: SHA }, run)).toBe(SHA); + writeFileSync(join(dir, 'src', 'zz-stray.ts'), ''); + expect(readBuildSha({ BUILD_SHA: SHA }, run)).toBe(`${SHA}-dirty`); + }); + 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); From c82193f0fb7613537938f9fdf3aced2d968cb411 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 06:56:27 +0000 Subject: [PATCH 11/74] buildSha: git speaks only for a tree it roots, mismatch and git failure fail closed, temp-file name matched exactly 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 --- docs/gamecult/client-build-install.md | 3 +- scripts/buildSha.js | 31 +++++++++----- scripts/buildSha.test.js | 58 +++++++++++++++++++++++---- 3 files changed, 74 insertions(+), 18 deletions(-) diff --git a/docs/gamecult/client-build-install.md b/docs/gamecult/client-build-install.md index 03bd3adc..e2215d61 100644 --- a/docs/gamecult/client-build-install.md +++ b/docs/gamecult/client-build-install.md @@ -48,8 +48,7 @@ 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`): ``, -with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. An explicit `BUILD_SHA` names the commit where there is no git; where git -sees a changed tree it is suffixed `-dirty` too, so it never vouches for code it does not hold. The handoff +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 `-mismatch`, a git failure stamps `unknown`. A full-sha `BUILD_SHA` stands only for a tree git does not govern (a source archive). 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="[^"]*'`. diff --git a/scripts/buildSha.js b/scripts/buildSha.js index fc025643..61a89445 100644 --- a/scripts/buildSha.js +++ b/scripts/buildSha.js @@ -1,8 +1,9 @@ // The commit a bundle was built from, stamped into its index.html as , so a built // folder says what it is without a build log: `grep -o 'build-sha" content="[^"]*' dist/index.html`. -// BUILD_SHA names the commit when it is a full sha (a build from a source archive has no git), otherwise git's -// HEAD does; either way -dirty is appended when git can see uncommitted changes, so an explicit sha never -// vouches for a tree that differs from it. No git and no BUILD_SHA: 'unknown'. +// Where git governs the tree (REPO_DIR is a repository root) git decides: HEAD, suffixed -dirty when the tree has +// uncommitted changes, or `-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'; @@ -12,26 +13,38 @@ 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\.[^/]*\.timestamp-[^/]*\.mjs$/; +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 = /^[0-9a-f]{40}$/.test(fromEnv) ? fromEnv : null; + 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(); - const sha = explicit ?? head; - if (!/^[0-9a-f]{40}$/.test(sha)) return 'unknown'; + 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 ? `${sha}-dirty` : sha; + return dirty ? `${head}-dirty` : head; } catch { - return explicit ?? 'unknown'; + return 'unknown'; } }; diff --git a/scripts/buildSha.test.js b/scripts/buildSha.test.js index b7bd85e7..4a270867 100644 --- a/scripts/buildSha.test.js +++ b/scripts/buildSha.test.js @@ -8,8 +8,8 @@ import { buildShaPlugin, gitIn, readBuildSha } from './buildSha'; const SHA = 'b6b1d522f1c0a9d3e2b4c5d6e7f8091a2b3c4d5e'; const gitAt = (head, status = '') => - (cmd) => - cmd === 'rev-parse' ? `${head}\n` : status; + (cmd, ...args) => + cmd === 'rev-parse' ? (args[0] === '--show-prefix' ? '\n' : `${head}\n`) : status; describe('readBuildSha', () => { it('reads the checkout it is built from', () => { @@ -23,8 +23,25 @@ describe('readBuildSha', () => { throw new Error('not a git repository'); }; expect(readBuildSha({ BUILD_SHA: SHA }, noGit)).toBe(SHA); - // an explicit sha with a clean tree is just itself - expect(readBuildSha({ BUILD_SHA: SHA }, gitAt('c'.repeat(40)))).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", () => { @@ -84,11 +101,38 @@ describe('readBuildSha dirty rules against a real repository', () => { }; }; it('does not let an explicit BUILD_SHA mask a dirty tree', () => { - const { dir } = repo2(); + const { dir, head } = repo2(); const run = gitIn(dir); - expect(readBuildSha({ BUILD_SHA: SHA }, run)).toBe(SHA); + expect(readBuildSha({ BUILD_SHA: head }, run)).toBe(head); writeFileSync(join(dir, 'src', 'zz-stray.ts'), ''); - expect(readBuildSha({ BUILD_SHA: SHA }, run)).toBe(`${SHA}-dirty`); + 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..timestamp--.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(); From eff894a89c15bf347ac374476715bc3577296495 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 06:57:45 +0000 Subject: [PATCH 12/74] docs: BUILD_SHA also stands where git cannot be read Co-Authored-By: Claude Sonnet 5.5 --- docs/gamecult/client-build-install.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/gamecult/client-build-install.md b/docs/gamecult/client-build-install.md index e2215d61..b2505fe1 100644 --- a/docs/gamecult/client-build-install.md +++ b/docs/gamecult/client-build-install.md @@ -48,7 +48,7 @@ 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`): ``, -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 `-mismatch`, a git failure stamps `unknown`. A full-sha `BUILD_SHA` stands only for a tree git does not govern (a source archive). The handoff +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 `-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="[^"]*'`. From 2b019a430217898afb0cd82a82414186ab2ce6e0 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 06:58:48 +0000 Subject: [PATCH 13/74] Phone hit: Spread28, Spread36 and Room36 variants Co-Authored-By: Claude Sonnet 5.5 --- src/app/styles/PhoneHit.css.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/app/styles/PhoneHit.css.ts b/src/app/styles/PhoneHit.css.ts index 8403c126..c388e5f6 100644 --- a/src/app/styles/PhoneHit.css.ts +++ b/src/app/styles/PhoneHit.css.ts @@ -38,12 +38,18 @@ const spread = (visual: number) => export const PhoneHitSpread24 = spread(24); export const PhoneHitSpread26 = spread(26); +export const PhoneHitSpread28 = spread(28); export const PhoneHitSpread32 = spread(32); +export const PhoneHitSpread36 = spread(36); export const PhoneHitSpread40 = spread(40); /** For a button in a clipping container (a Menu's overflow hidden eats a hit area drawn outside the box): the row * opens up on both axes, so the 44 px sits inside the container. */ -export const PhoneHitRoom26 = style([ - PhoneHitSpread26, - { '@media': { [PHONE]: { marginBlock: (PHONE_TAP - 26) / 2 } } }, -]); +const room = (visual: number, spreadClass: string) => + style( + [spreadClass, { '@media': { [PHONE]: { marginBlock: (PHONE_TAP - visual) / 2 } } }], + `PhoneHitRoom${visual}` + ); + +export const PhoneHitRoom26 = room(26, PhoneHitSpread26); +export const PhoneHitRoom36 = room(36, PhoneHitSpread36); From d917ccd3bba89d078ed9e5e9253fc2679c6231bf Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 06:59:50 +0000 Subject: [PATCH 14/74] Phone hit tests: shared kit, size tie, pinned new variants The helpers moved to phoneHitTestKit.ts so each later group adds its own phoneHit..test.ts. A new check ties PhoneHitSpread/Room 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 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 --- src/app/features/room/RoomInput.tsx | 4 +- src/app/phoneHitTestKit.ts | 161 ++++++++++++++++++++++++++++ src/app/phoneTapTargets.test.ts | 100 +++++------------ src/app/styles/PhoneHit.css.ts | 23 ++-- 4 files changed, 205 insertions(+), 83 deletions(-) create mode 100644 src/app/phoneHitTestKit.ts diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 41f67d03..2d63cbf1 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -116,7 +116,7 @@ import { useTheme } from '../../hooks/useTheme'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { useComposingCheck } from '../../hooks/useComposingCheck'; -import { PhoneHitSpread32 } from '../../styles/PhoneHit.css'; +import { PhoneHitSpread24, PhoneHitSpread32 } from '../../styles/PhoneHit.css'; interface RoomInputProps { editor: Editor; @@ -606,7 +606,7 @@ export const RoomInput = forwardRef( style={{ padding: `${config.space.S200} ${config.space.S300} 0` }} > setReplyDraft(undefined)} variant="SurfaceVariant" size="300" diff --git a/src/app/phoneHitTestKit.ts b/src/app/phoneHitTestKit.ts new file mode 100644 index 00000000..bcd81540 --- /dev/null +++ b/src/app/phoneHitTestKit.ts @@ -0,0 +1,161 @@ +// Shared helpers for the phone hit-area tests (phoneTapTargets.test.ts and one phoneHit..test.ts per later +// group of IconButtons). Not a test file: it registers nothing, the test files call it. +import { readFileSync } from 'fs'; +import { expect } from 'vitest'; +import zlib from 'zlib'; +import { compile, processVanillaFile } from '@vanilla-extract/integration'; + +// The CSS a .css.ts file emits, read back from vanilla-extract's own compile (as pages/auth/footerBand.test.ts does; +// vanilla-extract gzips the inlined source only when it is large, so both forms are read). +export const emitted = async (file: string) => { + const filePath = `${process.cwd()}/src/app/${file}`; + const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' }); + const code = await processVanillaFile({ source, filePath, identOption: 'debug' }); + // A file with recipes inlines one source per rule group; read them all. + return [...code.matchAll(/source=#?([A-Za-z0-9+/=]+)/g)] + .map((m) => { + const bytes = Buffer.from(m[1], 'base64'); + return (bytes[0] === 0x1f && bytes[1] === 0x8b ? zlib.gunzipSync(bytes) : bytes).toString(); + }) + .join('\n'); +}; + +export type Decls = Record; +const parse = (body: string): Decls => + Object.fromEntries( + body + .split('\n') + .map((l) => l.trim().replace(/;$/, '')) + .filter((l) => /^[a-z-]+: -?\d+px$/.test(l)) + .map((l) => [l.split(':')[0], Number(l.split(': ')[1].replace('px', ''))]) + ); + +/** Pixel declarations of the rules whose selector matches, inside or outside a media query. */ +export const rulesFor = (css: string, selector: RegExp, media: string | undefined): Decls[] => { + const scope = media + ? [...css.matchAll(/@media ([^{]+) \{\n([\s\S]*?\n)\}(?:\n|$)/g)] + .filter((m) => m[1] === media) + .map((m) => m[2]) + .join('\n') + : css.replace(/@media [^{]+ \{\n[\s\S]*?\n\}(?:\n|$)/g, ''); + return [...scope.matchAll(/^\s*([^{}\n]+) \{\n([^}]*)\}/gm)] + .filter((m) => selector.test(m[1])) + .map((m) => parse(m[2])); +}; + +/** The source of a file under src/app. */ +export const readSource = (file: string) => + readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8'); + +/** The end of a JSX opening tag starting at `from`: its closing > outside any {} expression. */ +const tagEnd = (src: string, from: number) => { + let depth = 0; + let j = from; + for (; j < src.length; j += 1) { + if (src[j] === '{') depth += 1; + else if (src[j] === '}') depth -= 1; + else if (src[j] === '>' && depth === 0) break; + } + return j; +}; + +/** The opening tag of every outside any {} expression. */ +export const iconButtonTags = (src: string) => { + const tags: string[] = []; + for (let i = src.indexOf(' { + const out: { tag: string; body: string }[] = []; + for (let i = src.indexOf('', end); + out.push({ tag, body: tag.endsWith('/>') || close === -1 ? '' : src.slice(end + 1, close) }); + } + return out; +}; + +const phoneHitOf = (tag: string) => tag.match(/className=\{[^}]*?\b(PhoneHit\w*)\b/)?.[1] ?? null; + +/** The class each IconButton must carry, in source order: the variant is chosen by the button's drawn size and + * whether a clipping container or neighbours surround it, so a swapped variant is a wrong hit area. Also checks + * the name is the one PhoneHit.css.ts exports, not a local of the same name. */ +export const expectPhoneHitClasses = (file: string, want: string[]) => { + const src = readSource(file); + expect(iconButtonTags(src).map(phoneHitOf)).toEqual(want); + [...new Set(want)].forEach((name) => + expect(src).toMatch( + new RegExp( + `import \\{(?:[^}]*,)?\\s*${name}\\s*(?:,[^}]*)?\\} from '(\\.\\./)+styles/PhoneHit\\.css'` + ) + ) + ); +}; + +// IconButton `size` -> padding on each side, Icon `size` -> px (folds 2.7.2). Both default to 400. +const BUTTON_PAD: Record = { '300': 4, '400': 8, '500': 12, '600': 16 }; +const ICON_PX: Record = { + '50': 16, + '100': 18, + '200': 20, + '300': 22, + '400': 24, + '500': 28, +}; + +/** The size prop of a tag: its literal value, the default when absent, or undefined when it is an expression. */ +const sizeProp = (tag: string) => { + const m = tag.match(/\ssize=(?:"(\w+)"|\{([^}]*)\})/); + if (!m) return '400'; + return m[1] ?? m[2].match(/^'(\w+)'$/)?.[1]; +}; + +/** The px an IconButton draws, from its size prop and the Icon inside; undefined with a reason when the source + * does not say (a variable size, no Icon, Icons of different sizes). */ +export const drawnPx = (tag: string, body: string): { px: number } | { undetermined: string } => { + const pad = BUTTON_PAD[sizeProp(tag) ?? '']; + if (pad === undefined) return { undetermined: 'size of the IconButton is not a literal' }; + const icons = [...body.matchAll(/ { + const t = body.slice(m.index, tagEnd(body, m.index ?? 0) + 1); + return ICON_PX[sizeProp(t) ?? '']; + }); + if (icons.length === 0) return { undetermined: 'no inside' }; + if (icons.some((p) => p === undefined)) + return { undetermined: 'size of an Icon is not a literal' }; + if (new Set(icons).size > 1) return { undetermined: 'Icons of different sizes inside' }; + return { px: icons[0] + 2 * pad }; +}; + +/** Per file, per IconButton index (source order): why its drawn size cannot be derived from the source. */ +export type SizeAllow = Record>; + +/** A button carrying PhoneHitSpread or PhoneHitRoom draws N px. Plain PhoneHit has no number to tie. An + * undetermined size passes only with an entry in `allow`, and an entry for a size that can be derived fails. */ +export const expectSizeTie = (file: string, allow: SizeAllow = {}) => { + const allowed = allow[file] ?? {}; + const seen = new Set(); + iconButtons(readSource(file)).forEach(({ tag, body }, i) => { + const variant = phoneHitOf(tag)?.match(/^PhoneHit(?:Spread|Room)(\d+)$/); + if (!variant) { + expect(allowed[i], `${file} #${i} is allow-listed but has no sized variant`).toBeUndefined(); + return; + } + const drawn = drawnPx(tag, body); + if ('undetermined' in drawn) { + expect( + allowed[i], + `${file} #${i}: ${drawn.undetermined}; add it to the allow-list` + ).toBeDefined(); + seen.add(i); + return; + } + expect(allowed[i], `${file} #${i} is allow-listed but its size is derivable`).toBeUndefined(); + expect(Number(variant[1]), `${file} #${i} draws ${drawn.px} px`).toBe(drawn.px); + }); + expect(Object.keys(allowed).map(Number).sort()).toEqual([...seen].sort()); +}; diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index a6459ade..f2166cad 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -1,46 +1,12 @@ -import { readFileSync } from 'fs'; import { describe, expect, it } from 'vitest'; -import zlib from 'zlib'; -import { compile, processVanillaFile } from '@vanilla-extract/integration'; import { MOBILE_BREAKPOINT } from './hooks/useScreenSize'; - -// The CSS a .css.ts file emits, read back from vanilla-extract's own compile (as pages/auth/footerBand.test.ts does; -// vanilla-extract gzips the inlined source only when it is large, so both forms are read). -const emitted = async (file: string) => { - const filePath = `${process.cwd()}/src/app/${file}`; - const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' }); - const code = await processVanillaFile({ source, filePath, identOption: 'debug' }); - // A file with recipes inlines one source per rule group; read them all. - return [...code.matchAll(/source=#?([A-Za-z0-9+/=]+)/g)] - .map((m) => { - const bytes = Buffer.from(m[1], 'base64'); - return (bytes[0] === 0x1f && bytes[1] === 0x8b ? zlib.gunzipSync(bytes) : bytes).toString(); - }) - .join('\n'); -}; - -type Decls = Record; -const parse = (body: string): Decls => - Object.fromEntries( - body - .split('\n') - .map((l) => l.trim().replace(/;$/, '')) - .filter((l) => /^[a-z-]+: -?\d+px$/.test(l)) - .map((l) => [l.split(':')[0], Number(l.split(': ')[1].replace('px', ''))]) - ); - -/** Pixel declarations of the rules whose selector matches, inside or outside a media query. */ -const rulesFor = (css: string, selector: RegExp, media: string | undefined): Decls[] => { - const scope = media - ? [...css.matchAll(/@media ([^{]+) \{\n([\s\S]*?\n)\}(?:\n|$)/g)] - .filter((m) => m[1] === media) - .map((m) => m[2]) - .join('\n') - : css.replace(/@media [^{]+ \{\n[\s\S]*?\n\}(?:\n|$)/g, ''); - return [...scope.matchAll(/^\s*([^{}\n]+) \{\n([^}]*)\}/gm)] - .filter((m) => selector.test(m[1])) - .map((m) => parse(m[2])); -}; +import { + emitted, + expectPhoneHitClasses, + expectSizeTie, + rulesFor, + type SizeAllow, +} from './phoneHitTestKit'; const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; const TAP = 44; @@ -166,8 +132,11 @@ describe('phone icon-button hit areas', () => { // the menu around them clips a hit area drawn outside its box) // each class owns its own margin: (44 - drawn size) / 2 const own = (size: number) => margins(new RegExp(`PhoneHit_PhoneHitSpread${size}__`)); - expect([own(24), own(26), own(32), own(40)]).toEqual([[10], [9], [6], [2]]); - expect(rulesFor(css, /PhoneHit_PhoneHitRoom26__/, PHONE)).toEqual([{ 'margin-block': 9 }]); + expect([24, 26, 28, 32, 36, 40].map(own)).toEqual([[10], [9], [8], [6], [4], [2]]); + // the Room variants open the block axis by the same amount, on top of their Spread class + const room = (size: number) => + rulesFor(css, new RegExp(`PhoneHit_PhoneHitRoom${size}__`), PHONE); + expect([room(26), room(36)]).toEqual([[{ 'margin-block': 9 }], [{ 'margin-block': 4 }]]); }); it("make a message's reaction chip 44 px to tap by opening 9 px above and below it", async () => { @@ -189,22 +158,6 @@ describe('phone icon-button hit areas', () => { }); describe('icon buttons in the room view', () => { - // The opening tag of every outside any {} expression. - const iconButtonTags = (src: string) => { - const tags: string[] = []; - for (let i = src.indexOf('' && depth === 0) break; - } - tags.push(src.slice(i, j + 1)); - } - return tags; - }; - // The class each IconButton must carry, in source order: the variant is chosen by the button's drawn size // and whether a clipping container or neighbours surround it, so a swapped variant is a wrong hit area. const expected: Record = { @@ -214,7 +167,7 @@ describe('icon buttons in the room view', () => { 'PhoneHitSpread32', // report dialog close ...Array(6).fill('PhoneHitRoom26'), // message options bar, inside a clipping menu ], - 'features/room/RoomInput.tsx': Array(6).fill('PhoneHitSpread32'), + 'features/room/RoomInput.tsx': ['PhoneHitSpread24', ...Array(5).fill('PhoneHitSpread32')], // reply cancel is a 16 px icon, the rest 24 px 'features/room/RoomViewHeader.tsx': Array(6).fill('PhoneHitSpread40'), // The chat path's other buttons. Two neighbours in a row: Spread32. One on its own: the plain PhoneHit. 'features/room/message/MessageEditor.tsx': ['PhoneHitSpread32', 'PhoneHitSpread32'], // toolbar, emoji @@ -232,21 +185,20 @@ describe('icon buttons in the room view', () => { 'pages/auth/login/PasswordLoginForm.tsx': ['PhoneHit'], }; + // IconButtons whose drawn size the source does not say (file -> index in source order -> why). Everything else + // is derived from the size props, so a Spread32 on a 24 px button fails. + const sizeAllow: SizeAllow = { + 'features/room/message/Message.tsx': { + 0: 'quick-reaction button wraps an emoji , no Icon; 32 px by its CSS class', + }, + }; + it.each(Object.entries(expected))( '%s: each IconButton carries its own PhoneHit class', - (file, want) => { - const src = readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8'); - expect( - iconButtonTags(src).map((t) => t.match(/className=\{[^}]*?\b(PhoneHit\w*)\b/)?.[1] ?? null) - ).toEqual(want); - // the name is the one PhoneHit.css.ts exports, not a local of the same name - [...new Set(want)].forEach((name) => - expect(src).toMatch( - new RegExp( - `import \\{(?:[^}]*,)?\\s*${name}\\s*(?:,[^}]*)?\\} from '(\\.\\./)+styles/PhoneHit\\.css'` - ) - ) - ); - } + (file, want) => expectPhoneHitClasses(file, want) + ); + + it.each(Object.keys(expected))('%s: each sized variant equals the px its button draws', (file) => + expectSizeTie(file, sizeAllow) ); }); diff --git a/src/app/styles/PhoneHit.css.ts b/src/app/styles/PhoneHit.css.ts index c388e5f6..189e58d1 100644 --- a/src/app/styles/PhoneHit.css.ts +++ b/src/app/styles/PhoneHit.css.ts @@ -29,12 +29,17 @@ export const PhoneHit = style({ selectors: { '&::after': hitArea }, }); +const spreads = new Map(); + /** An icon button in a row, `visual` px wide: the row opens up so its 44 px hit area meets its neighbours'. */ -const spread = (visual: number) => - style( +const spread = (visual: number) => { + const cls = style( [PhoneHit, { '@media': { [PHONE]: { marginInline: (PHONE_TAP - visual) / 2 } } }], `PhoneHitSpread${visual}` ); + spreads.set(visual, cls); + return cls; +}; export const PhoneHitSpread24 = spread(24); export const PhoneHitSpread26 = spread(26); @@ -44,12 +49,16 @@ export const PhoneHitSpread36 = spread(36); export const PhoneHitSpread40 = spread(40); /** For a button in a clipping container (a Menu's overflow hidden eats a hit area drawn outside the box): the row - * opens up on both axes, so the 44 px sits inside the container. */ -const room = (visual: number, spreadClass: string) => + * opens up on both axes, so the 44 px sits inside the container. It is the Spread class of the same size plus the + * block margin, so the two axes cannot disagree about the size. */ +const room = (visual: number) => style( - [spreadClass, { '@media': { [PHONE]: { marginBlock: (PHONE_TAP - visual) / 2 } } }], + [ + spreads.get(visual) ?? '', + { '@media': { [PHONE]: { marginBlock: (PHONE_TAP - visual) / 2 } } }, + ], `PhoneHitRoom${visual}` ); -export const PhoneHitRoom26 = room(26, PhoneHitSpread26); -export const PhoneHitRoom36 = room(36, PhoneHitSpread36); +export const PhoneHitRoom26 = room(26); +export const PhoneHitRoom36 = room(36); From fe12d8a4b11b9e0862b4c25031939a85c78c277c Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:05:58 +0000 Subject: [PATCH 15/74] Phone hit kit: pin Room composition, spread props are undetermined, tighter tag match Room throws when no Spread of its size exists, and a test reads the compiled class lists to prove Room composes Spread and PhoneHit. Props spread into an IconButton or Icon make the size undetermined (allow-list entry required). 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 --- src/app/phoneHitTestKit.test.ts | 41 ++++++++++++++++++++++ src/app/phoneHitTestKit.ts | 60 +++++++++++++++++++-------------- src/app/phoneTapTargets.test.ts | 15 +++++++++ src/app/styles/PhoneHit.css.ts | 12 +++---- 4 files changed, 97 insertions(+), 31 deletions(-) create mode 100644 src/app/phoneHitTestKit.test.ts diff --git a/src/app/phoneHitTestKit.test.ts b/src/app/phoneHitTestKit.test.ts new file mode 100644 index 00000000..92f1b23f --- /dev/null +++ b/src/app/phoneHitTestKit.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { drawnPx, expectSizeTie, iconButtons } from './phoneHitTestKit'; + +describe('phone hit test kit', () => { + it('reads { + const src = + ''; + expect(iconButtons(src).map((b) => b.tag)).toEqual([ + '', + '', + ]); + }); + + it('derives a drawn size from literal size props, defaults being 400', () => { + expect(drawnPx('', '')).toEqual({ px: 24 }); + expect(drawnPx('', '')).toEqual({ px: 40 }); + }); + + it('reads { + expect( + drawnPx('', '') + ).toEqual({ + px: 24, + }); + }); + + it('calls a size undetermined when props are spread into the IconButton or the Icon', () => { + expect(drawnPx('', '')).toHaveProperty('undetermined'); + expect(drawnPx('', '')).toHaveProperty( + 'undetermined' + ); + expect(drawnPx('', '')).toHaveProperty( + 'undetermined' + ); + expect(drawnPx('', '')).toHaveProperty('undetermined'); + }); + + it('fails a file with no IconButton, which would otherwise pass vacuously', () => { + expect(() => expectSizeTie('styles/PhoneHit.css.ts')).toThrow(/no IconButton/); + }); +}); diff --git a/src/app/phoneHitTestKit.ts b/src/app/phoneHitTestKit.ts index bcd81540..a29dcc22 100644 --- a/src/app/phoneHitTestKit.ts +++ b/src/app/phoneHitTestKit.ts @@ -7,10 +7,23 @@ import { compile, processVanillaFile } from '@vanilla-extract/integration'; // The CSS a .css.ts file emits, read back from vanilla-extract's own compile (as pages/auth/footerBand.test.ts does; // vanilla-extract gzips the inlined source only when it is large, so both forms are read). -export const emitted = async (file: string) => { +const compiled = async (file: string) => { const filePath = `${process.cwd()}/src/app/${file}`; const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' }); - const code = await processVanillaFile({ source, filePath, identOption: 'debug' }); + return processVanillaFile({ source, filePath, identOption: 'debug' }); +}; + +/** The class list each export of a .css.ts file resolves to (a composed class lists the classes it composes). */ +export const classLists = async (file: string): Promise> => + Object.fromEntries( + [...(await compiled(file)).matchAll(/^export var (\w+) = '([^']*)';$/gm)].map((m) => [ + m[1], + m[2].split(' '), + ]) + ); + +export const emitted = async (file: string) => { + const code = await compiled(file); // A file with recipes inlines one source per rule group; read them all. return [...code.matchAll(/source=#?([A-Za-z0-9+/=]+)/g)] .map((m) => { @@ -59,26 +72,18 @@ const tagEnd = (src: string, from: number) => { return j; }; -/** The opening tag of every outside any {} expression. */ -export const iconButtonTags = (src: string) => { - const tags: string[] = []; - for (let i = src.indexOf(' { - const out: { tag: string; body: string }[] = []; - for (let i = src.indexOf(' outside any {} expression, and + * what it wraps (empty when self-closing). */ +export const iconButtons = (src: string) => + [...src.matchAll(//])/g)].map((m) => { + const start = m.index ?? 0; + const end = tagEnd(src, start); + const tag = src.slice(start, end + 1); const close = src.indexOf('', end); - out.push({ tag, body: tag.endsWith('/>') || close === -1 ? '' : src.slice(end + 1, close) }); - } - return out; -}; + return { tag, body: tag.endsWith('/>') || close === -1 ? '' : src.slice(end + 1, close) }; + }); + +export const iconButtonTags = (src: string) => iconButtons(src).map((b) => b.tag); const phoneHitOf = (tag: string) => tag.match(/className=\{[^}]*?\b(PhoneHit\w*)\b/)?.[1] ?? null; @@ -110,6 +115,8 @@ const ICON_PX: Record = { /** The size prop of a tag: its literal value, the default when absent, or undefined when it is an expression. */ const sizeProp = (tag: string) => { + // props spread in may carry a size of their own + if (tag.includes('{...')) return undefined; const m = tag.match(/\ssize=(?:"(\w+)"|\{([^}]*)\})/); if (!m) return '400'; return m[1] ?? m[2].match(/^'(\w+)'$/)?.[1]; @@ -119,14 +126,15 @@ const sizeProp = (tag: string) => { * does not say (a variable size, no Icon, Icons of different sizes). */ export const drawnPx = (tag: string, body: string): { px: number } | { undetermined: string } => { const pad = BUTTON_PAD[sizeProp(tag) ?? '']; - if (pad === undefined) return { undetermined: 'size of the IconButton is not a literal' }; - const icons = [...body.matchAll(/ { + if (pad === undefined) + return { undetermined: 'size of the IconButton is not a literal (or props are spread in)' }; + const icons = [...body.matchAll(//])/g)].map((m) => { const t = body.slice(m.index, tagEnd(body, m.index ?? 0) + 1); return ICON_PX[sizeProp(t) ?? '']; }); if (icons.length === 0) return { undetermined: 'no inside' }; if (icons.some((p) => p === undefined)) - return { undetermined: 'size of an Icon is not a literal' }; + return { undetermined: 'size of an Icon is not a literal (or props are spread in)' }; if (new Set(icons).size > 1) return { undetermined: 'Icons of different sizes inside' }; return { px: icons[0] + 2 * pad }; }; @@ -139,7 +147,9 @@ export type SizeAllow = Record>; export const expectSizeTie = (file: string, allow: SizeAllow = {}) => { const allowed = allow[file] ?? {}; const seen = new Set(); - iconButtons(readSource(file)).forEach(({ tag, body }, i) => { + const buttons = iconButtons(readSource(file)); + expect(buttons.length, `${file} has no IconButton`).toBeGreaterThan(0); + buttons.forEach(({ tag, body }, i) => { const variant = phoneHitOf(tag)?.match(/^PhoneHit(?:Spread|Room)(\d+)$/); if (!variant) { expect(allowed[i], `${file} #${i} is allow-listed but has no sized variant`).toBeUndefined(); diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index f2166cad..5a5394e7 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { MOBILE_BREAKPOINT } from './hooks/useScreenSize'; import { + classLists, emitted, expectPhoneHitClasses, expectSizeTie, @@ -139,6 +140,20 @@ describe('phone icon-button hit areas', () => { expect([room(26), room(36)]).toEqual([[{ 'margin-block': 9 }], [{ 'margin-block': 4 }]]); }); + it('compose a Room variant from the Spread class of the same size, which composes PhoneHit', async () => { + const lists = await classLists('styles/PhoneHit.css.ts'); + const named = (list: string[], name: string) => list.filter((c) => c.includes(`_${name}__`)); + [26, 36].forEach((size) => { + const room = lists[`PhoneHitRoom${size}`]; + expect( + named(room, `PhoneHitSpread${size}`), + `Room${size} composes Spread${size}` + ).toHaveLength(1); + expect(named(room, 'PhoneHit'), `Room${size} composes PhoneHit`).toHaveLength(1); + }); + expect(lists.PhoneHitRoom36.some((c) => c.includes('_PhoneHitSpread26__'))).toBe(false); + }); + it("make a message's reaction chip 44 px to tap by opening 9 px above and below it", async () => { const css = await emitted('components/message/Reaction.css.ts'); const [chip] = rulesFor(css, /^button\.Reaction_Reaction__\w+$/, PHONE); diff --git a/src/app/styles/PhoneHit.css.ts b/src/app/styles/PhoneHit.css.ts index 189e58d1..9863851b 100644 --- a/src/app/styles/PhoneHit.css.ts +++ b/src/app/styles/PhoneHit.css.ts @@ -51,14 +51,14 @@ export const PhoneHitSpread40 = spread(40); /** For a button in a clipping container (a Menu's overflow hidden eats a hit area drawn outside the box): the row * opens up on both axes, so the 44 px sits inside the container. It is the Spread class of the same size plus the * block margin, so the two axes cannot disagree about the size. */ -const room = (visual: number) => - style( - [ - spreads.get(visual) ?? '', - { '@media': { [PHONE]: { marginBlock: (PHONE_TAP - visual) / 2 } } }, - ], +const room = (visual: number) => { + const spreadClass = spreads.get(visual); + if (!spreadClass) throw new Error(`PhoneHitRoom${visual} needs a PhoneHitSpread${visual}`); + return style( + [spreadClass, { '@media': { [PHONE]: { marginBlock: (PHONE_TAP - visual) / 2 } } }], `PhoneHitRoom${visual}` ); +}; export const PhoneHitRoom26 = room(26); export const PhoneHitRoom36 = room(36); From 351d1bc74539201221ea428b8aeb975af2111d14 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:15:10 +0000 Subject: [PATCH 16/74] Phone hit: call controls get their hit-area variants, with a test pinning them Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/call-status/CallControl.tsx | 5 +++++ src/app/features/call/CallControls.tsx | 2 ++ src/app/features/call/Controls.tsx | 6 ++++++ src/app/phoneHit.call.test.ts | 15 +++++++++++++++ 4 files changed, 28 insertions(+) create mode 100644 src/app/phoneHit.call.test.ts diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx index 3cf38b1f..d63f8709 100644 --- a/src/app/features/call-status/CallControl.tsx +++ b/src/app/features/call-status/CallControl.tsx @@ -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) => ( {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( { + it.each([ + ['features/call/Controls.tsx', Array(5).fill('PhoneHitSpread40')], + ['features/call/CallControls.tsx', ['PhoneHitSpread40']], + ['features/call-status/CallControl.tsx', Array(4).fill('PhoneHitSpread26')], + ])('%s', (file, want) => { + expectPhoneHitClasses(file as string, want as string[]); + expectSizeTie(file as string); + }); +}); From b8bef8c1751bf744190a1b788f92c980c166b45e Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:15:25 +0000 Subject: [PATCH 17/74] Phone hit: settings A icon buttons get PhoneHit, with a pinning test Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/settings/Settings.tsx | 3 +- src/app/features/settings/about/About.tsx | 3 +- src/app/features/settings/account/Account.tsx | 3 +- .../settings/developer-tools/DevelopTools.tsx | 3 +- src/app/features/settings/devices/Devices.tsx | 3 +- .../emojis-stickers/EmojisStickers.tsx | 3 +- .../settings/emojis-stickers/GlobalPacks.tsx | 3 ++ src/app/features/settings/general/General.tsx | 5 +++- .../notifications/KeywordMessages.tsx | 11 ++++++- .../settings/notifications/Notifications.tsx | 3 +- src/app/phoneHit.settingsA.test.ts | 29 +++++++++++++++++++ 11 files changed, 60 insertions(+), 9 deletions(-) create mode 100644 src/app/phoneHit.settingsA.test.ts diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx index 5e1a20f4..6c91bc9d 100644 --- a/src/app/features/settings/Settings.tsx +++ b/src/app/features/settings/Settings.tsx @@ -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) { {screenSize === ScreenSize.Mobile && ( - + )} diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx index 9ec74305..19407bdf 100644 --- a/src/app/features/settings/about/About.tsx +++ b/src/app/features/settings/about/About.tsx @@ -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) { - + diff --git a/src/app/features/settings/account/Account.tsx b/src/app/features/settings/account/Account.tsx index c4b56e47..77ee8671 100644 --- a/src/app/features/settings/account/Account.tsx +++ b/src/app/features/settings/account/Account.tsx @@ -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) { - + diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index a3f04567..7203fdc5 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -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) { - + diff --git a/src/app/features/settings/devices/Devices.tsx b/src/app/features/settings/devices/Devices.tsx index c957ab31..9e5821ba 100644 --- a/src/app/features/settings/devices/Devices.tsx +++ b/src/app/features/settings/devices/Devices.tsx @@ -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) { - + diff --git a/src/app/features/settings/emojis-stickers/EmojisStickers.tsx b/src/app/features/settings/emojis-stickers/EmojisStickers.tsx index 93715120..2c2193a9 100644 --- a/src/app/features/settings/emojis-stickers/EmojisStickers.tsx +++ b/src/app/features/settings/emojis-stickers/EmojisStickers.tsx @@ -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) { - + diff --git a/src/app/features/settings/emojis-stickers/GlobalPacks.tsx b/src/app/features/settings/emojis-stickers/GlobalPacks.tsx index a9288728..38647b13 100644 --- a/src/app/features/settings/emojis-stickers/GlobalPacks.tsx +++ b/src/app/features/settings/emojis-stickers/GlobalPacks.tsx @@ -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) { {removed ? ( ) : ( ; @@ -497,6 +498,7 @@ function DateHint({ hasChanges, handleReset }: DateHintProps) { > {hasChanges ? ( ) : ( - + diff --git a/src/app/features/settings/notifications/KeywordMessages.tsx b/src/app/features/settings/notifications/KeywordMessages.tsx index 7f84d607..b77cae21 100644 --- a/src/app/features/settings/notifications/KeywordMessages.tsx +++ b/src/app/features/settings/notifications/KeywordMessages.tsx @@ -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 && ( + {removing ? : } ); diff --git a/src/app/features/settings/notifications/Notifications.tsx b/src/app/features/settings/notifications/Notifications.tsx index 095a9bba..61af1a09 100644 --- a/src/app/features/settings/notifications/Notifications.tsx +++ b/src/app/features/settings/notifications/Notifications.tsx @@ -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) { - + diff --git a/src/app/phoneHit.settingsA.test.ts b/src/app/phoneHit.settingsA.test.ts new file mode 100644 index 00000000..37f1f3c2 --- /dev/null +++ b/src/app/phoneHit.settingsA.test.ts @@ -0,0 +1,29 @@ +import { describe, it } from 'vitest'; +import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; + +describe('icon buttons in settings A', () => { + // The class each IconButton must carry, in source order. Every one is alone in its row (a header close next to + // non-tappable title text, an input slot, or a list tile beside a non-tappable Avatar), so the plain PhoneHit. + const expected: Record = { + 'features/settings/developer-tools/DevelopTools.tsx': ['PhoneHit'], // header close, 40 px + 'features/settings/notifications/Notifications.tsx': ['PhoneHit'], // header close, 40 px + 'features/settings/notifications/KeywordMessages.tsx': ['PhoneHit', 'PhoneHit'], // input reset, remove: 26 px, alone + 'features/settings/emojis-stickers/EmojisStickers.tsx': ['PhoneHit'], // header close, 40 px + 'features/settings/emojis-stickers/GlobalPacks.tsx': ['PhoneHit', 'PhoneHit'], // undo / remove, a ternary pair, 26 px + 'features/settings/Settings.tsx': ['PhoneHit'], // mobile-only header close, 40 px + 'features/settings/about/About.tsx': ['PhoneHit'], // header close, 40 px + 'features/settings/general/General.tsx': ['PhoneHit', 'PhoneHit', 'PhoneHit'], // reset / info (ternary), header close + 'features/settings/account/Account.tsx': ['PhoneHit'], // header close, 40 px + 'features/settings/devices/Devices.tsx': ['PhoneHit'], // header close, 40 px + }; + + it.each(Object.entries(expected))('%s carries the right hit-area class', (file, want) => { + expectPhoneHitClasses(file, want); + }); + + // Plain PhoneHit has no size to tie, so nothing is allow-listed; the check still fails an allow-list entry + // or a sized variant that disagrees with the drawn size if one is added later. + it.each(Object.keys(expected))('%s ties a sized variant to the drawn size', (file) => { + expectSizeTie(file); + }); +}); From 0b40f4c66fbb45cee915d432479edbca3ae4b2bf Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:15:44 +0000 Subject: [PATCH 18/74] Phone hit: PhoneHit on the ten dialog and prompt close buttons, with a pinning test Co-Authored-By: Claude Sonnet 5.5 --- .../components/event-readers/EventReaders.tsx | 3 +- .../invite-user-prompt/InviteUserPrompt.tsx | 3 +- .../join-address-prompt/JoinAddressPrompt.tsx | 3 +- .../leave-room-prompt/LeaveRoomPrompt.tsx | 3 +- .../leave-space-prompt/LeaveSpacePrompt.tsx | 3 +- .../room-topic-viewer/RoomTopicViewer.tsx | 3 +- src/app/components/user-profile/PowerChip.tsx | 5 ++-- src/app/features/add-existing/AddExisting.tsx | 3 +- .../features/create-room/CreateRoomModal.tsx | 3 +- .../create-space/CreateSpaceModal.tsx | 3 +- src/app/phoneHit.dialogs.test.ts | 28 +++++++++++++++++++ 11 files changed, 49 insertions(+), 11 deletions(-) create mode 100644 src/app/phoneHit.dialogs.test.ts diff --git a/src/app/components/event-readers/EventReaders.tsx b/src/app/components/event-readers/EventReaders.tsx index c7900237..72b188ce 100644 --- a/src/app/components/event-readers/EventReaders.tsx +++ b/src/app/components/event-readers/EventReaders.tsx @@ -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>( Seen by - + diff --git a/src/app/components/invite-user-prompt/InviteUserPrompt.tsx b/src/app/components/invite-user-prompt/InviteUserPrompt.tsx index ddac0576..a503fe13 100644 --- a/src/app/components/invite-user-prompt/InviteUserPrompt.tsx +++ b/src/app/components/invite-user-prompt/InviteUserPrompt.tsx @@ -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) { - + diff --git a/src/app/components/join-address-prompt/JoinAddressPrompt.tsx b/src/app/components/join-address-prompt/JoinAddressPrompt.tsx index 50a89418..ffd5241c 100644 --- a/src/app/components/join-address-prompt/JoinAddressPrompt.tsx +++ b/src/app/components/join-address-prompt/JoinAddressPrompt.tsx @@ -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) { Join with Address - + diff --git a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx index 217491e6..832dd59d 100644 --- a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx +++ b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx @@ -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 Leave Room - + diff --git a/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx b/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx index 8709b942..6c75ef99 100644 --- a/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx +++ b/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx @@ -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 Leave Space - + diff --git a/src/app/components/room-topic-viewer/RoomTopicViewer.tsx b/src/app/components/room-topic-viewer/RoomTopicViewer.tsx index 52128772..0d091e7e 100644 --- a/src/app/components/room-topic-viewer/RoomTopicViewer.tsx +++ b/src/app/components/room-topic-viewer/RoomTopicViewer.tsx @@ -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} - + diff --git a/src/app/components/user-profile/PowerChip.tsx b/src/app/components/user-profile/PowerChip.tsx index a6758300..d3e9cf28 100644 --- a/src/app/components/user-profile/PowerChip.tsx +++ b/src/app/components/user-profile/PowerChip.tsx @@ -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) { Self Demotion - + @@ -120,7 +121,7 @@ function SharedPowerAlert({ power, onCancel, onChange }: SharedPowerAlertProps) Shared Power - + diff --git a/src/app/features/add-existing/AddExisting.tsx b/src/app/features/add-existing/AddExisting.tsx index d100096b..a57a6b10 100644 --- a/src/app/features/add-existing/AddExisting.tsx +++ b/src/app/features/add-existing/AddExisting.tsx @@ -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 Add Existing - + diff --git a/src/app/features/create-room/CreateRoomModal.tsx b/src/app/features/create-room/CreateRoomModal.tsx index d5d8c92d..9332f35c 100644 --- a/src/app/features/create-room/CreateRoomModal.tsx +++ b/src/app/features/create-room/CreateRoomModal.tsx @@ -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) { - + diff --git a/src/app/features/create-space/CreateSpaceModal.tsx b/src/app/features/create-space/CreateSpaceModal.tsx index c1bc6898..faa53b25 100644 --- a/src/app/features/create-space/CreateSpaceModal.tsx +++ b/src/app/features/create-space/CreateSpaceModal.tsx @@ -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) { New Space - + diff --git a/src/app/phoneHit.dialogs.test.ts b/src/app/phoneHit.dialogs.test.ts new file mode 100644 index 00000000..8b3416a5 --- /dev/null +++ b/src/app/phoneHit.dialogs.test.ts @@ -0,0 +1,28 @@ +import { describe, it } from 'vitest'; +import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; + +// Dialog and prompt close buttons: each is a 32 px button (24 px Icon + 8 px padding) alone at the end of a +// header whose title is not tappable, so the plain PhoneHit; no neighbour to spread from, no clipping container. +describe('icon buttons in dialogs and prompts', () => { + const expected: Record = { + 'features/create-space/CreateSpaceModal.tsx': ['PhoneHit'], + 'features/create-room/CreateRoomModal.tsx': ['PhoneHit'], + 'features/add-existing/AddExisting.tsx': ['PhoneHit'], + 'components/room-topic-viewer/RoomTopicViewer.tsx': ['PhoneHit'], + 'components/leave-room-prompt/LeaveRoomPrompt.tsx': ['PhoneHit'], + 'components/leave-space-prompt/LeaveSpacePrompt.tsx': ['PhoneHit'], + 'components/event-readers/EventReaders.tsx': ['PhoneHit'], + 'components/invite-user-prompt/InviteUserPrompt.tsx': ['PhoneHit'], + 'components/join-address-prompt/JoinAddressPrompt.tsx': ['PhoneHit'], // join cancel + 'components/user-profile/PowerChip.tsx': ['PhoneHit', 'PhoneHit'], // self demotion, shared power + }; + + it.each(Object.entries(expected))( + '%s: each IconButton carries its own PhoneHit class', + (file, want) => expectPhoneHitClasses(file, want) + ); + + it.each(Object.keys(expected))('%s: a sized variant matches the drawn size', (file) => + expectSizeTie(file) + ); +}); From cd90a044ed8524e4e38e59cfa2418fdf250a500c Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:16:02 +0000 Subject: [PATCH 19/74] Phone hit: page back buttons and Lobby/Notifications lone buttons Co-Authored-By: Claude Sonnet 5.5 --- .../JoinBeforeNavigate.tsx | 3 +- src/app/features/lobby/LobbyHeader.tsx | 4 ++- src/app/pages/client/direct/DirectCreate.tsx | 3 +- src/app/pages/client/explore/Featured.tsx | 3 +- src/app/pages/client/explore/Server.tsx | 3 +- src/app/pages/client/home/CreateRoom.tsx | 3 +- src/app/pages/client/home/Search.tsx | 3 +- src/app/pages/client/inbox/Invites.tsx | 3 +- src/app/pages/client/inbox/Notifications.tsx | 4 ++- src/app/pages/client/space/Search.tsx | 3 +- src/app/phoneHit.pages.test.ts | 29 +++++++++++++++++++ src/app/phoneHitTestKit.ts | 4 +-- 12 files changed, 53 insertions(+), 12 deletions(-) create mode 100644 src/app/phoneHit.pages.test.ts diff --git a/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx b/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx index 028cd560..43576cff 100644 --- a/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx +++ b/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx @@ -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 && ( {(onBack) => ( - + )} diff --git a/src/app/features/lobby/LobbyHeader.tsx b/src/app/features/lobby/LobbyHeader.tsx index ed526c68..ce248fe5 100644 --- a/src/app/features/lobby/LobbyHeader.tsx +++ b/src/app/features/lobby/LobbyHeader.tsx @@ -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) { {(onBack) => ( - + )} @@ -240,6 +241,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) { > {(triggerRef) => ( {(onBack) => ( - + )} diff --git a/src/app/pages/client/explore/Featured.tsx b/src/app/pages/client/explore/Featured.tsx index f056cbb5..c28fad0a 100644 --- a/src/app/pages/client/explore/Featured.tsx +++ b/src/app/pages/client/explore/Featured.tsx @@ -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() { {(onBack) => ( - + )} diff --git a/src/app/pages/client/explore/Server.tsx b/src/app/pages/client/explore/Server.tsx index 48f267cc..036c6488 100644 --- a/src/app/pages/client/explore/Server.tsx +++ b/src/app/pages/client/explore/Server.tsx @@ -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 && ( {(onBack) => ( - + )} diff --git a/src/app/pages/client/home/CreateRoom.tsx b/src/app/pages/client/home/CreateRoom.tsx index fddd75aa..8e277889 100644 --- a/src/app/pages/client/home/CreateRoom.tsx +++ b/src/app/pages/client/home/CreateRoom.tsx @@ -12,6 +12,7 @@ import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; import { CreateRoomForm } from '../../../features/create-room'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; export function HomeCreateRoom() { const screenSize = useScreenSizeContext(); @@ -25,7 +26,7 @@ export function HomeCreateRoom() { {(onBack) => ( - + )} diff --git a/src/app/pages/client/home/Search.tsx b/src/app/pages/client/home/Search.tsx index d5ddfb77..aa454d2c 100644 --- a/src/app/pages/client/home/Search.tsx +++ b/src/app/pages/client/home/Search.tsx @@ -5,6 +5,7 @@ import { MessageSearch } from '../../../features/message-search'; import { useHomeRooms } from './useHomeRooms'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; export function HomeSearch() { const scrollRef = useRef(null); @@ -19,7 +20,7 @@ export function HomeSearch() { {screenSize === ScreenSize.Mobile && ( {(onBack) => ( - + )} diff --git a/src/app/pages/client/inbox/Invites.tsx b/src/app/pages/client/inbox/Invites.tsx index ca26bac3..4d1580af 100644 --- a/src/app/pages/client/inbox/Invites.tsx +++ b/src/app/pages/client/inbox/Invites.tsx @@ -63,6 +63,7 @@ import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers'; import { useReportRoomSupported } from '../../../hooks/useReportRoomSupported'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; const COMPACT_CARD_WIDTH = 548; @@ -745,7 +746,7 @@ export function Invites() { {screenSize === ScreenSize.Mobile && ( {(onBack) => ( - + )} diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index 9592224f..8fddba44 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -97,6 +97,7 @@ import { } from '../../../hooks/useMemberPowerTag'; import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type RoomNotificationsGroup = { roomId: string; @@ -642,7 +643,7 @@ export function Notifications() { {screenSize === ScreenSize.Mobile && ( {(onBack) => ( - + )} @@ -694,6 +695,7 @@ export function Notifications() { onVisibilityChange={handleScrollTopVisibility} > virtualizer.scrollToOffset(0)} variant="SurfaceVariant" radii="Pill" diff --git a/src/app/pages/client/space/Search.tsx b/src/app/pages/client/space/Search.tsx index 017262b5..a2849ab8 100644 --- a/src/app/pages/client/space/Search.tsx +++ b/src/app/pages/client/space/Search.tsx @@ -11,6 +11,7 @@ import { roomToParentsAtom } from '../../../state/room/roomToParents'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; export function SpaceSearch() { const mx = useMatrixClient(); @@ -34,7 +35,7 @@ export function SpaceSearch() { {screenSize === ScreenSize.Mobile && ( {(onBack) => ( - + )} diff --git a/src/app/phoneHit.pages.test.ts b/src/app/phoneHit.pages.test.ts new file mode 100644 index 00000000..97eb77b8 --- /dev/null +++ b/src/app/phoneHit.pages.test.ts @@ -0,0 +1,29 @@ +import { describe, it } from 'vitest'; +import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; + +// Page back buttons (and two lone buttons beside them). Every one is alone in its row, 40 px drawn (default +// IconButton and Icon), so plain PhoneHit; the vertical stacks of the page have no variant. +// null = deliberately without: LobbyHeader's Members button renders only when screenSize !== Mobile. +const files: Record = { + 'features/lobby/LobbyHeader.tsx': ['PhoneHit', null, 'PhoneHit'], + 'features/join-before-navigate/JoinBeforeNavigate.tsx': ['PhoneHit'], + 'pages/client/space/Search.tsx': ['PhoneHit'], + 'pages/client/explore/Server.tsx': ['PhoneHit'], + 'pages/client/explore/Featured.tsx': ['PhoneHit'], + 'pages/client/home/CreateRoom.tsx': ['PhoneHit'], + 'pages/client/home/Search.tsx': ['PhoneHit'], + // the second is the 30 px scroll-to-top pill, floating alone at the top centre + 'pages/client/inbox/Notifications.tsx': ['PhoneHit', 'PhoneHit'], + 'pages/client/inbox/Invites.tsx': ['PhoneHit'], + 'pages/client/direct/DirectCreate.tsx': ['PhoneHit'], +}; + +describe.each(Object.entries(files))('phone hit area: %s', (file, want) => { + it('gives each IconButton its variant', () => { + expectPhoneHitClasses(file, want); + }); + // plain PhoneHit has no size to tie; this fails when a sized variant appears whose number is not the drawn px + it('ties any sized variant to the drawn size', () => { + expectSizeTie(file); + }); +}); diff --git a/src/app/phoneHitTestKit.ts b/src/app/phoneHitTestKit.ts index a29dcc22..46963024 100644 --- a/src/app/phoneHitTestKit.ts +++ b/src/app/phoneHitTestKit.ts @@ -90,10 +90,10 @@ const phoneHitOf = (tag: string) => tag.match(/className=\{[^}]*?\b(PhoneHit\w*) /** The class each IconButton must carry, in source order: the variant is chosen by the button's drawn size and * whether a clipping container or neighbours surround it, so a swapped variant is a wrong hit area. Also checks * the name is the one PhoneHit.css.ts exports, not a local of the same name. */ -export const expectPhoneHitClasses = (file: string, want: string[]) => { +export const expectPhoneHitClasses = (file: string, want: (string | null)[]) => { const src = readSource(file); expect(iconButtonTags(src).map(phoneHitOf)).toEqual(want); - [...new Set(want)].forEach((name) => + [...new Set(want.filter((w): w is string => w !== null))].forEach((name) => expect(src).toMatch( new RegExp( `import \\{(?:[^}]*,)?\\s*${name}\\s*(?:,[^}]*)?\\} from '(\\.\\./)+styles/PhoneHit\\.css'` From e209688de10cce5c91575de6a3410d8bcf7d2901 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:16:02 +0000 Subject: [PATCH 20/74] Phone hit: common-settings IconButtons get PhoneHit, with a pinning test Co-Authored-By: Claude Sonnet 5.5 --- .../developer-tools/DevelopTools.tsx | 3 ++- .../developer-tools/SendRoomEvent.tsx | 3 ++- .../developer-tools/StateEventEditor.tsx | 3 ++- .../emojis-stickers/EmojisStickers.tsx | 3 ++- .../emojis-stickers/RoomPacks.tsx | 3 +++ .../general/RoomEncryption.tsx | 8 +++++- .../common-settings/general/RoomUpgrade.tsx | 3 ++- .../common-settings/members/Members.tsx | 4 ++- .../permissions/PowersEditor.tsx | 3 ++- src/app/phoneHit.commonSettings.test.ts | 26 +++++++++++++++++++ 10 files changed, 51 insertions(+), 8 deletions(-) create mode 100644 src/app/phoneHit.commonSettings.test.ts diff --git a/src/app/features/common-settings/developer-tools/DevelopTools.tsx b/src/app/features/common-settings/developer-tools/DevelopTools.tsx index 29b6aa51..61e05a62 100644 --- a/src/app/features/common-settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/common-settings/developer-tools/DevelopTools.tsx @@ -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) { - + diff --git a/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx b/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx index f25ba7c3..a6e60a7b 100644 --- a/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx +++ b/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx @@ -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 - + diff --git a/src/app/features/common-settings/developer-tools/StateEventEditor.tsx b/src/app/features/common-settings/developer-tools/StateEventEditor.tsx index 0ea96901..0f9f4034 100644 --- a/src/app/features/common-settings/developer-tools/StateEventEditor.tsx +++ b/src/app/features/common-settings/developer-tools/StateEventEditor.tsx @@ -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 - + diff --git a/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx b/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx index ad8ffae6..f7e901d5 100644 --- a/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx +++ b/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx @@ -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) { - + diff --git a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx index fdbe546e..7be8de35 100644 --- a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx +++ b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx @@ -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 ? ( ) : ( Enable Encryption - setPrompt(false)} radii="300"> + setPrompt(false)} + radii="300" + > diff --git a/src/app/features/common-settings/general/RoomUpgrade.tsx b/src/app/features/common-settings/general/RoomUpgrade.tsx index 45a480aa..2971d565 100644 --- a/src/app/features/common-settings/general/RoomUpgrade.tsx +++ b/src/app/features/common-settings/general/RoomUpgrade.tsx @@ -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 }) { {room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'} - + diff --git a/src/app/features/common-settings/members/Members.tsx b/src/app/features/common-settings/members/Members.tsx index 15cd1d08..a1dddb54 100644 --- a/src/app/features/common-settings/members/Members.tsx +++ b/src/app/features/common-settings/members/Members.tsx @@ -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) { - + @@ -277,6 +278,7 @@ export function Members({ requestClose }: MembersProps) { anchorRef={scrollTopAnchorRef} > virtualizer.scrollToOffset(0)} variant="Surface" radii="Pill" diff --git a/src/app/features/common-settings/permissions/PowersEditor.tsx b/src/app/features/common-settings/permissions/PowersEditor.tsx index 97305946..0684a48a 100644 --- a/src/app/features/common-settings/permissions/PowersEditor.tsx +++ b/src/app/features/common-settings/permissions/PowersEditor.tsx @@ -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) { - + diff --git a/src/app/phoneHit.commonSettings.test.ts b/src/app/phoneHit.commonSettings.test.ts new file mode 100644 index 00000000..fdb2c4e0 --- /dev/null +++ b/src/app/phoneHit.commonSettings.test.ts @@ -0,0 +1,26 @@ +import { describe, it } from 'vitest'; +import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; + +// Every IconButton under features/common-settings, in source order. Header close buttons are alone at the +// header end (the title beside them is text), the dialog close buttons are alone in their Header, the +// scroll-to-top button floats alone, and the RoomPacks pair is a ternary (only one renders): all PhoneHit. +const files: [string, string[]][] = [ + ['features/common-settings/members/Members.tsx', ['PhoneHit', 'PhoneHit']], + ['features/common-settings/developer-tools/SendRoomEvent.tsx', ['PhoneHit']], + ['features/common-settings/developer-tools/StateEventEditor.tsx', ['PhoneHit']], + ['features/common-settings/developer-tools/DevelopTools.tsx', ['PhoneHit']], + ['features/common-settings/permissions/PowersEditor.tsx', ['PhoneHit']], + ['features/common-settings/emojis-stickers/EmojisStickers.tsx', ['PhoneHit']], + ['features/common-settings/emojis-stickers/RoomPacks.tsx', ['PhoneHit', 'PhoneHit']], + ['features/common-settings/general/RoomEncryption.tsx', ['PhoneHit']], + ['features/common-settings/general/RoomUpgrade.tsx', ['PhoneHit']], +]; + +describe.each(files)('phone hit area: %s', (file, want) => { + it('carries the variant its drawn size and neighbours call for', () => { + expectPhoneHitClasses(file, want); + }); + it('names a size that matches what it draws', () => { + expectSizeTie(file); + }); +}); From 200cf8fdb86e9cf26d8dd2f4be08738dfa6bdd21 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:16:07 +0000 Subject: [PATCH 21/74] Phone hit: viewer headers and UIA stages 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 --- src/app/components/Pdf-viewer/PdfViewer.tsx | 5 +++- src/app/components/UIAFlowOverlay.tsx | 2 ++ .../components/image-editor/ImageEditor.tsx | 3 ++- .../components/image-viewer/ImageViewer.tsx | 5 +++- src/app/components/text-viewer/TextViewer.tsx | 3 ++- .../components/uia-stages/PasswordStage.tsx | 3 ++- src/app/components/uia-stages/SSOStage.tsx | 3 ++- src/app/phoneHit.viewers.test.ts | 27 +++++++++++++++++++ 8 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 src/app/phoneHit.viewers.test.ts diff --git a/src/app/components/Pdf-viewer/PdfViewer.tsx b/src/app/components/Pdf-viewer/PdfViewer.tsx index 9c7fd980..d0e5f715 100644 --- a/src/app/components/Pdf-viewer/PdfViewer.tsx +++ b/src/app/components/Pdf-viewer/PdfViewer.tsx @@ -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>(
- + @@ -117,6 +118,7 @@ export const PdfViewer = as<'div', PdfViewerProps>( ( {Math.round(zoom * 100)}% 1 ? 'Success' : 'SurfaceVariant'} outlined={zoom > 1} size="300" diff --git a/src/app/components/UIAFlowOverlay.tsx b/src/app/components/UIAFlowOverlay.tsx index a2bdd1b8..49e1d821 100644 --- a/src/app/components/UIAFlowOverlay.tsx +++ b/src/app/components/UIAFlowOverlay.tsx @@ -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) => ( ( >
- + diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 4956a7b6..4d2d7694 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -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>( >
- + @@ -42,6 +43,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( ( {Math.round(zoom * 100)}% 1 ? 'Success' : 'SurfaceVariant'} outlined={zoom > 1} size="300" diff --git a/src/app/components/text-viewer/TextViewer.tsx b/src/app/components/text-viewer/TextViewer.tsx index ec4ed0a5..e9e150b5 100644 --- a/src/app/components/text-viewer/TextViewer.tsx +++ b/src/app/components/text-viewer/TextViewer.tsx @@ -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>( >
- + diff --git a/src/app/components/uia-stages/PasswordStage.tsx b/src/app/components/uia-stages/PasswordStage.tsx index 0164cae6..a7ca2d06 100644 --- a/src/app/components/uia-stages/PasswordStage.tsx +++ b/src/app/components/uia-stages/PasswordStage.tsx @@ -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({ Account Password - +
diff --git a/src/app/components/uia-stages/SSOStage.tsx b/src/app/components/uia-stages/SSOStage.tsx index e3d6126a..20decb6c 100644 --- a/src/app/components/uia-stages/SSOStage.tsx +++ b/src/app/components/uia-stages/SSOStage.tsx @@ -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({ SSO Login - +
diff --git a/src/app/phoneHit.viewers.test.ts b/src/app/phoneHit.viewers.test.ts new file mode 100644 index 00000000..3e579f4c --- /dev/null +++ b/src/app/phoneHit.viewers.test.ts @@ -0,0 +1,27 @@ +import { describe, it } from 'vitest'; +import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; + +// Viewer headers and the UIA stages. The back/close button sits beside a title that is not tappable, so it is a +// plain PhoneHit; the zoom out/in buttons sit either side of a tappable zoom Chip, so they spread at their drawn +// 24 px (IconButton size 300 around a 16 px Icon). +// Not covered: the two scroll arrows of components/url-preview/UrlPreviewCard.tsx. They are position: absolute and +// PhoneHit sets position: relative on phones, which would pull them into the flow; they need a variant that keeps +// the position, which PhoneHit.css.ts does not have yet. +const files: [string, string[]][] = [ + ['components/uia-stages/PasswordStage.tsx', ['PhoneHit']], + ['components/uia-stages/SSOStage.tsx', ['PhoneHit']], + ['components/UIAFlowOverlay.tsx', ['PhoneHit']], + ['components/text-viewer/TextViewer.tsx', ['PhoneHit']], + ['components/image-editor/ImageEditor.tsx', ['PhoneHit']], + ['components/image-viewer/ImageViewer.tsx', ['PhoneHit', 'PhoneHitSpread24', 'PhoneHitSpread24']], + ['components/Pdf-viewer/PdfViewer.tsx', ['PhoneHit', 'PhoneHitSpread24', 'PhoneHitSpread24']], +]; + +describe.each(files)('phone hit area: %s', (file, want) => { + it('gives each icon button the variant of its surroundings', () => { + expectPhoneHitClasses(file, want); + }); + it('ties each sized variant to the size the button draws', () => { + expectSizeTie(file); + }); +}); From 32dc46c21f465e36928f5ba0f717d4ed4dfe16e9 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:16:11 +0000 Subject: [PATCH 22/74] Phone hit: PhoneHit on the settings B, auth and verification icon buttons Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/BackupRestore.tsx | 2 ++ src/app/components/DeviceVerification.tsx | 3 ++- .../components/DeviceVerificationSetup.tsx | 5 +++-- .../settings/account/IgnoredUserList.tsx | 2 ++ src/app/features/settings/account/Profile.tsx | 9 +++++++- .../features/settings/devices/DeviceTile.tsx | 2 ++ .../settings/devices/Verification.tsx | 2 ++ src/app/pages/auth/ServerPicker.tsx | 2 ++ src/app/phoneHit.settingsB.test.ts | 21 +++++++++++++++++++ 9 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 src/app/phoneHit.settingsB.test.ts diff --git a/src/app/components/BackupRestore.tsx b/src/app/components/BackupRestore.tsx index 068f437b..57a18674 100644 --- a/src/app/components/BackupRestore.tsx +++ b/src/app/components/BackupRestore.tsx @@ -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) { )} Device Verification
- +
diff --git a/src/app/components/DeviceVerificationSetup.tsx b/src/app/components/DeviceVerificationSetup.tsx index 433fa6a1..68f42221 100644 --- a/src/app/components/DeviceVerificationSetup.tsx +++ b/src/app/components/DeviceVerificationSetup.tsx @@ -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 = ( authDict: AuthDict | null @@ -301,7 +302,7 @@ export const DeviceVerificationSetup = forwardRef Setup Device Verification
- +
@@ -336,7 +337,7 @@ export const DeviceVerificationReset = forwardRef Reset Device Verification
- + diff --git a/src/app/features/settings/account/IgnoredUserList.tsx b/src/app/features/settings/account/IgnoredUserList.tsx index 98db9459..791a407b 100644 --- a/src/app/features/settings/account/IgnoredUserList.tsx +++ b/src/app/features/settings/account/IgnoredUserList.tsx @@ -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 && ( Remove Avatar - setAlertRemove(false)} radii="300"> + setAlertRemove(false)} + radii="300" + > @@ -273,6 +279,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) { hasChanges && !changingDisplayName && ( { + it('gives each icon button its variant', () => expectPhoneHitClasses(file, want)); + it('ties each sized variant to the size it draws', () => expectSizeTie(file)); +}); From 84ee800e3f4fddfb31e4491c9616b7f92d777b74 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:16:31 +0000 Subject: [PATCH 23/74] Phone hit: room/space settings, lobby, message search, explore icon buttons Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/lobby/HierarchyItemMenu.tsx | 2 ++ src/app/features/lobby/Lobby.tsx | 2 ++ .../features/message-search/MessageSearch.tsx | 2 ++ .../features/room-settings/RoomSettings.tsx | 3 +- .../room-settings/general/General.tsx | 3 +- .../room-settings/permissions/Permissions.tsx | 3 +- .../features/space-settings/SpaceSettings.tsx | 3 +- .../space-settings/general/General.tsx | 3 +- .../permissions/Permissions.tsx | 3 +- src/app/pages/client/explore/Explore.tsx | 8 +++++- src/app/phoneHit.roomSpace.test.ts | 28 +++++++++++++++++++ 11 files changed, 53 insertions(+), 7 deletions(-) create mode 100644 src/app/phoneHit.roomSpace.test.ts diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx index 1802e3b0..cdfa2d8a 100644 --- a/src/app/features/lobby/HierarchyItemMenu.tsx +++ b/src/app/features/lobby/HierarchyItemMenu.tsx @@ -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 ( virtualizer.scrollToOffset(0)} variant="SurfaceVariant" radii="Pill" diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx index 26085b5f..a1759ef9 100644 --- a/src/app/features/message-search/MessageSearch.tsx +++ b/src/app/features/message-search/MessageSearch.tsx @@ -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({ virtualizer.scrollToOffset(0)} variant="SurfaceVariant" radii="Pill" diff --git a/src/app/features/room-settings/RoomSettings.tsx b/src/app/features/room-settings/RoomSettings.tsx index 8601c365..8f96bd7c 100644 --- a/src/app/features/room-settings/RoomSettings.tsx +++ b/src/app/features/room-settings/RoomSettings.tsx @@ -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) { {screenSize === ScreenSize.Mobile && ( - + )} diff --git a/src/app/features/room-settings/general/General.tsx b/src/app/features/room-settings/general/General.tsx index 15157304..e29fe2da 100644 --- a/src/app/features/room-settings/general/General.tsx +++ b/src/app/features/room-settings/general/General.tsx @@ -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) { - + diff --git a/src/app/features/room-settings/permissions/Permissions.tsx b/src/app/features/room-settings/permissions/Permissions.tsx index 7572a71b..d513dda9 100644 --- a/src/app/features/room-settings/permissions/Permissions.tsx +++ b/src/app/features/room-settings/permissions/Permissions.tsx @@ -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) { - + diff --git a/src/app/features/space-settings/SpaceSettings.tsx b/src/app/features/space-settings/SpaceSettings.tsx index b5fefc93..1030972f 100644 --- a/src/app/features/space-settings/SpaceSettings.tsx +++ b/src/app/features/space-settings/SpaceSettings.tsx @@ -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) {screenSize === ScreenSize.Mobile && ( - + )} diff --git a/src/app/features/space-settings/general/General.tsx b/src/app/features/space-settings/general/General.tsx index 72040632..e99a4906 100644 --- a/src/app/features/space-settings/general/General.tsx +++ b/src/app/features/space-settings/general/General.tsx @@ -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) { - + diff --git a/src/app/features/space-settings/permissions/Permissions.tsx b/src/app/features/space-settings/permissions/Permissions.tsx index 7572a71b..d513dda9 100644 --- a/src/app/features/space-settings/permissions/Permissions.tsx +++ b/src/app/features/space-settings/permissions/Permissions.tsx @@ -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) { - + diff --git a/src/app/pages/client/explore/Explore.tsx b/src/app/pages/client/explore/Explore.tsx index dae83166..6944c9f7 100644 --- a/src/app/pages/client/explore/Explore.tsx +++ b/src/app/pages/client/explore/Explore.tsx @@ -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() { Add Server - setDialog(false)} radii="300"> + setDialog(false)} + radii="300" + > diff --git a/src/app/phoneHit.roomSpace.test.ts b/src/app/phoneHit.roomSpace.test.ts new file mode 100644 index 00000000..c98c2b09 --- /dev/null +++ b/src/app/phoneHit.roomSpace.test.ts @@ -0,0 +1,28 @@ +import { describe, it } from 'vitest'; +import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; + +describe('icon buttons in room settings, space settings, lobby, search and explore', () => { + // The class each IconButton must carry, in source order. Vertical stacks and lone buttons: the plain PhoneHit. + const expected: Record = { + 'features/room-settings/RoomSettings.tsx': ['PhoneHit'], // close, phone only, alone at the header end + 'features/room-settings/general/General.tsx': ['PhoneHit'], // close, 40 px + 'features/room-settings/permissions/Permissions.tsx': ['PhoneHit'], + 'features/space-settings/SpaceSettings.tsx': ['PhoneHit'], + 'features/space-settings/general/General.tsx': ['PhoneHit'], + 'features/space-settings/permissions/Permissions.tsx': ['PhoneHit'], + // 16 px icon + 8 padding = 24 px, 8-12 px from the Open / Add chips beside it in a hierarchy card + 'features/lobby/HierarchyItemMenu.tsx': ['PhoneHitSpread24'], + 'features/lobby/Lobby.tsx': ['PhoneHit'], // floating scroll-to-top, alone + 'features/message-search/MessageSearch.tsx': ['PhoneHit'], // floating scroll-to-top, alone + 'pages/client/explore/Explore.tsx': ['PhoneHit'], // Add Server dialog close + }; + + it.each(Object.entries(expected))( + '%s: each IconButton carries its own PhoneHit class', + (file, want) => expectPhoneHitClasses(file, want) + ); + + it.each(Object.keys(expected))('%s: a sized variant matches the drawn size', (file) => + expectSizeTie(file) + ); +}); From a0d047c139f7727f1d9e67780670ff9d713d0c71 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:16:41 +0000 Subject: [PATCH 24/74] Phone hit: message content and data editor icon buttons Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/AccountDataEditor.tsx | 3 ++- .../image-pack-view/ImagePackView.tsx | 3 ++- src/app/components/message/FileHeader.tsx | 2 ++ .../message/content/AudioContent.tsx | 2 ++ src/app/phoneHit.content.test.ts | 26 +++++++++++++++++++ src/app/plugins/react-custom-html-parser.tsx | 2 ++ 6 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 src/app/phoneHit.content.test.ts diff --git a/src/app/components/AccountDataEditor.tsx b/src/app/components/AccountDataEditor.tsx index 2dbaf1f1..8c28b935 100644 --- a/src/app/components/AccountDataEditor.tsx +++ b/src/app/components/AccountDataEditor.tsx @@ -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({ - + diff --git a/src/app/components/image-pack-view/ImagePackView.tsx b/src/app/components/image-pack-view/ImagePackView.tsx index ab81d503..15b1cd08 100644 --- a/src/app/components/image-pack-view/ImagePackView.tsx +++ b/src/app/components/image-pack-view/ImagePackView.tsx @@ -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) { - + diff --git a/src/app/components/message/FileHeader.tsx b/src/app/components/message/FileHeader.tsx index 2ffc9ec4..41cef5f4 100644 --- a/src/app/components/message/FileHeader.tsx +++ b/src/app/components/message/FileHeader.tsx @@ -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 ? ( diff --git a/src/app/components/message/content/AudioContent.tsx b/src/app/components/message/content/AudioContent.tsx index 478486a4..94132cea 100644 --- a/src/app/components/message/content/AudioContent.tsx +++ b/src/app/components/message/content/AudioContent.tsx @@ -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} > diff --git a/src/app/phoneHit.content.test.ts b/src/app/phoneHit.content.test.ts new file mode 100644 index 00000000..741a5828 --- /dev/null +++ b/src/app/phoneHit.content.test.ts @@ -0,0 +1,26 @@ +import { describe, it } from 'vitest'; +import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; + +describe('icon buttons in message content and data editors', () => { + // The class each IconButton must carry, in source order. + const expected: Record = { + // mute toggle, 16 px icon + 8 padding = 24 px, 8 px from the volume slider (tappable) + 'components/message/content/AudioContent.tsx': ['PhoneHitSpread24'], + // download, alone at the end of the header row (26 px) + 'components/message/FileHeader.tsx': ['PhoneHit'], + // code block expand/collapse, 24 px, 8 px from the Copy chip + 'plugins/react-custom-html-parser.tsx': ['PhoneHitSpread24'], + // close at the header end, alone + 'components/AccountDataEditor.tsx': ['PhoneHit'], + 'components/image-pack-view/ImagePackView.tsx': ['PhoneHit'], + }; + + it.each(Object.entries(expected))( + '%s: each IconButton carries its own PhoneHit class', + (file, want) => expectPhoneHitClasses(file, want) + ); + + it.each(Object.keys(expected))('%s: a sized variant matches the drawn size', (file) => + expectSizeTie(file) + ); +}); diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index 961131d9..2398a4c9 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -22,6 +22,7 @@ import Linkify from 'linkify-react'; import { ErrorBoundary } from 'react-error-boundary'; import { ChildNode } from 'domhandler'; import * as css from '../styles/CustomHtml.css'; +import { PhoneHitSpread24 } from '../styles/PhoneHit.css'; import { getMxIdLocalPart, getCanonicalAliasRoomId, @@ -282,6 +283,7 @@ export function CodeBlock({ variant="SurfaceVariant" outlined radii="300" + className={PhoneHitSpread24} onClick={toggleExpand} aria-label={expanded ? 'Collapse' : 'Expand'} > From da05210db29eb55d2c27f7c0fb490c10f07f36fa Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:16:46 +0000 Subject: [PATCH 25/74] Phone hit: editor toolbar, emoji sidebar, upload cards, client root Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/editor/Toolbar.tsx | 9 +++ .../emoji-board/components/Sidebar.tsx | 2 + .../upload-card/CompactUploadCardRenderer.tsx | 2 + .../upload-card/UploadCardRenderer.tsx | 2 + src/app/pages/client/ClientRoot.tsx | 2 + src/app/phoneHit.editor.test.ts | 61 +++++++++++++++++++ 6 files changed, 78 insertions(+) create mode 100644 src/app/phoneHit.editor.test.ts diff --git a/src/app/components/editor/Toolbar.tsx b/src/app/components/editor/Toolbar.tsx index 7d701c42..3721b909 100644 --- a/src/app/components/editor/Toolbar.tsx +++ b/src/app/components/editor/Toolbar.tsx @@ -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) { {(triggerRef) => ( {(triggerRef) => ( {(triggerRef) => ( handleMenuSelect(1)} size="400" @@ -178,6 +182,7 @@ export function HeadingBlockButton() { > {(triggerRef) => ( handleMenuSelect(2)} size="400" @@ -193,6 +198,7 @@ export function HeadingBlockButton() { > {(triggerRef) => ( handleMenuSelect(3)} size="400" @@ -208,6 +214,7 @@ export function HeadingBlockButton() { } > {(triggerRef) => ( {(triggerRef) => ( setIsMarkdown(!isMarkdown)} diff --git a/src/app/components/emoji-board/components/Sidebar.tsx b/src/app/components/emoji-board/components/Sidebar.tsx index de22b483..d2d5f197 100644 --- a/src/app/components/emoji-board/components/Sidebar.tsx +++ b/src/app/components/emoji-board/components/Sidebar.tsx @@ -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({ > {(ref) => ( )} )} { + // The class each IconButton must carry, in source order. + const expected: Record = { + 'components/editor/Toolbar.tsx': [ + 'PhoneHitSpread36', // mark button: size 400 + 20 px icon, in a row 4 px apart + 'PhoneHitSpread36', // block button, same row + 'PhoneHitRoom36', // heading 1, inside the heading Menu (overflow hidden) + 'PhoneHitRoom36', // heading 2 + 'PhoneHitRoom36', // heading 3 + 'PhoneHit', // heading opener: two 20 px icons, 56 px wide, only its height is short + 'PhoneHit', // Exit chip: text label, wider than 44 px + 'PhoneHit', // markdown toggle, alone at the end of the toolbar + ], + // A column of 40 px buttons: there is no vertical variant, so only PhoneHit; neighbours overlap by their 2 px gap. + 'components/emoji-board/components/Sidebar.tsx': ['PhoneHit'], + // Both cards put a Retry chip beside the cancel button when the upload failed. + 'components/upload-card/UploadCardRenderer.tsx': ['PhoneHitSpread28'], + 'components/upload-card/CompactUploadCardRenderer.tsx': ['PhoneHitSpread28'], + 'pages/client/ClientRoot.tsx': ['PhoneHit'], // alone, absolute in the corner + }; + + const sizeAllow: SizeAllow = {}; + + it.each(Object.entries(expected))( + '%s: each IconButton carries its own PhoneHit class', + (file, want) => expectPhoneHitClasses(file, want) + ); + + it.each(Object.keys(expected))('%s: Spread and Room variants name the size they draw', (file) => + expectSizeTie(file, sizeAllow) + ); +}); + +describe('the estate navigator find button', () => { + // The only IconButton there needs no PhoneHit: every button in the navigator header is 44 px on a phone because + // the Targets class sets a min size, and the button sits under that class. + it('is 44 px by the Targets class instead', async () => { + const src = readSource('features/estate/EstateNavigator.tsx'); + const tags = iconButtonTags(src); + expect(tags).toHaveLength(1); + expect(tags[0]).not.toMatch(/PhoneHit/); + const header = src.slice(src.indexOf('')); + expect(header.indexOf('className={css.Targets}')).toBeGreaterThan(-1); + expect(header.indexOf('className={css.Targets}')).toBeLessThan(header.indexOf(' Date: Fri, 13 Nov 2026 07:21:00 +0000 Subject: [PATCH 26/74] Phone hit kit: read the className expression, not its source text 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 --- src/app/phoneHitTestKit.test.ts | 47 ++++++++++++++++++++++++++++++++- src/app/phoneHitTestKit.ts | 43 +++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/app/phoneHitTestKit.test.ts b/src/app/phoneHitTestKit.test.ts index 92f1b23f..bd5f8bcd 100644 --- a/src/app/phoneHitTestKit.test.ts +++ b/src/app/phoneHitTestKit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { drawnPx, expectSizeTie, iconButtons } from './phoneHitTestKit'; +import { drawnPx, expectSizeTie, iconButtons, phoneHitOf } from './phoneHitTestKit'; describe('phone hit test kit', () => { it('reads { @@ -38,4 +38,49 @@ describe('phone hit test kit', () => { it('fails a file with no IconButton, which would otherwise pass vacuously', () => { expect(() => expectSizeTie('styles/PhoneHit.css.ts')).toThrow(/no IconButton/); }); + + describe('phoneHitOf reads the imported class, applied unconditionally', () => { + const tag = (expr: string) => ``; + + it('accepts the bare identifier and merges that always include it', () => { + expect(phoneHitOf(tag('PhoneHitSpread26'))).toBe('PhoneHitSpread26'); + expect(phoneHitOf(tag('classNames(PhoneHitRoom36, css.other)'))).toBe('PhoneHitRoom36'); + expect(phoneHitOf(tag('`${PhoneHit} ${css.other}`'))).toBe('PhoneHit'); + expect(phoneHitOf(tag("[PhoneHit, 'a b'].join(' ')"))).toBe('PhoneHit'); + expect(phoneHitOf(tag('classNames(css.a, { [css.b]: on }, PhoneHit)'))).toBe('PhoneHit'); + }); + + it('rejects the name as a string literal', () => { + expect(phoneHitOf(tag("'PhoneHitSpread26'"))).toBeNull(); + expect(phoneHitOf(tag('`PhoneHitSpread26`'))).toBeNull(); + }); + + it('rejects a name built by concatenation', () => { + expect(phoneHitOf(tag('"PhoneHitSpread26" + "x"'))).toBeNull(); + expect(phoneHitOf(tag("'PhoneHit' + 'Spread26'"))).toBeNull(); + }); + + it('rejects a conditionally applied name', () => { + expect(phoneHitOf(tag('cond ? PhoneHitSpread26 : undefined'))).toBeNull(); + expect(phoneHitOf(tag('cond && PhoneHitSpread26'))).toBeNull(); + expect(phoneHitOf(tag('cond || PhoneHitSpread26'))).toBeNull(); + expect(phoneHitOf(tag('maybe ?? PhoneHitSpread26'))).toBeNull(); + expect(phoneHitOf(tag("classNames(cond ? PhoneHit : '', css.a)"))).toBeNull(); + }); + + it('rejects a member of another object and a tag without className', () => { + expect(phoneHitOf(tag('other.PhoneHit'))).toBeNull(); + expect(phoneHitOf('')).toBeNull(); + }); + + it('rejects a spread after className, which can override it, and accepts one before', () => { + expect(phoneHitOf('')).toBeNull(); + expect(phoneHitOf('')).toBeNull(); + expect(phoneHitOf('')).toBe('PhoneHit'); + }); + + it('reads a className past braces inside strings', () => { + expect(phoneHitOf(tag("classNames('{', PhoneHit)"))).toBe('PhoneHit'); + }); + }); }); diff --git a/src/app/phoneHitTestKit.ts b/src/app/phoneHitTestKit.ts index a29dcc22..d86f23f5 100644 --- a/src/app/phoneHitTestKit.ts +++ b/src/app/phoneHitTestKit.ts @@ -85,7 +85,48 @@ export const iconButtons = (src: string) => export const iconButtonTags = (src: string) => iconButtons(src).map((b) => b.tag); -const phoneHitOf = (tag: string) => tag.match(/className=\{[^}]*?\b(PhoneHit\w*)\b/)?.[1] ?? null; +/** The expression of a tag's className={...} and the index of its closing brace, read to the balanced brace + * (strings and templates may hold braces); undefined when the tag has none. */ +const classNameExpr = (tag: string) => { + const m = /\sclassName=\{/.exec(tag); + if (!m) return undefined; + const from = m.index + m[0].length; + let depth = 1; + let quote = ''; + for (let j = from; j < tag.length; j += 1) { + const c = tag[j]; + if (quote) { + if (c === '\\') j += 1; + else if (c === quote) quote = ''; + } else if (c === '"' || c === "'" || c === '`') quote = c; + else if (c === '{') depth += 1; + else if (c === '}' && (depth -= 1) === 0) return { expr: tag.slice(from, j), end: j }; + } + return undefined; +}; + +/** The expression with its string literals and the literal text of its templates removed (a `${...}` keeps its + * expression), so only identifiers and operators remain. */ +const withoutLiterals = (expr: string) => + expr + .replace(/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g, ' ') + .replace(/`((?:[^`\\]|\\.)*)`/g, (_, body: string) => + [...body.matchAll(/\$\{([^}]*)\}/g)].map((t) => ` ${t[1]} `).join(' ') + ); + +/** The PhoneHit* class a tag carries: a bare identifier in its className expression, applied unconditionally. A + * name inside a string, built by concatenation, or behind ?:, &&, || or ??, or followed by a {...spread}, is not the + * imported class (or not always applied), so it reads as null. */ +export const phoneHitOf = (tag: string) => { + const found = classNameExpr(tag); + if (found === undefined) return null; + // props spread in after className can override it at runtime; before it they cannot + if (tag.slice(found.end).includes('{...')) return null; + const bare = withoutLiterals(found.expr); + if (/\?|&&|\|\|/.test(bare)) return null; + const names = [...bare.matchAll(/(? m[1]); + return names.length === 1 ? names[0] : null; +}; /** The class each IconButton must carry, in source order: the variant is chosen by the button's drawn size and * whether a clipping container or neighbours surround it, so a swapped variant is a wrong hit area. Also checks From 761d13fac5ca6b1d267eaba32bdc96c437d75b5f Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:21:57 +0000 Subject: [PATCH 27/74] Phone hit editor tests: pin estate Targets nesting and selector, ClientRoot inline absolute, sidebar gap Co-Authored-By: Claude Sonnet 5.5 --- src/app/phoneHit.editor.test.ts | 60 +++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/src/app/phoneHit.editor.test.ts b/src/app/phoneHit.editor.test.ts index 98bfc051..ef016b59 100644 --- a/src/app/phoneHit.editor.test.ts +++ b/src/app/phoneHit.editor.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { MOBILE_BREAKPOINT } from './hooks/useScreenSize'; import { + classLists, emitted, expectPhoneHitClasses, expectSizeTie, @@ -23,7 +24,8 @@ describe('icon buttons in the editor, emoji board, upload cards and client root' 'PhoneHit', // Exit chip: text label, wider than 44 px 'PhoneHit', // markdown toggle, alone at the end of the toolbar ], - // A column of 40 px buttons: there is no vertical variant, so only PhoneHit; neighbours overlap by their 2 px gap. + // A column of 40 px buttons 4 px apart (the stack's gap="100", pinned below): there is no vertical variant, so only + // PhoneHit; the 44 px areas then touch with no overlap. 'components/emoji-board/components/Sidebar.tsx': ['PhoneHit'], // Both cards put a Retry chip beside the cancel button when the upload failed. 'components/upload-card/UploadCardRenderer.tsx': ['PhoneHitSpread28'], @@ -43,19 +45,63 @@ describe('icon buttons in the editor, emoji board, upload cards and client root' ); }); +/** The JSX element opened at `open` (index of its ``: nesting is counted, not guessed (no self-closing Box here). */ +const boxSpan = (src: string, open: number) => { + const marks = [...src.slice(open).matchAll(/])|<\/Box>/g)]; + const depths = marks.map((m, i) => + marks.slice(0, i + 1).reduce((d, x) => d + (x[0] === '' ? -1 : 1), 0) + ); + const end = depths.indexOf(0); + if (end === -1) throw new Error('unbalanced '); + return [open, open + (marks[end].index ?? 0) + marks[end][0].length] as const; +}; + describe('the estate navigator find button', () => { // The only IconButton there needs no PhoneHit: every button in the navigator header is 44 px on a phone because - // the Targets class sets a min size, and the button sits under that class. + // the Targets class sets a min size, and the button sits inside the Box that carries that class. it('is 44 px by the Targets class instead', async () => { const src = readSource('features/estate/EstateNavigator.tsx'); const tags = iconButtonTags(src); expect(tags).toHaveLength(1); expect(tags[0]).not.toMatch(/PhoneHit/); - const header = src.slice(src.indexOf('')); - expect(header.indexOf('className={css.Targets}')).toBeGreaterThan(-1); - expect(header.indexOf('className={css.Targets}')).toBeLessThan(header.indexOf(''); + const boxStart = src.indexOf('', boxStart))).toMatch(/className=\{css\.Targets\}/); + const button = src.indexOf(' { + // PhoneHit makes the button position: relative on a phone; the inline style wins over a class, so the corner + // button stays absolute and its ::after anchors to it. + it('ClientRoot keeps its corner button absolutely positioned inline', () => { + const [tag] = iconButtonTags(readSource('pages/client/ClientRoot.tsx')); + expect(tag).toMatch(/style=\{\{[^}]*position:\s*'absolute'/); + }); + + // A 40 px button with a 44 px area, in a column 4 px apart: the areas touch and do not overlap. A smaller gap + // overlaps them, a larger one leaves a dead strip. + it('the emoji sidebar stack keeps its 4 px gap', () => { + const src = readSource('components/emoji-board/components/Sidebar.tsx'); + const stack = src.slice( + src.indexOf('export const SidebarStack'), + src.indexOf('export function SidebarDivider') + ); + expect(stack).toMatch(/direction="Column"/); + expect(stack).toMatch(/gap="100"/); }); }); From 4eaa6f9c55469b4c2e2ff7e4a45d3fc679737080 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:28:37 +0000 Subject: [PATCH 28/74] Phone hit kit: read IconButton className off the TypeScript AST 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 --- src/app/phoneHitTestKit.test.ts | 114 ++++++++++++++++----- src/app/phoneHitTestKit.ts | 171 ++++++++++++++++++++------------ 2 files changed, 196 insertions(+), 89 deletions(-) diff --git a/src/app/phoneHitTestKit.test.ts b/src/app/phoneHitTestKit.test.ts index bd5f8bcd..a77a2e27 100644 --- a/src/app/phoneHitTestKit.test.ts +++ b/src/app/phoneHitTestKit.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { drawnPx, expectSizeTie, iconButtons, phoneHitOf } from './phoneHitTestKit'; +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { relative } from 'path'; +import { + drawnPx, + expectPhoneHitClasses, + expectSizeTie, + iconButtons, + phoneHitOf, +} from './phoneHitTestKit'; describe('phone hit test kit', () => { it('reads { @@ -39,38 +48,60 @@ describe('phone hit test kit', () => { expect(() => expectSizeTie('styles/PhoneHit.css.ts')).toThrow(/no IconButton/); }); - describe('phoneHitOf reads the imported class, applied unconditionally', () => { + describe('phoneHitOf reads the imported class off the AST, applied unconditionally', () => { const tag = (expr: string) => ``; + const ok = (expr: string, name: string) => expect(phoneHitOf(tag(expr))).toBe(name); + const no = (expr: string) => expect(phoneHitOf(tag(expr))).toBeNull(); it('accepts the bare identifier and merges that always include it', () => { - expect(phoneHitOf(tag('PhoneHitSpread26'))).toBe('PhoneHitSpread26'); - expect(phoneHitOf(tag('classNames(PhoneHitRoom36, css.other)'))).toBe('PhoneHitRoom36'); - expect(phoneHitOf(tag('`${PhoneHit} ${css.other}`'))).toBe('PhoneHit'); - expect(phoneHitOf(tag("[PhoneHit, 'a b'].join(' ')"))).toBe('PhoneHit'); - expect(phoneHitOf(tag('classNames(css.a, { [css.b]: on }, PhoneHit)'))).toBe('PhoneHit'); + ok('PhoneHitSpread26', 'PhoneHitSpread26'); + ok('classNames(PhoneHitRoom36, css.other)', 'PhoneHitRoom36'); + ok('clsx(css.a, { [css.b]: on }, PhoneHit)', 'PhoneHit'); + ok('cx(PhoneHit)', 'PhoneHit'); + ok('`${PhoneHit} ${css.other}`', 'PhoneHit'); + ok('classNames(/* PhoneHit */ PhoneHit)', 'PhoneHit'); }); - it('rejects the name as a string literal', () => { - expect(phoneHitOf(tag("'PhoneHitSpread26'"))).toBeNull(); - expect(phoneHitOf(tag('`PhoneHitSpread26`'))).toBeNull(); - }); - - it('rejects a name built by concatenation', () => { - expect(phoneHitOf(tag('"PhoneHitSpread26" + "x"'))).toBeNull(); - expect(phoneHitOf(tag("'PhoneHit' + 'Spread26'"))).toBeNull(); + it('rejects the name as a string, a concatenation or a comment', () => { + no("'PhoneHitSpread26'"); + no('`PhoneHitSpread26`'); + no('"PhoneHitSpread26" + "x"'); + no("'PhoneHit' + 'Spread26'"); + no('/* PhoneHit */ undefined'); + no('css.a // PhoneHit'); }); it('rejects a conditionally applied name', () => { - expect(phoneHitOf(tag('cond ? PhoneHitSpread26 : undefined'))).toBeNull(); - expect(phoneHitOf(tag('cond && PhoneHitSpread26'))).toBeNull(); - expect(phoneHitOf(tag('cond || PhoneHitSpread26'))).toBeNull(); - expect(phoneHitOf(tag('maybe ?? PhoneHitSpread26'))).toBeNull(); - expect(phoneHitOf(tag("classNames(cond ? PhoneHit : '', css.a)"))).toBeNull(); + no('cond ? PhoneHitSpread26 : undefined'); + no('cond && PhoneHitSpread26'); + no('cond || PhoneHitSpread26'); + no('maybe ?? PhoneHitSpread26'); + no("classNames(cond ? PhoneHit : '', css.a)"); + no('classNames(PhoneHit, cond && PhoneHit)'); }); - it('rejects a member of another object and a tag without className', () => { - expect(phoneHitOf(tag('other.PhoneHit'))).toBeNull(); + it('rejects every other expression shape the name can hide in', () => { + no('classNames({ PhoneHit: on })'); + no('clsx({ [PhoneHit]: on })'); + no('`${`${PhoneHit}`}`'); + no('typeof PhoneHit'); + no("(PhoneHit = 'x')"); + no('(PhoneHit, css.a)'); + no('(PhoneHit)'); + no('() => PhoneHit'); + no('PhoneHit.foo'); + no('other.PhoneHit'); + no('classNames(css.a, [PhoneHit])'); + no('wrap(PhoneHit)'); + no('PhoneHitSpread26é'); + no('`${css.a}`'); + }); + + it('rejects a tag without className, with two, or with a non-expression one', () => { expect(phoneHitOf('')).toBeNull(); + expect(phoneHitOf('')).toBeNull(); + expect(phoneHitOf('')).toBeNull(); + expect(phoneHitOf('')).toBe('PhoneHit'); }); it('rejects a spread after className, which can override it, and accepts one before', () => { @@ -80,7 +111,44 @@ describe('phone hit test kit', () => { }); it('reads a className past braces inside strings', () => { - expect(phoneHitOf(tag("classNames('{', PhoneHit)"))).toBe('PhoneHit'); + ok("classNames('{', PhoneHit)", 'PhoneHit'); + }); + }); + + describe('expectPhoneHitClasses', () => { + const check = (src: string, want: (string | null)[]) => { + const dir = mkdtempSync(`${tmpdir()}/phone-hit-kit-`); + try { + writeFileSync(`${dir}/Probe.tsx`, src); + expectPhoneHitClasses(relative(`${process.cwd()}/src/app`, `${dir}/Probe.tsx`), want); + } finally { + rmSync(dir, { recursive: true }); + } + }; + const imp = "import { PhoneHit, PhoneHitSpread26 } from '../../styles/PhoneHit.css';\n"; + const src = + imp + + 'const A = () => (<>);'; + + it('pins each button in order, null meaning no class', () => { + check(src, ['PhoneHit', 'PhoneHitSpread26', null]); + expect(() => check(src, ['PhoneHit', 'PhoneHit', null])).toThrow(); + expect(() => check(src, ['PhoneHit', 'PhoneHitSpread26', 'PhoneHit'])).toThrow(); + expect(() => check(src, ['PhoneHit', 'PhoneHitSpread26'])).toThrow(); + }); + + it('fails a name that is not read from PhoneHit.css or is applied conditionally', () => { + const local = "const PhoneHit = 'x';\n"; + expect(() => check(local, ['PhoneHit'])).toThrow(); + expect(() => check(local, [null])).not.toThrow(); + const cond = imp + ''; + expect(() => check(cond, ['PhoneHit'])).toThrow(); + const aliased = + "import { Other as PhoneHit } from '../../styles/PhoneHit.css';\n"; + expect(() => check(aliased, ['PhoneHit'])).toThrow(); + const wrongModule = + "import { PhoneHit } from './other';\n"; + expect(() => check(wrongModule, ['PhoneHit'])).toThrow(); }); }); }); diff --git a/src/app/phoneHitTestKit.ts b/src/app/phoneHitTestKit.ts index d86f23f5..896b917b 100644 --- a/src/app/phoneHitTestKit.ts +++ b/src/app/phoneHitTestKit.ts @@ -2,6 +2,7 @@ // group of IconButtons). Not a test file: it registers nothing, the test files call it. import { readFileSync } from 'fs'; import { expect } from 'vitest'; +import ts from 'typescript'; import zlib from 'zlib'; import { compile, processVanillaFile } from '@vanilla-extract/integration'; @@ -72,77 +73,113 @@ const tagEnd = (src: string, from: number) => { return j; }; -/** Every outside any {} expression, and - * what it wraps (empty when self-closing). */ +const tsx = (src: string) => + ts.createSourceFile('source.tsx', src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + +type Opening = ts.JsxOpeningElement | ts.JsxSelfClosingElement; + +/** Every IconButton element (not IconButtonX) of a parsed source, in source order. */ +const elements = (sf: ts.SourceFile) => { + const found: Opening[] = []; + const walk = (n: ts.Node) => { + if ( + (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) && + ts.isIdentifier(n.tagName) && + n.tagName.text === 'IconButton' + ) + found.push(n); + ts.forEachChild(n, walk); + }; + walk(sf); + return found; +}; + +const bodyOf = (el: Opening) => + ts.isJsxOpeningElement(el) && ts.isJsxElement(el.parent) + ? el.getSourceFile().text.slice(el.end, el.parent.closingElement.getStart()) + : ''; + +/** Every - [...src.matchAll(//])/g)].map((m) => { - const start = m.index ?? 0; - const end = tagEnd(src, start); - const tag = src.slice(start, end + 1); - const close = src.indexOf('', end); - return { tag, body: tag.endsWith('/>') || close === -1 ? '' : src.slice(end + 1, close) }; - }); + elements(tsx(src)).map((el) => ({ tag: el.getText(), body: bodyOf(el) })); export const iconButtonTags = (src: string) => iconButtons(src).map((b) => b.tag); -/** The expression of a tag's className={...} and the index of its closing brace, read to the balanced brace - * (strings and templates may hold braces); undefined when the tag has none. */ -const classNameExpr = (tag: string) => { - const m = /\sclassName=\{/.exec(tag); - if (!m) return undefined; - const from = m.index + m[0].length; - let depth = 1; - let quote = ''; - for (let j = from; j < tag.length; j += 1) { - const c = tag[j]; - if (quote) { - if (c === '\\') j += 1; - else if (c === quote) quote = ''; - } else if (c === '"' || c === "'" || c === '`') quote = c; - else if (c === '{') depth += 1; - else if (c === '}' && (depth -= 1) === 0) return { expr: tag.slice(from, j), end: j }; - } - return undefined; -}; - -/** The expression with its string literals and the literal text of its templates removed (a `${...}` keeps its - * expression), so only identifiers and operators remain. */ -const withoutLiterals = (expr: string) => - expr - .replace(/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g, ' ') - .replace(/`((?:[^`\\]|\\.)*)`/g, (_, body: string) => - [...body.matchAll(/\$\{([^}]*)\}/g)].map((t) => ` ${t[1]} `).join(' ') - ); - -/** The PhoneHit* class a tag carries: a bare identifier in its className expression, applied unconditionally. A - * name inside a string, built by concatenation, or behind ?:, &&, || or ??, or followed by a {...spread}, is not the - * imported class (or not always applied), so it reads as null. */ -export const phoneHitOf = (tag: string) => { - const found = classNameExpr(tag); - if (found === undefined) return null; - // props spread in after className can override it at runtime; before it they cannot - if (tag.slice(found.end).includes('{...')) return null; - const bare = withoutLiterals(found.expr); - if (/\?|&&|\|\|/.test(bare)) return null; - const names = [...bare.matchAll(/(? m[1]); - return names.length === 1 ? names[0] : null; -}; - -/** The class each IconButton must carry, in source order: the variant is chosen by the button's drawn size and - * whether a clipping container or neighbours surround it, so a swapped variant is a wrong hit area. Also checks - * the name is the one PhoneHit.css.ts exports, not a local of the same name. */ -export const expectPhoneHitClasses = (file: string, want: string[]) => { - const src = readSource(file); - expect(iconButtonTags(src).map(phoneHitOf)).toEqual(want); - [...new Set(want)].forEach((name) => - expect(src).toMatch( - new RegExp( - `import \\{(?:[^}]*,)?\\s*${name}\\s*(?:,[^}]*)?\\} from '(\\.\\./)+styles/PhoneHit\\.css'` - ) - ) +/** Local names the source imports from the PhoneHit.css module (as written, not aliased). */ +const importedHits = (sf: ts.SourceFile) => + new Set( + sf.statements.flatMap((st) => { + const nb = ts.isImportDeclaration(st) ? st.importClause?.namedBindings : undefined; + return nb && + ts.isNamedImports(nb) && + ts.isStringLiteral((st as ts.ImportDeclaration).moduleSpecifier) && + /^(\.\.\/)+styles\/PhoneHit\.css$/.test( + ((st as ts.ImportDeclaration).moduleSpecifier as ts.StringLiteral).text + ) + ? nb.elements.filter((e) => !e.propertyName).map((e) => e.name.text) + : []; + }) ); + +const MERGERS = new Set(['classNames', 'clsx', 'cx']); + +/** The PhoneHit* class an IconButton carries, or null. className must appear exactly once, with no props spread + * after it (a spread can override it at runtime), and its expression must be: the bare identifier; a classNames / + * clsx / cx call with it as a direct argument; or a template with it as a direct span. The identifier must occur + * nowhere else in the expression, so a key, a member, a closure, a conditional or a comma can never carry it. When + * `imported` is given, the identifier must also be a binding imported from the PhoneHit.css module. */ +const hitOf = (el: Opening, imported?: Set): string | null => { + const props = el.attributes.properties; + const at = props.flatMap((p, i) => + ts.isJsxAttribute(p) && ts.isIdentifier(p.name) && p.name.text === 'className' ? [i] : [] + ); + if (at.length !== 1 || props.slice(at[0] + 1).some(ts.isJsxSpreadAttribute)) return null; + const attr = props[at[0]] as ts.JsxAttribute; + const expr = + attr.initializer && ts.isJsxExpression(attr.initializer) && attr.initializer.expression; + if (!expr) return null; + const isHit = (n: ts.Node): n is ts.Identifier => + ts.isIdentifier(n) && /^PhoneHit\w*$/.test(n.text) && (!imported || imported.has(n.text)); + let direct: ts.Node[] = []; + if (ts.isIdentifier(expr)) direct = [expr]; + else if ( + ts.isCallExpression(expr) && + ts.isIdentifier(expr.expression) && + MERGERS.has(expr.expression.text) + ) + direct = [...expr.arguments]; + else if (ts.isTemplateExpression(expr)) direct = expr.templateSpans.map((sp) => sp.expression); + const hits = direct.filter(isHit); + if (hits.length !== 1) return null; + let occurrences = 0; + const count = (n: ts.Node) => { + if (ts.isIdentifier(n) && n.text === hits[0].text) occurrences += 1; + ts.forEachChild(n, count); + }; + count(expr); + return occurrences === 1 ? hits[0].text : null; }; +/** The PhoneHit* class of one tag's text (see hitOf), without the import-binding check. */ +export const phoneHitOf = (tag: string) => { + const els = elements(tsx(tag.endsWith('/>') ? tag : `${tag}`)); + return els.length === 1 ? hitOf(els[0]) : null; +}; + +/** The PhoneHit* class of each IconButton of a source, in source order; null when it has none or the name is not + * the one imported from PhoneHit.css. */ +const phoneHitClasses = (src: string) => { + const sf = tsx(src); + const imported = importedHits(sf); + return elements(sf).map((el) => hitOf(el, imported)); +}; + +/** The class each IconButton must carry, in source order (null = must carry none): the variant is chosen by the + * button's drawn size and whether a clipping container or neighbours surround it, so a swapped variant is a wrong + * hit area. The name must also be the one PhoneHit.css exports, not a local of the same name. */ +export const expectPhoneHitClasses = (file: string, want: (string | null)[]) => + expect(phoneHitClasses(readSource(file))).toEqual(want); + // IconButton `size` -> padding on each side, Icon `size` -> px (folds 2.7.2). Both default to 400. const BUTTON_PAD: Record = { '300': 4, '400': 8, '500': 12, '600': 16 }; const ICON_PX: Record = { @@ -188,10 +225,12 @@ export type SizeAllow = Record>; export const expectSizeTie = (file: string, allow: SizeAllow = {}) => { const allowed = allow[file] ?? {}; const seen = new Set(); - const buttons = iconButtons(readSource(file)); + const src = readSource(file); + const buttons = iconButtons(src); + const classes = phoneHitClasses(src); expect(buttons.length, `${file} has no IconButton`).toBeGreaterThan(0); buttons.forEach(({ tag, body }, i) => { - const variant = phoneHitOf(tag)?.match(/^PhoneHit(?:Spread|Room)(\d+)$/); + const variant = classes[i]?.match(/^PhoneHit(?:Spread|Room)(\d+)$/); if (!variant) { expect(allowed[i], `${file} #${i} is allow-listed but has no sized variant`).toBeUndefined(); return; From d5a0c1b4ffd4b4248fecbaa3c4d18861f53c1126 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 13:46:44 +0000 Subject: [PATCH 29/74] Phone hit: a repo-wide guard for bare IconButtons, PhoneHitInPlace for the preview arrows, drop the dead Editor.preview 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 --- src/app/components/editor/Editor.preview.tsx | 84 ------------------- .../components/url-preview/UrlPreviewCard.tsx | 12 ++- src/app/phoneHit.coverage.test.ts | 58 +++++++++++++ src/app/phoneHitTestKit.ts | 2 +- src/app/phoneTapTargets.test.ts | 10 ++- src/app/styles/PhoneHit.css.ts | 4 + 6 files changed, 80 insertions(+), 90 deletions(-) delete mode 100644 src/app/components/editor/Editor.preview.tsx create mode 100644 src/app/phoneHit.coverage.test.ts diff --git a/src/app/components/editor/Editor.preview.tsx b/src/app/components/editor/Editor.preview.tsx deleted file mode 100644 index b760dddc..00000000 --- a/src/app/components/editor/Editor.preview.tsx +++ /dev/null @@ -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 ( - <> - setOpen(!open)}> - - - }> - - setOpen(false), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, - }} - > - -
- - - - } - after={ - <> - setToolbar(!toolbar)} - aria-pressed={toolbar} - > - - - - - - - - - - } - bottom={ - toolbar && ( -
- - -
- ) - } - /> -
-
-
-
-
- - ); -} diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index ee8967af..5d40e10c 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -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) => { <>
(({ children, ...props }, ref) => { <>
= { + 'features/estate/EstateNavigator.tsx': { + indices: [0], + why: 'Find a room sits in a css.Targets row that already gives it a 44 px phone target', + }, + 'features/lobby/LobbyHeader.tsx': { + indices: [1], + why: 'Members button, hidden on mobile', + }, +}; + +const tsxFiles = (dir: string): string[] => + readdirSync(`${process.cwd()}/src/app/${dir}`, { withFileTypes: true }).flatMap((e) => { + if (e.isDirectory()) return tsxFiles(`${dir}${e.name}/`); + return e.name.endsWith('.tsx') ? [`${dir}${e.name}`] : []; + }); + +describe('phone hit areas: every IconButton is covered or named', () => { + it('lists exactly the IconButtons without a PhoneHit class', () => { + const bare: Record = {}; + tsxFiles('').forEach((file) => { + const src = readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8'); + if (!src.includes(' (c === null ? [i] : [])); + if (missing.length > 0) bare[file] = missing; + }); + expect(bare).toEqual( + Object.fromEntries(Object.entries(EXEMPT).map(([f, e]) => [f, e.indices])) + ); + }); +}); + +// UrlPreviewCard's arrows are absolutely positioned by their own class; PhoneHit would set position: relative on them +// and win or lose by stylesheet order, so they carry the variant that adds only the hit area. +describe('phone hit area of a button positioned by its own class', () => { + it('the arrows carry PhoneHitInPlace', () => { + expectPhoneHitClasses('components/url-preview/UrlPreviewCard.tsx', [ + 'PhoneHitInPlace', + 'PhoneHitInPlace', + ]); + }); + + it('PhoneHitInPlace draws the ::after and sets no position of its own', async () => { + const lists = await classLists('styles/PhoneHit.css.ts'); + expect(lists.PhoneHitInPlace).toHaveLength(1); + const css = await emitted('styles/PhoneHit.css.ts'); + const rules = [...css.matchAll(/^([^{}\n]*PhoneHitInPlace__[^{}\n]*) \{$/gm)].map((m) => m[1]); + expect(rules.length).toBeGreaterThan(0); + rules.forEach((sel) => expect(sel).toMatch(/::after$/)); + }); +}); diff --git a/src/app/phoneHitTestKit.ts b/src/app/phoneHitTestKit.ts index 896b917b..d8449cba 100644 --- a/src/app/phoneHitTestKit.ts +++ b/src/app/phoneHitTestKit.ts @@ -168,7 +168,7 @@ export const phoneHitOf = (tag: string) => { /** The PhoneHit* class of each IconButton of a source, in source order; null when it has none or the name is not * the one imported from PhoneHit.css. */ -const phoneHitClasses = (src: string) => { +export const phoneHitClasses = (src: string) => { const sf = tsx(src); const imported = importedHits(sf); return elements(sf).map((el) => hitOf(el, imported)); diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index 5a5394e7..192f8414 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -102,13 +102,16 @@ describe('phone icon-button hit areas', () => { expect(css).toMatch(/::after \{[^}]*transform: translate\(-50%, -50%\);/); // the box anchors to its own button, receives the tap, and sits above nothing it should not expect(css).toMatch(/@media [^{]+ \{\n\s*\.PhoneHit_PhoneHit__\w+ \{\n\s*position: relative;/); - const afterDecls = [...css.matchAll(/::after \{\n([^}]*)\}/g)].flatMap((m) => + // PhoneHit and PhoneHitInPlace each draw it; the Spread and Room classes compose PhoneHit + const afters = [...css.matchAll(/::after \{\n([^}]*)\}/g)]; + expect(afters).toHaveLength(2); + const afterDecls = afters.flatMap((m) => m[1] .split('\n') .map((l) => l.trim().split(':')[0]) .filter(Boolean) ); - expect([...afterDecls].sort()).toEqual([ + expect([...new Set(afterDecls)].sort()).toEqual([ 'content', 'height', 'left', @@ -117,8 +120,9 @@ describe('phone icon-button hit areas', () => { 'transform', 'width', ]); - // nothing on a phone moves a button off its own anchor: only relative (the button) and absolute (the box) + // nothing on a phone moves a button off its own anchor: only relative (the button) and absolute (the boxes) expect([...css.matchAll(/position: (\w+);/g)].map((m) => m[1]).sort()).toEqual([ + 'absolute', 'absolute', 'relative', ]); diff --git a/src/app/styles/PhoneHit.css.ts b/src/app/styles/PhoneHit.css.ts index 9863851b..e971ecb3 100644 --- a/src/app/styles/PhoneHit.css.ts +++ b/src/app/styles/PhoneHit.css.ts @@ -29,6 +29,10 @@ export const PhoneHit = style({ selectors: { '&::after': hitArea }, }); +/** For a button that is already positioned (absolute or fixed) by its own class: PhoneHit's `position: relative` + * would fight that class by stylesheet order, and the positioned button already contains its ::after. */ +export const PhoneHitInPlace = style({ selectors: { '&::after': hitArea } }); + const spreads = new Map(); /** An icon button in a row, `visual` px wide: the row opens up so its 44 px hit area meets its neighbours'. */ From 88fd0a889a3d75b3cf0b9c274b955c5fdeaa560c Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 14:30:46 +0000 Subject: [PATCH 30/74] docs: Commons and per-Persona Files desk, design only Co-Authored-By: Claude Sonnet 5.5 --- docs/commons-files-desk.md | 105 +++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 docs/commons-files-desk.md diff --git a/docs/commons-files-desk.md b/docs/commons-files-desk.md new file mode 100644 index 00000000..9956cc60 --- /dev/null +++ b/docs/commons-files-desk.md @@ -0,0 +1,105 @@ +# Files desk: Commons and per-Persona file zones + +Status: design only. No code, nothing deployed. Items marked **UNKNOWN** are not verified against the Body; **Q-E** / **Q-M** are open questions for Eureka (host) and Meta (audience, uploads), collected at the end. + +Source: Meta, #swarm 2026-11-13 14:28Z; ruling `ratatoskr:ruling:persona-file-sharing-zone` (each Persona has her own file sharing zone the frontend shows: a browsable, previewing collection; the Self writes to her outbox, her Persona decides what is shared, the Self never holds Matrix credentials). + +## What exists, and what this adds + +| | Matrix file zone (`docs/file-zone.md`, built) | Files desk (this page) | +| -------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| Storage | Matrix media in a `#files-` room, albums as threads | Directories on Yggdrasil, served by a small host | +| Listing | thread roots, `buildAlbums` | `GET /index?path=` | +| Unit | album of posted files | file in a directory tree | +| Strength | marks (keep / fix / drop), receipts (`fetched for`), any Matrix client works | whole trees without N uploads, no Matrix size limit, `/opt/commons` material becomes visible to people on a phone | +| Entry | Media row per Mind (`MediaRow.tsx`) | Files row (below) | + +They do not compete. The Matrix zone stays where a file needs a mark or a receipt (Aqua's listening kits); the Files desk is for browsing and fetching what is already on disk. Neither replaces the other, and the Files desk does not write Matrix events. The ruling left "Matrix media plus a listing versus a served directory" open; this page is the served-directory half, built beside the Matrix half already live. + +## Zones + +| Zone | Contents | Who sees it | +| ---------------- | ------------------------------------------------------------ | -------------------------------------------------------------- | +| Commons | a curated tree of `/opt/commons` | members and Minds | +| Persona `` | what that Persona has chosen to share from her Self's outbox | **Q-M:** all members, or the Persona's stewards and Minds only | +| handoff-private | `/opt/commons/handoff-private` (0750) | never: not a zone, not listed, not a path the host resolves | + +Layout of `/opt/commons` today (read 2026-11-13): `backlog` (755, per-Mind backlog md and summary.json), `bin`, `cast-art` (setgid, group-writable: poses, busts), `handoff` (setgid, group-writable, 92 entries of briefs, patches, receipts, dings, kits), `handoff-private` (0750, ACL), `opt` (toolchains, root-owned), `situation` (0750, setgid), all `nobody:nogroup`. **`handoff` is not a publishable tree:** it holds ops notes, custody receipts (`bifrost-appsecret-custody-receipt-*`) and patches written for Minds, with no sticky bit. Serving it wholesale shows secrets-adjacent notes to every member and lets any Mind with group write change what a phone user sees. The Commons zone is therefore an allowlist of roots (candidate: `cast-art`, `backlog`, and a new `commons/` directory Minds publish into on purpose), not `/opt/commons` itself. **Q-E:** which roots; whether `handoff` ever, and if so through a reviewed `published/` subdirectory. + +Per-Persona source is the outbox from `ratatoskr:follow_up:album-outbox-ops-and-persona-specs`: `/var/lib/mind--outbox`, owned by `mind--self`, 0750, ACL read for `mind-` and the host user. The Self writes there; the Persona decides what is shared. Making that decision a filesystem fact (a `shared/` subdirectory the Persona's turn populates by copy or hardlink, the host serves only `shared/`) keeps the host dumb: it never learns what "decides" means. **Q-E:** `shared/` subdirectory versus a per-file marker. + +## Phone-first UI + +Phone width is the design width; desktop adds the navigator beside it (as `Desk` does in `Router.tsx`). Vertical space is cheap, so a screen shows one thing and hides what is not in use. + +- **Entry.** A `Files` row pinned in the navigator beside `DeskEntry` and `MindMedia`: one at the estate level (Commons) and one inside each Mind's group (her zone), same `NavItem` anatomy as `DeskEntry`, dim when the zone has no items. Route `/files/:zone/*path`, sibling of `DESK_PATH`. +- **List.** One column. Row = icon or thumbnail, name (truncates in the middle, keeping the extension), size and age on a second dim line. Directories first. Rows are at least 56 px tall; the tap target is the whole row (the `PhoneHit` variants and the repo-wide guard test from `phoneHit.coverage.test.ts` apply to any IconButton inside). +- **Hidden until needed.** Breadcrumb is one line (current folder, a back arrow; the full path on tap). Search, sort and the grid toggle sit behind a single icon in the header and open inline above the list. Download and share appear on the open file, not on every row. Nothing is permanently stacked in the header. +- **Preview.** Tap a file: it opens in place below its row, full width, and pushes the rows below it down (vertical room is the cheap axis). Image: fitted, tap for the existing `ImageViewer`. Audio: the `AudioContent` controls rehosted (`renderMediaControl`), one player at a time, a mini-player pinned to the bottom only while something plays. Video: native controls. Text, md, json, patch: monospace, first 64 KiB, "show all" below. PDF: open in a new tab. Anything else: name, size, hash, one Download button. +- **Honesty rows.** Loading, empty, "host unreachable", "not allowed" are rows that say so (as `MediaNote` does); no spinner without a name for what it waits on. +- **Role-driven.** The index response carries the caller's role; the client uses it only to hide controls (upload), never to decide access. + +## Host contract + +The host is a service on Yggdrasil that Eureka owns. The client holds no filesystem knowledge. All JSON here is the xenos boundary between a browser and a file server, not state. + +- `GET /index?path=

` returns `{ role, zone, path, entries: [{ name, kind: "file"|"dir", size, mtime, mime, hash }] }`. `mtime` is unix seconds. `hash` is the lower-case hex sha256 of the plaintext bytes, absent for directories, and may be absent for a file whose hash is not computed yet (client shows nothing, never blocks on it). `mime` is sniffed by the host from magic bytes, not guessed from the extension. +- `GET /file?path=

` streams the bytes with `Accept-Ranges: bytes` and 206 for `Range` (audio and video seek; a phone on mobile data does not fetch 40 MB to play 10 seconds). `Content-Type` is the sniffed mime, `X-Content-Type-Options: nosniff`, `Content-Disposition: attachment` for anything that can run in the origin (`html`, `svg`, `js`, `xhtml`), and a `Content-Security-Policy: sandbox` on every response. +- `path` is `/`, with the zone one of the names the host announces. The host resolves the real path of every request and refuses anything that leaves the zone's root: `..`, absolute paths, NUL, symlinks that cross the root, dotfiles, and `handoff-private`, which is not on any allowlist. Errors are typed (`401 unauthenticated`, `403 zone not allowed`, `404`, `416`), and a path outside the allowlist is 404, not 403, so it says nothing about what exists. +- **Auth: Matrix OpenID.** The client calls `mx.getOpenIdToken()` (already used in `CallWidgetDriver.ts`) and sends it as `Authorization: Bearer `. The host verifies it against the homeserver's federation `openid/userinfo` endpoint, **pinned to our server name** (an `@void:evil.example` token must fail), caches the verified `sub` for the token's lifetime, and derives the role. It never sees the user's access token, and a Mind's Self never holds a token (ruling above): Minds reach the same bytes through the filesystem or the mouth, not this host. +- **Role** in the response: `member`, `mind`, `steward` (host decides from the Matrix id and the room/space membership the host reads; the client trusts none of it for access). **Q-E:** exact source of membership (estate Space membership read by the host with a service account, versus the Persona catalogue file for Minds). +- **Uploads, only if Meta approves (Q-M):** `PUT /file?path=

` with `Content-Length` required, a hard per-file cap (suggest 25 MiB; the existing Matrix limit is `m.upload.size` and the mouth fetches at most 5 MiB), no overwrite (409), into a single `inbox/` directory per zone, `201` with the stored name, size and hash, and the same path and sniff rules. Without approval the host serves no `PUT` and the client has no upload control; the `role` field is what would gate the control. The Matrix zone already carries uploads (`ShareSet`), so the Files desk does not need them to be useful. + +### The one problem the contract leaves: media elements cannot send headers + +`

` with `Content-Length` required, a hard per-file cap (suggest 25 MiB; the existing Matrix limit is `m.upload.size` and the mouth fetches at most 5 MiB), no overwrite (409), into a single `inbox/` directory per zone, `201` with the stored name, size and hash, and the same path and sniff rules. Without approval the host serves no `PUT` and the client has no upload control; the `role` field is what would gate the control. The Matrix zone already carries uploads (`ShareSet`), so the Files desk does not need them to be useful. +- **Uploads** (Meta approved for members and Minds, #swarm 2026-11-13 14:30Z; rules are Eureka's, below). `PUT /file?path=

` writes only under an `uploads/` subtree of a zone, with `Content-Length` required; the host enforces a size cap, a type allowlist (sniffed, not the client's claim), no overwrite unless the request carries an explicit flag, and never replaces a file a Mind wrote into the handoff tree; every write is audit-logged with the caller's Matrix id. Reads cover the whole allowlisted tree, so an uploaded file is listed like any other. Success is `201` with the stored name, size and hash. The `role` in the index response gates the upload control; the host gates the write. ### The one problem the contract leaves: media elements cannot send headers @@ -60,11 +60,47 @@ The host is a service on Yggdrasil that Eureka owns. The client holds no filesys Recommendation: 2 for audio and video, 1 for images and text. Confidence moderate: I have not tested a service worker against range requests on the target phone, and 2 is the usual answer for authenticated media (signed URLs). **Q-E.** +## Upload flow + +Design only. Every number and error code is a named placeholder until Eureka gives them on the PR: `` (per-file size), `` (per-pick total, if any), `` (the allowlist), `` (the explicit query flag), and the codes ``, ``, ``, ``, ``, ``, ``, ``, ``. + +**Where.** The control exists only where an upload is allowed: inside a folder under a zone's `uploads/` subtree (or at the `uploads/` row itself), and only when the index `role` is member or mind. Elsewhere there is no button; the list does not carry a disabled one. A folder outside `uploads/` shows a one-line dim note, "Uploads go in uploads/", which links to that folder. Creating subfolders under `uploads/` is not in this design (**Q-E8**). + +**Pick or drop.** One `Upload` row at the top of an `uploads/` folder opens the native picker (any number of files; on a phone this is the OS file sheet, camera roll included). On wide screens the list is also a drop target; the drop highlight appears only while a drag is over it. Nothing starts on pick: the picked files appear as a staging list (name, size, a type icon) with `Upload` and `Cancel` as full-width rows, as `ShareSet` does for a Matrix set. Files that already fail the client's cheap checks (over ``, a type outside ``) are marked in the staging list with the reason and left out of the send; the client mirrors the cap and allowlist as a courtesy only, the host is the authority, and the list is read from the host (**Q-E9**: an `/index` field or a `/limits` call) rather than hard-coded. + +**Send.** Files go one at a time, in pick order, each a `PUT /file?path=/` with the bearer token. One at a time keeps a phone on mobile data from starving every transfer, and a refusal names exactly one file. The transport is `XMLHttpRequest`, not `fetch`: `fetch` has no upload progress and the row needs it. The body is the `File` itself (streamed by the browser, not read into memory; unlike the Matrix path, the client computes no hash first, the host returns one). The name is the file's own name, with path separators and control characters replaced, never a path the user typed. + +**Progress.** The staging list becomes the progress list: per file a thin bar and "412 KB of 3.1 MB", per batch "2 of 5". Finished files show a tick and the stored name; refused ones show the refusal in place; waiting ones are dim. Only the active file animates. The list stays on screen after the last file until dismissed, so a refusal is not lost to a refresh of the folder underneath it. The folder listing refetches after each success, so the file appears in the list as it lands. + +**Cancel.** `Cancel` on the active file aborts the request (`xhr.abort()`) and goes on to the next; `Cancel all` on the batch aborts the active file and drops the rest. The host must discard an aborted partial write and never leave a half file in the listing (**Q-E10**, written to a temporary name and renamed on completion). Leaving the page aborts whatever runs, as the Matrix share does; the client does not resume. + +**Overwrite.** A name that already exists is refused with `` and shown as " is already there". A `Replace` row appears under that refusal only for a file the caller wrote themselves, and sends the same `PUT` with ``; a Mind-written handoff file refuses with `` and offers only "Keep both", which re-sends under a suffixed name (`name (2).ext`) chosen by the client. Whether the client can tell "mine" from the index (an `owner` field with the Matrix id) or learns it only from the refusal is **Q-E11**; absent an owner field, `Replace` is simply offered after `` and the host's answer decides. + +**Refusal messages.** One plain sentence per code, shown in the file's row; nothing says "error" without saying why, and none names a server path. + +| Code | Message (draft) | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `` | Sign-in expired. Reload to upload again. (The client fetches a fresh OpenID token once and retries before showing this.) | +| `` | Uploads are for members and Minds. | +| `` | That folder does not take uploads. | +| `` | "" is a file; allowed types are . | +| `` | "" is ; the limit is . | +| `` | "" is already there. | +| `` | "" was written by a Mind and cannot be replaced. | +| `` | The shared space is full. Tell a steward. | +| `` / network / timeout | Could not reach the file host. The file was not saved. | + +Retry is one tap on a failed row; the batch never retries on its own. An unknown code shows the host's status text as it is, still without a path. + +**Honest end state.** Done means the host answered `201` and the stored name, size and hash are shown. "Saved" is not said earlier; the hash the host returns is displayed so a sender can compare it with their own. + +**Reuse and not.** `ShareSet`'s state machine (staging, one-send-per-zone guard, failed files kept by position) is the model, but the Matrix share path and its `createUploadAtom` are tied to the SDK's media upload and are not reused; the new controller sits in `features/files/uploadController.ts` with the same injected-port shape, so order, refusal and cancel rules are tested against a fake `PUT` without a browser. Real transfer progress and a real range of a phone's picker stay a phone check. + ## Client shape Under `src/app/features/files`, mirroring `features/albums`: -- `filesHost.ts`: ports, `list(path)`, `url(path)`, `ticket(path)`; the only module that knows the contract. Injected, so the UI is tested against a fake and the real client never runs in a test. +- `filesHost.ts`: ports, `list(path)`, `url(path)`, `ticket(path)`, `put(path, file, onProgress, signal)`; the only module that knows the contract. Injected, so the UI is tested against a fake and the real client never runs in a test. - `useFilesZone.ts`: react-query on `['files', zone, path]`, `staleTime` 30 s; the role comes with every index. - `FilesDesk.tsx`, `FileRow.tsx`, `FilePreview.tsx`: pure rendering from the above; the preview picks its renderer from the host's `mime`, not the extension. - `config.json`: `filesDesk: { enabled: false, host: "https://…" }`, dark by default as `fileZoneShare.enabled` is. With it off, no row mounts and no request leaves the client. @@ -76,7 +112,7 @@ Under `src/app/features/files`, mirroring `features/albums`: 2. **Read-only Commons desk**: route, row, list, breadcrumb, role display, flagged off. 3. **Preview and player**: image, audio with ranges, text, video; the ticket if Q-E settles on 2. 4. **Per-Persona rows** in each Mind's group, once the outbox `shared/` convention and the zone provisioning exist. -5. **Uploads**: only on Meta's yes. +5. **Uploads**: the flow above; needs Eureka's codes and caps and the host's `PUT`. Each is its own PR. Slices 1 to 3 need no host to build and test; slice 2 needs the real host to be useful, not to land. @@ -95,11 +131,15 @@ The Matrix file zone (marks, receipts, `ShareSet`); any Mind-side fetch (the mou - **Q-E5.** Per-Persona publishing: a `shared/` subdirectory versus a marker, and who populates it when the Persona decides. - **Q-E6.** Hash cost: compute lazily and cache by (path, mtime, size), or at publish time. - **Q-E7.** CORS for `chat.gamecult.org` and the dev previews, rate limits, and the `Content-Disposition` list. +- **Q-E8.** Subfolders under `uploads/`: can a client create them (by a `PUT` path that implies them, or a `MKCOL`-like call), or is `uploads/` flat? +- **Q-E9.** Cap and allowlist discovery: fields in the index response, or a `/limits` endpoint; and the exact codes and cap numbers behind every placeholder in the upload flow. +- **Q-E10.** Partial writes: temporary name then rename, so an aborted or failed upload never appears in a listing. +- **Q-E11.** Does the index carry an `owner` Matrix id per file (so `Replace` is offered only on one's own files), and is the audit log readable by stewards from the client or only on the host? **For Meta:** - **Q-M1.** Audience: Commons for members and Minds as proposed; are Users and Patrons ever in (the Matrix zone's audience question, `ratatoskr:question:file-zone-audience`, is the same fork)? - **Q-M2.** Is a Persona's zone visible to all members or to her stewards and Minds? -- **Q-M3.** Uploads: yes or no, and if yes, who (members, Minds, stewards) and the cap. +- **Q-M3.** ~~Uploads~~ approved for members and Minds (2026-11-13 14:30Z); open only: may Users or Patrons upload if they are ever let in? - **Q-M4.** Name: "Files" for the row, "Commons" for the shared zone. - **Q-M5.** Does the Matrix Media row stay beside the Files row on each Mind, or does one absorb the other once the Files desk exists (recommendation: both stay; marks and receipts are Matrix's). From 0ab185bb57acee40c300f3a36d56daaac4405472 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 14:48:01 +0000 Subject: [PATCH 32/74] docs: Files desk as a comparison, Matrix as the single authority (A) against the host service (B); publisher rule Co-Authored-By: Claude Sonnet 5.5 --- docs/commons-files-desk.md | 162 ++++++++++++++++++++++++++++++++++--- 1 file changed, 150 insertions(+), 12 deletions(-) diff --git a/docs/commons-files-desk.md b/docs/commons-files-desk.md index f11abb9e..7b106392 100644 --- a/docs/commons-files-desk.md +++ b/docs/commons-files-desk.md @@ -1,10 +1,148 @@ -# Files desk: Commons and per-Persona file zones +# Files desk: Commons and per-Persona file zones, two designs compared -Status: design only. No code, nothing deployed. Items marked **UNKNOWN** are not verified against the Body; **Q-E** / **Q-M** are open questions for Eureka (host) and Meta (audience, names), collected at the end. +Status: design only. No feature code is merged, nothing deployed. **The host-service design (Option B, first written here) is superseded in part** by Meta's ruling that two authorities, Matrix and a Commons host, are incoherent: albums already sit on Matrix, so the Commons should too. Eureka agrees (#minds 2026-11-13 14:40Z), has paused the `/index` and `/file` service, and is checking the homeserver upload cap and whether a Mind account or bot can post file events from Yggdrasil (answers will land on PR #142 and be folded in here). This page now compares **A: Matrix as the single authority** with **B: the host service**, recommends A, and keeps B in full below the line so the comparison can be checked. Items marked **UNKNOWN** are not verified; **Q-E** are questions for Eureka, **Q-M** for Meta. -Source: Meta, #swarm 2026-11-13 14:28Z; ruling `ratatoskr:ruling:persona-file-sharing-zone` (each Persona has her own file sharing zone the frontend shows: a browsable, previewing collection; the Self writes to her outbox, her Persona decides what is shared, the Self never holds Matrix credentials). +Source: Meta, #swarm 2026-11-13 14:28Z and 14:40Z; ruling `ratatoskr:ruling:persona-file-sharing-zone` (a browsable, previewing collection per Persona; the Self writes to her outbox, her Persona decides what is shared, the Self never holds Matrix credentials). -## What exists, and what this adds +## What exists on Matrix today (the base A must fit) + +Read from this repo at dev `8ba407cd`: + +- **Zone = a room** whose `m.room.create` type is `org.gamecult.file_zone` (`isFileZone.ts`), or one listed in config `fileZoneRooms`. One per Mind, `#-files:`, found by alias or by the carried id in the root Space's `org.gamecult.stewardships` (`mediaRoom.ts`, `MediaRow.tsx`); rendered as the Media row of each Mind's group. +- **Album = a thread root** (a text event; its `body`, or its latest `m.replace` edit, is the title); **file = a thread reply** with `msgtype` `m.audio|m.image|m.video|m.file` (`buildAlbums.ts`). A redacted event or an `m.replace` is never a file. Loose room messages are not shown. +- **Read** = `GET /rooms/{id}/threads` and `/relations/{root}/m.thread`, one page of 100 each (`useAlbums.ts`; pagination is a known gap). Unencrypted rooms only. +- **Write** = `sendShareSet` (`shareSet.ts`, behind `fileZoneShare.enabled`): a root, then one thread reply per file, content from `getMsgContent` with `info.sha256`. +- **Media** = authenticated Matrix media fetched to a blob (`downloadMedia` in `FileContent.tsx`): so the "media element cannot send a header" problem that Option B needed a ticket for is already solved this way, at the cost that a file is fully downloaded before it plays. +- **Receipts** = `org.gamecult.file_received` from a Mind's mouth after it fetches and verifies the sha256 (`docs/file-zone-mind-contract.md`); the mouth enforces the Mind's membership in the room. + +Option A is an extension of this, not a second system. + +## The two options + +**A. Matrix is the only authority.** Commons is one more file-zone room; each Mind's zone is the room she already has. Audience is room membership. Upload is the ordinary Matrix upload. Folders are albums (thread roots). A small publisher service with its own Matrix account posts allowlisted handoff documents into the Commons room. The Files desk is a view over file events in those rooms. + +**B. The host service.** A file host on Yggdrasil serves allowlisted directories (`GET /index`, `GET /file`, a capped `PUT`), authenticated by Matrix OpenID; the client browses it as a tree. Written out in full below the line. + +| | A: Matrix only | B: host service | +| -------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- | +| Authorities | one (Matrix: identity, audience, storage, audit) | two (Matrix for identity, the host for files and access) | +| Audience | room membership and power levels | the host derives a role from Matrix identity and enforces it again | +| Audit of writes | the event `sender`, kept by the homeserver | the host's own log | +| Overwriting someone's file | impossible: events are immutable and only the sender's own edit counts; a redaction is a steward's act | host rule, host code | +| Upload size cap | the homeserver's `m.upload.size`, global, **UNKNOWN** value (Q-E) | the host's own, chosen freely | +| Type allowlist | not enforceable by the homeserver; client-side courtesy, steward redaction | enforced by the host (sniffed) | +| What is browsable | what was posted (published or uploaded) | what is on disk under the allowlist, live | +| Mind-written handoff docs | a publisher posts them once, when final (below) | the host serves the tree | +| Per-Persona zone | the existing Media room; the Self's outbox reaches it through the mouth, as the ruling says | a `shared/` directory the host serves | +| Folders | albums; nesting by path in the album title | real directories | +| Mind fetch | unchanged (mouth, room id and event id, receipts) | a second route the mouth would need | +| Media playback | blob download, as today; no seeking before the whole file arrives | ranges and a ticket, or the same blob | +| New moving parts | a room, a publisher | a host service, OpenID verification, CORS, tickets, path containment, rate limits | + +## Recommendation: A + +Confidence: high on the authority question (it agrees with Meta's ruling, with Eureka's lean, with the standing ruling that the Self never holds credentials while her Persona decides what is shared, and with the fact that everything built so far reads Matrix); moderate on the details below, which Eureka's answers can change. + +What A gives up, stated plainly: + +- **A live view of what is on disk.** Only what was published or uploaded appears. A handoff doc that was never marked final is not in the Commons. +- **Files over the homeserver's upload cap.** B had no Matrix limit; A has exactly one. +- **A server-side type allowlist.** Under A a member can upload any type the homeserver accepts; the client shows a refusal for types it does not want, and stewards redact. If a strict allowlist matters, a gating bot (post, check, redact) would have to exist, and it is a second authority again. +- **Seeking inside a large audio or video file** until Matrix media supports ranges for the client (**UNKNOWN**; the blob route is what runs today). +- **Hierarchy beyond the album level** as a native structure; see Folders. + +What it buys: one identity, one audience, one audit trail, immutable files, existing receipts and the mouth's fetch, and no new service to keep secure. + +## Option A in detail + +**Rooms.** Commons is a room with create type `org.gamecult.file_zone`, alias `#commons-files:` (name is **Q-M**), join rule `restricted` to the estate Space so members join without an invite and Minds are invited, history `shared`, unencrypted (the zones are, and `info.sha256` is written only there). Power levels: `events_default` 0 so members post; `redact` and `state_default` at steward level. The room is muted for push by provisioning: file posts are thread replies, which can raise unread counts and notifications (**UNKNOWN** under this server's push rules), so the room must not shout. Per-Persona zones are the rooms Eureka already provisions; nothing new. + +**Folders.** Four ways to have folders on Matrix, in order of preference: + +1. _Albums are folders; nesting is a path in the album title_ (`handoff/2026-11`, `cast-art/poses`). Zero wire change: `buildAlbums` already reads it, and the client groups albums by title path as a pure view. Cost: a thread root per folder, and the rule that a folder's root is posted once by whoever opens the folder. **Recommended.** +2. A `org.gamecult.file.path` key on each file event. Gives a real tree with files anywhere; needs every poster to write it and every client to honour it; other Matrix clients ignore it and show a flat thread. +3. A room per folder, in a Space. Native hierarchy and per-folder audience; heavy (a room is a membership, a sync entry and provisioning each) and it multiplies the sync load on a phone. +4. A state event per folder (`org.gamecult.file_folder`). Mutable folder metadata (title, order), writable only at steward level; useful later if folders need descriptions, not needed for a first cut. + +**Albums in each Mind's room** are the same mechanism: a Mind's Media room holds that Mind's albums, the Commons holds the shared ones. The Files desk is the same view over either room; a zone differs only by room, not by code. + +**The publisher.** A small service on Yggdrasil with its own Matrix account (a bot or a Mind-class account; whether either may post file events from there is Eureka's check, **Q-E1**). The Self never holds this account; the publisher holds it. It owns the allowlist. + +- **When it posts.** Handoff documents are edited in place, so mirroring every save would spam the room with drafts. The rule (Eureka's lean, adopted): **post once, when the document is marked final**; a later final version posts again with a `replaces` relation to the earlier event. How "final" is marked (a front-matter line, a sibling marker, a directory convention) is the publisher's concern and a **Q-E2**; the client must not know. +- **Allowlist.** Lives on the publisher: a list of source roots and patterns, read by the publisher only. `handoff-private` is not on it and the publisher never lists, reads or posts from it. The client holds no path to anything on disk. +- **The event.** A file event in the thread of the folder's album, built the way `getMsgContent` builds them (`msgtype`, `body`, `filename`, `url` from the media repo, `info.mimetype|size|sha256`), plus one extra key `org.gamecult.publish`: `{ source, sha256, replaces? }` where `source` is a path relative to the allowlisted root, never absolute, and `replaces` is the event id of the version it supersedes. +- **`replaces`, not `m.replace`.** `m.replace` is the stock edit relation, but Ratatoskr's `buildAlbums` deliberately never treats an edit as a file, several clients do not render an edited file, and an edit hides the earlier version. A custom key keeps both events as files; the view shows the latest of a chain and a "N earlier versions" row under it, and other Matrix clients see two ordinary file posts. A chain is read from the events themselves (each carries its `replaces`), not from a table. +- **Idempotence.** The publisher's own state (a typed CultCache document of `source`, `sha256`, `event_id`) says what it posted; if that is lost, it rebuilds by reading its own events back from the room (`org.gamecult.publish.source` plus `sha256`) rather than guessing. The same `sha256` never posts twice. +- **Receipts and fetch** work unchanged: the event is an ordinary file event in a file-zone room, so a Mind's mouth can fetch it by room id and event id (5 MiB cap there) and tick `fetched for`. + +**Uploads (approved for members and Minds).** The ordinary Matrix upload, through the composer pipeline and `ShareSet`. The host rules Eureka gave map onto Matrix as follows: + +| Host rule | Under A | +| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | +| only under an `uploads/` subtree | an album (folder) named `uploads/...`; the client offers upload only there (and in a Mind's own zone) | +| never overwrite Mind-written handoff files | free: other people's events cannot be replaced; a duplicate name is just another post in the thread | +| size cap | the homeserver's `m.upload.size`, shown from `MediaConfigLoader`; the client refuses a larger file before sending | +| type allowlist | client-side only; see "gives up" | +| explicit flag to overwrite | no overwrite exists; a new version is a new post, optionally with `replaces` set by the same sender | +| audit log with the Matrix id | the event `sender` | + +What A adds to `ShareSet`: sharing into an existing album ("add files", listed as not built in `docs/file-zone.md`), progress per file, cancel, and per-error refusal messages written against the SDK's `MatrixError` (`M_TOO_LARGE`, `M_FORBIDDEN`, `M_LIMIT_EXCEEDED`) instead of host codes. The draft refusal table of Option B's upload flow carries over with those codes. **UNKNOWN:** whether the SDK exposes upload progress and abort for media (it does for `uploadContent` with a progress callback; the composer already shows progress, `state/upload.ts`). + +## What each costs on the phone + +Under both, the phone design is the same shape (one column, rows 56 px or more, hidden controls, in-place preview; see the Option B sections below, which are written against the host but whose layout applies). + +- **A, new cost:** the Commons room is one more room in sync and it must be muted; albums and files are one page of 100 each until pagination exists (a Commons of hundreds of documents needs it first); a folder tree is a title-path grouping, so a deep tree is a long list of albums with breadcrumbs, not true directories; a file plays after a full download (small audio and images are fine; a 40 MB clip is not). +- **A, no cost:** no second sign-in, no ticket, no CORS, no new fetch layer, no host-unreachable state beyond the homeserver's own. The album list, album view, share panel and receipts exist and are tested; the view is their extension. +- **B, new cost:** every screen is a second client against a second service with its own errors, token refresh and unreachable state; media needs the ticket or the blob; the tree and preview are new UI. +- **B, benefit:** a tree that is live and complete, ranges, and no Matrix size limit. + +## Changes to Eureka's host work + +Under A the paused service is not built, and most of its list disappears: + +- **Dropped:** `/index`, `/file`, `PUT`, OpenID verification pinned to the server name, CORS, path containment and sniff rules, tickets, the `uploads/` write rules, the role response, the per-path audit log (Q-E3, Q-E4, Q-E6, Q-E7, Q-E8 to Q-E11 of the Option B list). +- **Kept:** the Commons allowlist decision (Q-E1 of that list), moved to the publisher: which roots, and that `handoff` is only ever published through the final-marker rule, never browsed. +- **New:** (1) provision the Commons room (type, alias, restricted join to the estate Space, power levels, push muted); (2) the publisher: account, allowlist, final-marker rule, media upload, posting into folder threads, the `org.gamecult.publish` key, idempotence from its own events; (3) confirm the homeserver upload cap and whether the account may post file events from Yggdrasil (**Q-E1**, asked on the PR); (4) the per-Persona path stays the one already specified (`album-outbox-ops`: outbox, Persona decision, mouth post), so the `shared/` convention of Option B is retired. + +## Cuts under A + +1. **Commons entry**: a Commons row in the navigator (like `MediaRow`, found by alias or carried id), opening the room's album view. Flagged, no new data layer. +2. **Pagination** of albums and files in `useAlbums` (a prerequisite for a Commons of any size). +3. **Folders by title path** and the album list grouped by it. +4. **Versions**: read `org.gamecult.publish.replaces`, show the latest of a chain with the earlier versions folded under it. +5. **Add files to an existing album**, with per-file progress, cancel and the refusal messages above (the upload flow). + +PR #143 (Option B cut 1: contract types, host client, fake host) is **held**; under A it is not needed and would be closed. Its Soul pass found, and a revived B would have to close, an unvalidated `host` that could send the bearer to the wrong origin, hostile-looking entry names beyond separators, loose numeric checks and missing CSP and `Content-Disposition` headers in the fake. + +## Not in scope + +The Mind-side fetch (the mouth's); encrypted rooms; deletion and rename by this client; search across zones; any permission model of our own beyond Matrix room membership and power levels. + +## Open questions + +**Eureka (#minds 14:40Z, answers to land on PR #142):** + +- **Q-E1.** What is the homeserver upload cap (`max_upload_size`), and may a bot or Mind-class account post file events from Yggdrasil (the publisher's account)? +- **Q-E2.** How a handoff doc is marked final (front matter, sibling marker, or directory), and which roots the allowlist names. +- **Q-E3.** Commons room provisioning: alias, restricted join to the estate Space, power levels, push rules. +- **Q-E4.** Does Synapse media honour `Range` for authenticated media (decides whether large audio and video need anything beyond the blob route)? + +**Meta:** + +- **Q-M1.** Commons audience: members and Minds as proposed; Users and Patrons never, or later? +- **Q-M2.** Is a Persona's zone visible to all members or to her stewards and Minds? (The Matrix zone's audience question, `ratatoskr:question:file-zone-audience`, is the same fork.) +- **Q-M3.** May Users or Patrons upload if they are ever let in? +- **Q-M4.** Names: "Files" for the view, "Commons" for the shared room. +- **Q-M5.** A type allowlist is not enforceable under A without a gating bot; is a client-side refusal plus steward redaction enough? + +--- + +# Option B in full (kept for the comparison; superseded in part by Option A above) + +The sections below were written against the host service. Where they speak of a host, a role, a ticket, a token, `/index` or `/file`, they are Option B. The phone layout, the zone table and the refusal wording carry over to A with the Matrix equivalents given above. + +### What exists, and what this adds | | Matrix file zone (`docs/file-zone.md`, built) | Files desk (this page) | | -------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | @@ -16,7 +154,7 @@ Source: Meta, #swarm 2026-11-13 14:28Z; ruling `ratatoskr:ruling:persona-file-sh They do not compete. The Matrix zone stays where a file needs a mark or a receipt (Aqua's listening kits); the Files desk is for browsing and fetching what is already on disk. Neither replaces the other, and the Files desk does not write Matrix events. The ruling left "Matrix media plus a listing versus a served directory" open; this page is the served-directory half, built beside the Matrix half already live. -## Zones +### Zones (host) | Zone | Contents | Who sees it | | ---------------- | ------------------------------------------------------------ | -------------------------------------------------------------- | @@ -28,7 +166,7 @@ Layout of `/opt/commons` today (read 2026-11-13): `backlog` (755, per-Mind backl Per-Persona source is the outbox from `ratatoskr:follow_up:album-outbox-ops-and-persona-specs`: `/var/lib/mind--outbox`, owned by `mind--self`, 0750, ACL read for `mind-` and the host user. The Self writes there; the Persona decides what is shared. Making that decision a filesystem fact (a `shared/` subdirectory the Persona's turn populates by copy or hardlink, the host serves only `shared/`) keeps the host dumb: it never learns what "decides" means. **Q-E:** `shared/` subdirectory versus a per-file marker. -## Phone-first UI +### Phone-first UI (host) Phone width is the design width; desktop adds the navigator beside it (as `Desk` does in `Router.tsx`). Vertical space is cheap, so a screen shows one thing and hides what is not in use. @@ -39,7 +177,7 @@ Phone width is the design width; desktop adds the navigator beside it (as `Desk` - **Honesty rows.** Loading, empty, "host unreachable", "not allowed" are rows that say so (as `MediaNote` does); no spinner without a name for what it waits on. - **Role-driven.** The index response carries the caller's role; the client uses it only to hide controls (upload), never to decide access. -## Host contract +### Host contract The host is a service on Yggdrasil that Eureka owns. The client holds no filesystem knowledge. All JSON here is the xenos boundary between a browser and a file server, not state. @@ -60,7 +198,7 @@ The host is a service on Yggdrasil that Eureka owns. The client holds no filesys Recommendation: 2 for audio and video, 1 for images and text. Confidence moderate: I have not tested a service worker against range requests on the target phone, and 2 is the usual answer for authenticated media (signed URLs). **Q-E.** -## Upload flow +### Upload flow (host) Design only. Every number and error code is a named placeholder until Eureka gives them on the PR: `` (per-file size), `` (per-pick total, if any), `` (the allowlist), `` (the explicit query flag), and the codes ``, ``, ``, ``, ``, ``, ``, ``, ``. @@ -96,7 +234,7 @@ Retry is one tap on a failed row; the batch never retries on its own. An unknown **Reuse and not.** `ShareSet`'s state machine (staging, one-send-per-zone guard, failed files kept by position) is the model, but the Matrix share path and its `createUploadAtom` are tied to the SDK's media upload and are not reused; the new controller sits in `features/files/uploadController.ts` with the same injected-port shape, so order, refusal and cancel rules are tested against a fake `PUT` without a browser. Real transfer progress and a real range of a phone's picker stay a phone check. -## Client shape +### Client shape (host) Under `src/app/features/files`, mirroring `features/albums`: @@ -106,7 +244,7 @@ Under `src/app/features/files`, mirroring `features/albums`: - `config.json`: `filesDesk: { enabled: false, host: "https://…" }`, dark by default as `fileZoneShare.enabled` is. With it off, no row mounts and no request leaves the client. - Test host: `src/preview-demo` already answers for a pretend homeserver through a fetch guard (`install.ts`); it gains a fake `/index` and `/file` with ranges so the previews are checked in a real browser before the real host exists. -## Cuts +### Cuts (host) 1. **Contract and fake host** (no UI): `filesHost.ts`, types, `preview-demo` fake, path tests (traversal strings from the host side are Eureka's; the client must only build `path` from entries it was given). 2. **Read-only Commons desk**: route, row, list, breadcrumb, role display, flagged off. @@ -116,11 +254,11 @@ Under `src/app/features/files`, mirroring `features/albums`: Each is its own PR. Slices 1 to 3 need no host to build and test; slice 2 needs the real host to be useful, not to land. -## Not in scope +### Not in scope (host) The Matrix file zone (marks, receipts, `ShareSet`); any Mind-side fetch (the mouth's); encrypted rooms; deletion and rename (a read surface first; the files belong to whoever wrote them on disk); search across zones; a permission model of our own beyond what the host derives from Matrix identity. -## Open questions +### Open questions (host) **For Eureka (host):** From a8e3acbde0cc08e91e8f56cb83b7a9fb72a62bec Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 15:46:23 +0000 Subject: [PATCH 33/74] docs: Files desk decided as Matrix-only (A); Eureka's notes folded in; cut 1 recorded Co-Authored-By: Claude Sonnet 5.5 --- docs/commons-files-desk.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/commons-files-desk.md b/docs/commons-files-desk.md index 7b106392..9689f744 100644 --- a/docs/commons-files-desk.md +++ b/docs/commons-files-desk.md @@ -1,6 +1,13 @@ # Files desk: Commons and per-Persona file zones, two designs compared -Status: design only. No feature code is merged, nothing deployed. **The host-service design (Option B, first written here) is superseded in part** by Meta's ruling that two authorities, Matrix and a Commons host, are incoherent: albums already sit on Matrix, so the Commons should too. Eureka agrees (#minds 2026-11-13 14:40Z), has paused the `/index` and `/file` service, and is checking the homeserver upload cap and whether a Mind account or bot can post file events from Yggdrasil (answers will land on PR #142 and be folded in here). This page now compares **A: Matrix as the single authority** with **B: the host service**, recommends A, and keeps B in full below the line so the comparison can be checked. Items marked **UNKNOWN** are not verified; **Q-E** are questions for Eureka, **Q-M** for Meta. +Status: **decided: Option A, Matrix as the single authority** (Meta, #swarm 2026-11-13 14:44Z: "Let's try matrix shaped"). Design only; no Files desk code is merged and nothing is deployed. Option B (the host service) is parked, not deleted: Eureka's branch `eureka-self/commons-host` at `a391960d` has no PR and a Soul verdict of NOT READY with fixes owed, and is woken only if Meta later wants a real file store. PR #143 (B's cut 1 in this repo) is closed unmerged. Items marked **UNKNOWN** are not verified; **Q-E** are questions for Eureka, **Q-M** for Meta. + +### Eureka's answers so far (`/opt/commons/handoff/commons-matrix-notes-20261113.md`, read 2026-11-13; not yet posted on PR #142) + +- **Upload cap (Q-E1, read).** Synapse 1.164.1; the repo template `homeserver.yaml` has `max_upload_size: 50M` and the nginx vhost `client_max_body_size 50m`; the live runtime file is root-only and was not read. No per-room or per-user per-file cap exists; `media_upload_limits` is a per-user time-window quota. `GET /_matrix/client/v1/media/config` returns `m.upload.size` to an authenticated client, which is what the client reads. Raising the cap takes the config line, a Synapse restart and nginx at least as large. +- **Posting (Q-E1, read).** A Self holds no Matrix credentials, so the publisher needs its own bot account and token (a MAS compatibility token via `mas-cli`). It uploads with `POST /_matrix/media/v3/upload` and sends `m.room.message` with `msgtype` `m.file` or `m.image`. The Persona mouth sends `m.text` only today, so file events are new for it too. +- **Not yet read:** the final-marker convention (Eureka proposes a sibling `.final` or a `published/` hardlink the publisher watches, with `org.gamecult.publish.replaces` carrying the prior event id and sha256), Commons room provisioning (alias, power levels, unencrypted, membership by the estate Space, publisher bot at power level 50), and whether Synapse media honours `Range` (to be checked in `synapse/media/media_repository.py` and against a live mxc). +- **Edited-doc stale copies:** a redacted file's media persists unless purged; the publisher dedupes by sha256. Source: Meta, #swarm 2026-11-13 14:28Z and 14:40Z; ruling `ratatoskr:ruling:persona-file-sharing-zone` (a browsable, previewing collection per Persona; the Self writes to her outbox, her Persona decides what is shared, the Self never holds Matrix credentials). @@ -39,7 +46,7 @@ Option A is an extension of this, not a second system. | Media playback | blob download, as today; no seeking before the whole file arrives | ranges and a ticket, or the same blob | | New moving parts | a room, a publisher | a host service, OpenID verification, CORS, tickets, path containment, rate limits | -## Recommendation: A +## Recommendation: A (decided) Confidence: high on the authority question (it agrees with Meta's ruling, with Eureka's lean, with the standing ruling that the Self never holds credentials while her Persona decides what is shared, and with the fact that everything built so far reads Matrix); moderate on the details below, which Eureka's answers can change. @@ -107,6 +114,8 @@ Under A the paused service is not built, and most of its list disappears: ## Cuts under A +Status 2026-11-13: none built. Cut 1 below is next, on branch `void/files-desk-view` (worktree `wt/files-view`, no commits yet); its shape: a pure `albumFolders` view (folders from `/`-separated album titles, leaf name, counts), folder navigation and breadcrumb in `AlbumView` through a presentational list component tested by static render, `ShareSet` sending into the current folder by prefixing the album title, and a quiet `Commons` row (alias `#commons-files:` or the carried `media.commons` id, through `lookupMediaRoom`, hidden while unset or looking, behind a dark `filesDesk.enabled` flag, and filtered out of the tiers so it is not listed twice). Muting is provisioning's (Q-E3), not the client's. + 1. **Commons entry**: a Commons row in the navigator (like `MediaRow`, found by alias or carried id), opening the room's album view. Flagged, no new data layer. 2. **Pagination** of albums and files in `useAlbums` (a prerequisite for a Commons of any size). 3. **Folders by title path** and the album list grouped by it. From 9db91378c52f981b45586f73c1504a8c320e1d2c Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 17:52:10 +0000 Subject: [PATCH 34/74] Phone glyphs: icons inside IconButtons and the nav chevron draw larger on phones, box unchanged folds draws a glyph at 16-24 px; on a phone the 16/18/20 px ones read small in their buttons. One global rule per folds Icon size scales the glyph (22/24/24/26/28 px) at paint time inside an IconButton, so no button, PhoneHit area or row changes size and desktop is untouched. phoneGlyph.test.ts pins the folds class names, the px range and that the rule is a phone-only scale. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room-nav/styles.css.ts | 8 ++++ src/app/phoneGlyph.test.ts | 53 +++++++++++++++++++++++++ src/index.tsx | 1 + src/phoneGlyph.css.ts | 28 +++++++++++++ 4 files changed, 90 insertions(+) create mode 100644 src/app/phoneGlyph.test.ts create mode 100644 src/phoneGlyph.css.ts diff --git a/src/app/features/room-nav/styles.css.ts b/src/app/features/room-nav/styles.css.ts index 1d46e596..afa0c1a0 100644 --- a/src/app/features/room-nav/styles.css.ts +++ b/src/app/features/room-nav/styles.css.ts @@ -3,6 +3,7 @@ import { config } from 'folds'; import { surfaceInk } from '../scene/sceneShell.css'; import { skyLabelInk } from '../scene/skyLabelInk'; import { NAV_ICON_REST_OPACITY } from '../../components/nav/styles.css'; +import { PHONE_GLYPHS } from '../../../phoneGlyph.css'; export const CategoryButton = style({ flexGrow: 1, @@ -32,4 +33,11 @@ export const CategoryButton = style({ }); export const CategoryButtonIcon = style({ opacity: NAV_ICON_REST_OPACITY, + // A phone draws the 16 px chevron as its IconButton neighbours are drawn (see phoneGlyph.css.ts): paint-time, so + // the gutter and the row keep their layout. + '@media': { + 'screen and (max-width: 750px)': { + scale: `${PHONE_GLYPHS['50'].phonePx / PHONE_GLYPHS['50'].px}`, + }, + }, }); diff --git a/src/app/phoneGlyph.test.ts b/src/app/phoneGlyph.test.ts new file mode 100644 index 00000000..0e54c22a --- /dev/null +++ b/src/app/phoneGlyph.test.ts @@ -0,0 +1,53 @@ +import { readFileSync } from 'fs'; +import { describe, expect, it } from 'vitest'; +import { MOBILE_BREAKPOINT } from './hooks/useScreenSize'; +import { emitted } from './phoneHitTestKit'; +import { ICON_BUTTON_CLASS, PHONE_GLYPHS } from '../phoneGlyph.css'; + +const foldsJs = readFileSync(`${process.cwd()}/node_modules/folds/dist/index.js`, 'utf8'); + +describe('phone glyphs inside IconButtons', () => { + it("name folds' own classes: the IconButton base class and one Icon class per size", () => { + expect(foldsJs).toContain( + `var IconButton$1 = createRuntimeFn({ defaultClassName: "${ICON_BUTTON_CLASS} ` + ); + const iconSizes = foldsJs.match( + /var Icon\$1 = createRuntimeFn\(\{[^\n]*variantClassNames: \{ size: \{([^}]*)\}/ + )?.[1]; + expect(iconSizes).toBeDefined(); + Object.entries(PHONE_GLYPHS).forEach(([size, { cls }]) => + expect(iconSizes).toContain(`"${size}": "${cls}"`) + ); + }); + + it('draw the folds Icon px (16 18 20 22 24) at 22..28 px, never smaller, never past the smallest button box', () => { + // folds IconButton padding is 4 px a side at the smallest size, so its box is the glyph plus 8. + Object.values(PHONE_GLYPHS).forEach(({ px, phonePx }) => { + expect(phonePx).toBeGreaterThanOrEqual(22); + expect(phonePx).toBeLessThanOrEqual(28); + expect(phonePx).toBeGreaterThan(px); + expect(phonePx).toBeLessThanOrEqual(px + 8); + }); + }); + + it('are a paint-time scale on phones only: no size, margin or padding, and no rule outside the phone query', async () => { + const css = await emitted('../phoneGlyph.css.ts'); + const phone = `@media screen and (max-width: ${MOBILE_BREAKPOINT}px)`; + expect( + css.replace(new RegExp(`${phone.replace(/[()]/g, '\\$&')} \\{\\n[\\s\\S]*?\\n\\}`, 'g'), '') + ).not.toMatch(/\.brs7ce5/); + Object.entries(PHONE_GLYPHS).forEach(([size, { cls, px, phonePx }]) => { + const rule = css.match( + new RegExp(`\\.${ICON_BUTTON_CLASS} > \\.${cls} \\{\\n([^}]*)\\}`) + )?.[1]; + expect(rule, `rule for Icon size ${size}`).toBeDefined(); + expect( + rule! + .trim() + .split('\n') + .map((l) => l.trim().split(':')[0]) + ).toEqual(['scale']); + expect(Number(rule!.match(/scale: ([\d.]+)/)![1])).toBeCloseTo(phonePx / px, 6); + }); + }); +}); diff --git a/src/index.tsx b/src/index.tsx index 51e9760f..eca2bc07 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -14,6 +14,7 @@ import '@fontsource/montserrat/200.css'; import '@fontsource/montserrat/300.css'; import '@fontsource/ibm-plex-mono/400.css'; import 'folds/dist/style.css'; +import './phoneGlyph.css'; import { configClass, varsClass } from 'folds'; enableMapSet(); diff --git a/src/phoneGlyph.css.ts b/src/phoneGlyph.css.ts new file mode 100644 index 00000000..14707f63 --- /dev/null +++ b/src/phoneGlyph.css.ts @@ -0,0 +1,28 @@ +import { globalStyle } from '@vanilla-extract/css'; +import { MOBILE_BREAKPOINT } from './app/hooks/useScreenSize'; + +// Phone glyph size. On a phone the glyph inside an IconButton is drawn larger, by `scale` (a paint-time transform: +// the button's box, its PhoneHit area and every row of buttons keep the layout they have, so nothing reflows at +// 320 px). folds draws a glyph at one of its Icon sizes and the IconButton's box is padding plus that glyph, so the +// factor is chosen per Icon size. Desktop is not touched. +// +// The class names are folds 2.7.2's own (the Icon size classes and the IconButton base class); phoneGlyph.test.ts +// reads them back from folds' dist, so a folds upgrade that renames them fails there instead of silently here. +export const ICON_BUTTON_CLASS = 'brs7ce5'; +/** folds Icon size -> [its class, its px, the px it is drawn at on a phone]. The 24 px glyph is the folds default and + * reads small in a 40 px button, so it grows too; 28 px and up are already large. */ +export const PHONE_GLYPHS = { + '50': { cls: 'cpipac7', px: 16, phonePx: 22 }, + '100': { cls: 'cpipac8', px: 18, phonePx: 24 }, + '200': { cls: 'cpipac9', px: 20, phonePx: 24 }, + '300': { cls: 'cpipaca', px: 22, phonePx: 26 }, + '400': { cls: 'cpipacb', px: 24, phonePx: 28 }, +} as const; + +const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; + +Object.values(PHONE_GLYPHS).forEach(({ cls, px, phonePx }) => + globalStyle(`.${ICON_BUTTON_CLASS} > .${cls}`, { + '@media': { [PHONE]: { scale: `${phonePx / px}` } }, + }) +); From 9d65a345079507d14df0f7416b43fd28fa145d74 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 18:20:12 +0000 Subject: [PATCH 35/74] phoneGlyph test: pin each Icon size's px to folds' own CSS Co-Authored-By: Claude Sonnet 5.5 --- src/app/phoneGlyph.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/app/phoneGlyph.test.ts b/src/app/phoneGlyph.test.ts index 0e54c22a..6969da0e 100644 --- a/src/app/phoneGlyph.test.ts +++ b/src/app/phoneGlyph.test.ts @@ -5,6 +5,14 @@ import { emitted } from './phoneHitTestKit'; import { ICON_BUTTON_CLASS, PHONE_GLYPHS } from '../phoneGlyph.css'; const foldsJs = readFileSync(`${process.cwd()}/node_modules/folds/dist/index.js`, 'utf8'); +const foldsCss = readFileSync(`${process.cwd()}/node_modules/folds/dist/style.css`, 'utf8'); + +/** The px folds draws an Icon class at: its width variable's rem value, at the 16 px root. */ +const foldsPx = (cls: string) => { + const v = foldsCss.match(new RegExp(`\\.${cls} \\{\\s*width: var\\((--[\\w-]+)\\)`))?.[1]; + const rem = v && foldsCss.match(new RegExp(`${v}: ([\\d.]+)rem`))?.[1]; + return rem ? Number(rem) * 16 : undefined; +}; describe('phone glyphs inside IconButtons', () => { it("name folds' own classes: the IconButton base class and one Icon class per size", () => { @@ -20,6 +28,9 @@ describe('phone glyphs inside IconButtons', () => { ); }); + it("start from the px folds draws each Icon size at (the scale's divisor is not a guess)", () => + Object.values(PHONE_GLYPHS).forEach(({ cls, px }) => expect(foldsPx(cls)).toBe(px))); + it('draw the folds Icon px (16 18 20 22 24) at 22..28 px, never smaller, never past the smallest button box', () => { // folds IconButton padding is 4 px a side at the smallest size, so its box is the glyph plus 8. Object.values(PHONE_GLYPHS).forEach(({ px, phonePx }) => { From 4fd83fb4c415f1be025e0fecbbb35d570fffcf38 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 18:36:51 +0000 Subject: [PATCH 36/74] Soft logout keeps local data and re-logs in on the same device; MAS front hides register and reset Soft (soft_logout === true) drops only the access token and records the device; sign-in is fixed to that user and sends device_id; same user and device keeps the stores, anything else wipes via the one wipeLocalData. Hard logout still wipes. When discovery advertises an msc2965 account or issuer, the register and forgot-password entries give way to a notice pointing at the account page. Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/MasAccountNotice.tsx | 11 ++ src/app/pages/auth/login/Login.tsx | 13 +- .../pages/auth/login/PasswordLoginForm.tsx | 92 ++++++++------ src/app/pages/auth/login/SoftLogoutNotice.tsx | 20 +++ src/app/pages/auth/login/TokenLogin.tsx | 16 ++- src/app/pages/auth/login/loginUtil.ts | 16 ++- src/app/pages/auth/masAccount.test.ts | 74 +++++++++++ src/app/pages/auth/masAccount.ts | 32 +++++ .../auth/register/RegisterRoute.test.tsx | 39 ++++-- src/app/pages/auth/register/RegisterRoute.tsx | 18 ++- .../auth/reset-password/ResetPassword.tsx | 9 +- src/app/pages/client/ClientRoot.tsx | 14 +- src/app/state/softLogout.test.ts | 120 ++++++++++++++++++ src/app/state/softLogout.ts | 91 +++++++++++++ src/client/initMatrix.ts | 53 ++++++-- 15 files changed, 541 insertions(+), 77 deletions(-) create mode 100644 src/app/pages/auth/MasAccountNotice.tsx create mode 100644 src/app/pages/auth/login/SoftLogoutNotice.tsx create mode 100644 src/app/pages/auth/masAccount.test.ts create mode 100644 src/app/pages/auth/masAccount.ts create mode 100644 src/app/state/softLogout.test.ts create mode 100644 src/app/state/softLogout.ts diff --git a/src/app/pages/auth/MasAccountNotice.tsx b/src/app/pages/auth/MasAccountNotice.tsx new file mode 100644 index 00000000..b8f506b4 --- /dev/null +++ b/src/app/pages/auth/MasAccountNotice.tsx @@ -0,0 +1,11 @@ +import { Text } from 'folds'; +import React from 'react'; + +export function MasAccountNotice({ url }: { url: string }) { + return ( + + Accounts here are managed by your sign-in service. Create or recover an account on its{' '} + account page. + + ); +} diff --git a/src/app/pages/auth/login/Login.tsx b/src/app/pages/auth/login/Login.tsx index ea08e4f0..05204779 100644 --- a/src/app/pages/auth/login/Login.tsx +++ b/src/app/pages/auth/login/Login.tsx @@ -15,6 +15,10 @@ import { getLoginPath, getRegisterPath, withSearchParam } from '../../pathUtils' import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin'; import { LoginPathSearchParams } from '../../paths'; import { useClientConfig } from '../../../hooks/useClientConfig'; +import { useMasAccountUrl } from '../masAccount'; +import { MasAccountNotice } from '../MasAccountNotice'; +import { SoftLogoutNotice } from './SoftLogoutNotice'; +import { useSoftLogout } from '../../../state/softLogout'; import { noSignInMethodMessage } from '../authMessages'; const getLoginTokenSearchParam = () => { @@ -57,12 +61,15 @@ export function Login() { } const parsedFlows = useParsedLoginFlows(loginFlows.flows); + const masUrl = useMasAccountUrl(); + const softLogout = useSoftLogout(); return ( Login + {softLogout && } {parsedFlows.token && loginSearchParams.loginToken && ( )} @@ -93,9 +100,9 @@ export function Login() { )} - {registrationEntry(config) ? ( - - ) : ( + {masUrl && } + {!masUrl && registrationEntry(config) && } + {!masUrl && !registrationEntry(config) && ( Do not have an account? Register diff --git a/src/app/pages/auth/login/PasswordLoginForm.tsx b/src/app/pages/auth/login/PasswordLoginForm.tsx index 6372684c..63c28577 100644 --- a/src/app/pages/auth/login/PasswordLoginForm.tsx +++ b/src/app/pages/auth/login/PasswordLoginForm.tsx @@ -19,7 +19,7 @@ import { } from 'folds'; import FocusTrap from 'focus-trap-react'; import { Link } from 'react-router-dom'; -import { MatrixError } from 'matrix-js-sdk'; +import { LoginRequest, MatrixError } from 'matrix-js-sdk'; import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix'; import { EMAIL_REGEX } from '../../../utils/regex'; import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo'; @@ -39,6 +39,8 @@ import { loginErrorMessage } from '../authMessages'; import { ForgotLink } from '../styles.css'; import { getResetPasswordPath } from '../../pathUtils'; import { stopPropagation } from '../../../utils/keyboard'; +import { useSoftLogout, withSoftLogoutDevice } from '../../../state/softLogout'; +import { useMasAccountUrl } from '../masAccount'; import { PhoneHit } from '../../../styles/PhoneHit.css'; function UsernameHint({ server }: { server: string }) { @@ -118,6 +120,10 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog const server = useAuthServer(); const clientConfig = useClientConfig(); + const softLogout = useSoftLogout(); + const masUrl = useMasAccountUrl(); + const withDevice = (request: LoginRequest) => withSoftLogoutDevice(request, softLogout); + const serverDiscovery = useAutoDiscoveryInfo(); const baseUrl = serverDiscovery['m.homeserver'].base_url; @@ -130,15 +136,18 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog useLoginComplete(loginState.status === AsyncStatus.Success ? loginState.data : undefined); const handleUsernameLogin = (username: string, password: string) => { - startLogin(baseUrl, { - type: 'm.login.password', - identifier: { - type: 'm.id.user', - user: username, - }, - password, - initial_device_display_name: 'Ratatoskr Web', - }); + startLogin( + baseUrl, + withDevice({ + type: 'm.login.password', + identifier: { + type: 'm.id.user', + user: username, + }, + password, + initial_device_display_name: 'Ratatoskr Web', + }) + ); }; const handleMxIdLogin = async (mxId: string, password: string) => { @@ -148,27 +157,33 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog const getBaseUrl = factoryGetBaseUrl(clientConfig, mxIdServer); - startLogin(getBaseUrl, { - type: 'm.login.password', - identifier: { - type: 'm.id.user', - user: mxIdUsername, - }, - password, - initial_device_display_name: 'Ratatoskr Web', - }); + startLogin( + getBaseUrl, + withDevice({ + type: 'm.login.password', + identifier: { + type: 'm.id.user', + user: mxIdUsername, + }, + password, + initial_device_display_name: 'Ratatoskr Web', + }) + ); }; const handleEmailLogin = (email: string, password: string) => { - startLogin(baseUrl, { - type: 'm.login.password', - identifier: { - type: 'm.id.thirdparty', - medium: 'email', - address: email, - }, - password, - initial_device_display_name: 'Ratatoskr Web', - }); + startLogin( + baseUrl, + withDevice({ + type: 'm.login.password', + identifier: { + type: 'm.id.thirdparty', + medium: 'email', + address: email, + }, + password, + initial_device_display_name: 'Ratatoskr Web', + }) + ); }; const handleSubmit: FormEventHandler = (evt) => { @@ -207,7 +222,8 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog Username )} - - - - Forget Password? - - - + {!masUrl && ( + + + + Forget Password? + + + + )} + + ); +} diff --git a/src/app/pages/auth/login/TokenLogin.tsx b/src/app/pages/auth/login/TokenLogin.tsx index 0541742d..51a4b46b 100644 --- a/src/app/pages/auth/login/TokenLogin.tsx +++ b/src/app/pages/auth/login/TokenLogin.tsx @@ -14,6 +14,7 @@ import React, { useCallback, useEffect } from 'react'; import { MatrixError } from 'matrix-js-sdk'; import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { useSoftLogout, withSoftLogoutDevice } from '../../../state/softLogout'; import { CustomLoginResponse, LoginError, login, useLoginComplete } from './loginUtil'; function LoginTokenError({ message }: { message: string }) { @@ -46,6 +47,7 @@ type TokenLoginProps = { export function TokenLogin({ token }: TokenLoginProps) { const discovery = useAutoDiscoveryInfo(); const baseUrl = discovery['m.homeserver'].base_url; + const softLogout = useSoftLogout(); const [loginState, startLogin] = useAsyncCallback< CustomLoginResponse, @@ -54,12 +56,14 @@ export function TokenLogin({ token }: TokenLoginProps) { >(useCallback(login, [])); useEffect(() => { - startLogin(baseUrl, { - type: 'm.login.token', - token, - initial_device_display_name: 'Ratatoskr Web', - }); - }, [baseUrl, token, startLogin]); + startLogin( + baseUrl, + withSoftLogoutDevice( + { type: 'm.login.token', token, initial_device_display_name: 'Ratatoskr Web' }, + softLogout + ) + ); + }, [baseUrl, token, softLogout, startLogin]); useLoginComplete(loginState.status === AsyncStatus.Success ? loginState.data : undefined); diff --git a/src/app/pages/auth/login/loginUtil.ts b/src/app/pages/auth/login/loginUtil.ts index ba5b0bc9..f4124db7 100644 --- a/src/app/pages/auth/login/loginUtil.ts +++ b/src/app/pages/auth/login/loginUtil.ts @@ -11,6 +11,8 @@ import { } from '../../afterLoginRedirectPath'; import { getHomePath } from '../../pathUtils'; import { setFallbackSession } from '../../../state/sessions'; +import { clearSoftLogout, decideLoginOutcome, getSoftLogout } from '../../../state/softLogout'; +import { wipeLocalData } from '../../../../client/initMatrix'; export enum GetBaseUrlError { NotAllow = 'NotAllow', @@ -112,12 +114,18 @@ export const useLoginComplete = (data?: CustomLoginResponse) => { const navigate = useNavigate(); useEffect(() => { - if (data) { - const { response: loginRes, baseUrl: loginBaseUrl } = data; - setFallbackSession(loginRes.access_token, loginRes.device_id, loginRes.user_id, loginBaseUrl); + if (!data) return; + const { response: loginRes, baseUrl: loginBaseUrl } = data; + const finish = async () => { const afterLoginRedirectUrl = getAfterLoginRedirectPath(); + // A soft logout left stores behind; they stay only for the same user on the same device. + const outcome = decideLoginOutcome(getSoftLogout(), loginRes); + if (outcome === 'wipe') await wipeLocalData(); + clearSoftLogout(); + setFallbackSession(loginRes.access_token, loginRes.device_id, loginRes.user_id, loginBaseUrl); deleteAfterLoginRedirectPath(); navigate(afterLoginRedirectUrl ?? getHomePath(), { replace: true }); - } + }; + finish(); }, [data, navigate]); }; diff --git a/src/app/pages/auth/masAccount.test.ts b/src/app/pages/auth/masAccount.test.ts new file mode 100644 index 00000000..c45497f3 --- /dev/null +++ b/src/app/pages/auth/masAccount.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'fs'; +import { masAccountUrl } from './masAccount'; +import type { AutoDiscoveryInfo } from '../../cs-api'; + +const info = (auth?: unknown): AutoDiscoveryInfo => + ({ + 'm.homeserver': { base_url: 'https://hs.example' }, + ...(auth === undefined ? {} : { 'org.matrix.msc2965.authentication': auth }), + } as AutoDiscoveryInfo); + +describe('masAccountUrl', () => { + it('prefers the account url, then the issuer', () => { + expect( + masAccountUrl(info({ account: 'https://a.example/acc', issuer: 'https://a.example/' })) + ).toBe('https://a.example/acc'); + expect(masAccountUrl(info({ issuer: 'https://a.example/' }))).toBe('https://a.example/'); + }); + it('is undefined when nothing is advertised', () => { + expect(masAccountUrl(info())).toBeUndefined(); + expect(masAccountUrl(info({}))).toBeUndefined(); + }); + it('accepts http only for localhost', () => { + expect(masAccountUrl(info({ account: 'http://a.example/acc' }))).toBeUndefined(); + expect(masAccountUrl(info({ account: 'http://localhost:8080/acc' }))).toBe( + 'http://localhost:8080/acc' + ); + }); + it('skips an unusable account url and falls to a usable issuer', () => { + expect(masAccountUrl(info({ account: 'not a url', issuer: 'https://a.example/' }))).toBe( + 'https://a.example/' + ); + }); + it('rejects non-strings and non-http schemes', () => { + expect(masAccountUrl(info({ account: 42, issuer: { x: 1 } }))).toBeUndefined(); + expect(masAccountUrl(info({ account: 'data:text/html,x' }))).toBeUndefined(); + expect(masAccountUrl(info({ account: 'ftp://a.example' }))).toBeUndefined(); + }); +}); + +const src = (p: string) => readFileSync(`${process.cwd()}/src/app/pages/auth/${p}`, 'utf8'); + +describe('MAS front guards', () => { + it('Login hides both register entries behind the MAS predicate', () => { + const s = src('login/Login.tsx'); + expect(s).toContain('useMasAccountUrl()'); + expect(s).toContain('{masUrl && }'); + expect(s).toContain('{!masUrl && !registrationEntry(config) && ('); + }); + it('PasswordLoginForm hides the forgot link behind the MAS predicate', () => { + const s = src('login/PasswordLoginForm.tsx'); + expect(s).toContain('useMasAccountUrl()'); + expect(s).toMatch(/\{!masUrl && \([\s\S]*getResetPasswordPath/); + }); + it('RegisterRoute and ResetPassword show the notice under MAS', () => { + ['register/RegisterRoute.tsx', 'reset-password/ResetPassword.tsx'].forEach((p) => { + const s = src(p); + expect(s).toContain('useMasAccountUrl()'); + expect(s).toContain(' { + expect(src('login/PasswordLoginForm.tsx').match(/withDevice\(\{/g)).toHaveLength(3); + expect(src('login/TokenLogin.tsx')).toContain('withSoftLogoutDevice('); + }); + it('the logout listener and login completion defer to the one wipe', () => { + const root = readFileSync(`${process.cwd()}/src/app/pages/client/ClientRoot.tsx`, 'utf8'); + expect(root).toContain('classifyLogout(err)'); + expect(root).toContain('wipeLocalData(mx)'); + expect(root).not.toContain('localStorage.clear'); + expect(src('login/loginUtil.ts')).toContain('wipeLocalData()'); + }); +}); diff --git a/src/app/pages/auth/masAccount.ts b/src/app/pages/auth/masAccount.ts new file mode 100644 index 00000000..b131a916 --- /dev/null +++ b/src/app/pages/auth/masAccount.ts @@ -0,0 +1,32 @@ +import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo'; +import type { AutoDiscoveryInfo } from '../../cs-api'; + +// Plain http is for a developer's own machine only. +const isLocalHost = (hostname: string): boolean => + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname === '127.0.0.1' || + hostname === '[::1]'; + +const usable = (value: unknown): string | undefined => { + if (typeof value !== 'string') return undefined; + try { + const { protocol, hostname } = new URL(value); + if (protocol === 'https:' || (protocol === 'http:' && isLocalHost(hostname))) return value; + } catch { + // not a url + } + return undefined; +}; + +/** + * The account page of the sign-in service fronting this homeserver (MAS), when discovery + * advertises one: the account url, else the issuer, the same preference the devices page uses. + * Undefined means the homeserver manages its own accounts. + */ +export const masAccountUrl = (info: AutoDiscoveryInfo): string | undefined => { + const auth = info['org.matrix.msc2965.authentication']; + return usable(auth?.account) ?? usable(auth?.issuer); +}; + +export const useMasAccountUrl = (): string | undefined => masAccountUrl(useAutoDiscoveryInfo()); diff --git a/src/app/pages/auth/register/RegisterRoute.test.tsx b/src/app/pages/auth/register/RegisterRoute.test.tsx index 043c1174..1fde64cd 100644 --- a/src/app/pages/auth/register/RegisterRoute.test.tsx +++ b/src/app/pages/auth/register/RegisterRoute.test.tsx @@ -3,16 +3,26 @@ import { describe, expect, it, vi } from 'vitest'; import { renderToString } from 'react-dom/server'; import { ClientConfigProvider } from '../../../hooks/useClientConfig'; import type { ClientConfig } from '../../../hooks/useClientConfig'; +import { AutoDiscoveryInfoProvider } from '../../../hooks/useAutoDiscoveryInfo'; +import type { AutoDiscoveryInfo } from '../../../cs-api'; import { RegisterRoute } from './RegisterRoute'; // The in-client form needs a homeserver; the route test only needs to know whether it was asked for. vi.mock('./Register', () => ({ Register: () =>

in-client register form
})); -const render = (config: ClientConfig) => +const plain: AutoDiscoveryInfo = { 'm.homeserver': { base_url: 'https://hs.example' } }; +const mas: AutoDiscoveryInfo = { + ...plain, + 'org.matrix.msc2965.authentication': { account: 'https://auth.example/account' }, +}; + +const render = (config: ClientConfig, info: AutoDiscoveryInfo = plain) => renderToString( - - - + + + + + ); describe('the /register route', () => { @@ -23,15 +33,26 @@ describe('the /register route', () => { }); it('with the switch off shows the in-client form', () => { - expect(render({ registration: { enabled: false, url: 'https://accounts.example/join' } })).toContain( - 'in-client register form' - ); + expect( + render({ registration: { enabled: false, url: 'https://accounts.example/join' } }) + ).toContain('in-client register form'); expect(render({})).toContain('in-client register form'); }); it('with the switch on but an unusable url falls back to the form', () => { - expect(render({ registration: { enabled: true, url: 'ftp://accounts.example/join' } })).toContain( - 'in-client register form' + expect( + render({ registration: { enabled: true, url: 'ftp://accounts.example/join' } }) + ).toContain('in-client register form'); + }); + + it('under MAS shows the account page notice, not the form, with the switch on or off', () => { + [{}, { registration: { enabled: true, url: 'https://accounts.example/join' } }].forEach( + (config) => { + const html = render(config, mas); + expect(html).toContain('href="https://auth.example/account"'); + expect(html).not.toContain('in-client register form'); + expect(html).not.toContain('Continue to sign up'); + } ); }); }); diff --git a/src/app/pages/auth/register/RegisterRoute.tsx b/src/app/pages/auth/register/RegisterRoute.tsx index 094bc8fd..fa56527a 100644 --- a/src/app/pages/auth/register/RegisterRoute.tsx +++ b/src/app/pages/auth/register/RegisterRoute.tsx @@ -2,6 +2,8 @@ import { Box, Text } from 'folds'; import React, { useEffect } from 'react'; import { useClientConfig } from '../../../hooks/useClientConfig'; import { registrationEntry } from '../registrationEntry'; +import { useMasAccountUrl } from '../masAccount'; +import { MasAccountNotice } from '../MasAccountNotice'; import { Register } from './Register'; // With the Register switch on, a stranger gets one way to register: the account service's own @@ -9,9 +11,21 @@ import { Register } from './Register'; // the switch off, the in-client form is the way. export function RegisterRoute() { const entry = registrationEntry(useClientConfig()); + const masUrl = useMasAccountUrl(); useEffect(() => { - if (entry) window.location.replace(entry.url); - }, [entry]); + if (entry && !masUrl) window.location.replace(entry.url); + }, [entry, masUrl]); + // Under MAS the in-client sign-up (UIA) cannot work; the account page is the only way. + if (masUrl) { + return ( + + + Register + + + + ); + } if (!entry) return ; return ( diff --git a/src/app/pages/auth/reset-password/ResetPassword.tsx b/src/app/pages/auth/reset-password/ResetPassword.tsx index c5e1d2ad..b9c2e9a6 100644 --- a/src/app/pages/auth/reset-password/ResetPassword.tsx +++ b/src/app/pages/auth/reset-password/ResetPassword.tsx @@ -4,6 +4,8 @@ import { Link, useSearchParams } from 'react-router-dom'; import { getLoginPath } from '../../pathUtils'; import { useAuthServer } from '../../../hooks/useAuthServer'; import { PasswordResetForm } from './PasswordResetForm'; +import { useMasAccountUrl } from '../masAccount'; +import { MasAccountNotice } from '../MasAccountNotice'; import { ResetPasswordPathSearchParams } from '../../paths'; const useResetPasswordSearchParams = ( @@ -18,6 +20,7 @@ const useResetPasswordSearchParams = ( export function ResetPassword() { const server = useAuthServer(); + const masUrl = useMasAccountUrl(); const [searchParams] = useSearchParams(); const resetPasswordSearchParams = useResetPasswordSearchParams(searchParams); @@ -26,7 +29,11 @@ export function ResetPassword() { Reset Password - + {masUrl ? ( + + ) : ( + + )} diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 565ff058..dd63226a 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -19,6 +19,7 @@ import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useState } import { clearCacheAndReload, clearLoginData, + wipeLocalData, initClient, logoutClient, startClient, @@ -35,6 +36,8 @@ import { stopPropagation } from '../../utils/keyboard'; import { SyncStatus } from './SyncStatus'; import { AuthMetadataProvider } from '../../hooks/useAuthMetadata'; import { getFallbackSession } from '../../state/sessions'; +import { beginSoftLogout, classifyLogout } from '../../state/softLogout'; +import { pushSessionToSW } from '../../../sw-session'; import { AutoDiscovery } from './AutoDiscovery'; import { PhoneHit } from '../../styles/PhoneHit.css'; @@ -127,10 +130,15 @@ function ClientRootOptions({ mx }: { mx?: MatrixClient }) { const useLogoutListener = (mx?: MatrixClient) => { useEffect(() => { - const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => { + const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async (err) => { mx?.stopClient(); - await mx?.clearStores(); - window.localStorage.clear(); + // Soft: the token is dead, the device is not. Keep the stores and ask for a sign-in. + if (classifyLogout(err) === 'soft' && beginSoftLogout()) { + pushSessionToSW(); + window.location.reload(); + return; + } + await wipeLocalData(mx); window.location.reload(); }; diff --git a/src/app/state/softLogout.test.ts b/src/app/state/softLogout.test.ts new file mode 100644 index 00000000..e529288d --- /dev/null +++ b/src/app/state/softLogout.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest'; +import { MatrixError } from 'matrix-js-sdk'; +import { setFallbackSession, getFallbackSession } from './sessions'; +import { + beginSoftLogout, + classifyLogout, + clearSoftLogout, + decideLoginOutcome, + getSoftLogout, + setSoftLogout, + withSoftLogoutDevice, +} from './softLogout'; + +const memoryStorage = (): Storage => { + const m = new Map(); + return { + get length() { + return m.size; + }, + clear: () => m.clear(), + getItem: (k) => m.get(k) ?? null, + key: (i) => [...m.keys()][i] ?? null, + removeItem: (k) => { + m.delete(k); + }, + setItem: (k, v) => { + m.set(k, String(v)); + }, + }; +}; + +const record = { userId: '@a:hs', deviceId: 'DEV', baseUrl: 'https://hs.example' }; + +describe('classifyLogout', () => { + it('is soft only for soft_logout === true', () => { + expect(classifyLogout(new MatrixError({ errcode: 'M_UNKNOWN_TOKEN', soft_logout: true }))).toBe( + 'soft' + ); + expect(classifyLogout({ data: { soft_logout: true } })).toBe('soft'); + }); + it('is hard for false, absent, string-ish, and non-MatrixError values', () => { + expect( + classifyLogout(new MatrixError({ errcode: 'M_UNKNOWN_TOKEN', soft_logout: false })) + ).toBe('hard'); + expect(classifyLogout(new MatrixError({ errcode: 'M_UNKNOWN_TOKEN' }))).toBe('hard'); + expect(classifyLogout({ data: { soft_logout: 'true' } })).toBe('hard'); + expect(classifyLogout(undefined)).toBe('hard'); + expect(classifyLogout(null)).toBe('hard'); + expect(classifyLogout('boom')).toBe('hard'); + expect(classifyLogout(new Error('x'))).toBe('hard'); + }); +}); + +describe('the soft logout record', () => { + it('round-trips and clears', () => { + const s = memoryStorage(); + expect(getSoftLogout(s)).toBeUndefined(); + setSoftLogout(record, s); + expect(getSoftLogout(s)).toEqual(record); + clearSoftLogout(s); + expect(getSoftLogout(s)).toBeUndefined(); + }); + it('treats garbage and partial records as absent', () => { + const s = memoryStorage(); + s.setItem('ratatoskr_soft_logout', 'not json'); + expect(getSoftLogout(s)).toBeUndefined(); + s.setItem('ratatoskr_soft_logout', JSON.stringify({ userId: '@a:hs', deviceId: '' })); + expect(getSoftLogout(s)).toBeUndefined(); + }); +}); + +describe('beginSoftLogout', () => { + it('keeps the device keys and the record, drops only the access token', () => { + const s = memoryStorage(); + const real = globalThis.localStorage; + Object.defineProperty(globalThis, 'localStorage', { value: s, configurable: true }); + try { + setFallbackSession('tok', 'DEV', '@a:hs', 'https://hs.example'); + s.setItem('other_key', 'kept'); + } finally { + Object.defineProperty(globalThis, 'localStorage', { value: real, configurable: true }); + } + expect(beginSoftLogout(s)).toBe(true); + expect(getFallbackSession(s)).toBeUndefined(); + expect(s.getItem('cinny_access_token')).toBeNull(); + expect(s.getItem('cinny_device_id')).toBe('DEV'); + expect(s.getItem('other_key')).toBe('kept'); + expect(getSoftLogout(s)).toEqual(record); + }); + it('does nothing without a session', () => { + const s = memoryStorage(); + expect(beginSoftLogout(s)).toBe(false); + expect(getSoftLogout(s)).toBeUndefined(); + }); +}); + +describe('withSoftLogoutDevice', () => { + const req = { type: 'm.login.password', password: 'p' } as const; + it('asks for the recorded device on password and token requests alike', () => { + expect(withSoftLogoutDevice(req, record)).toEqual({ ...req, device_id: 'DEV' }); + const tok = { type: 'm.login.token', token: 't' }; + expect(withSoftLogoutDevice(tok, record)).toEqual({ ...tok, device_id: 'DEV' }); + }); + it('leaves the request alone without a record', () => { + expect(withSoftLogoutDevice(req, undefined)).toBe(req); + }); +}); + +describe('decideLoginOutcome', () => { + it('is plain without a record', () => { + expect(decideLoginOutcome(undefined, { user_id: '@a:hs', device_id: 'DEV' })).toBe('plain'); + }); + it('keeps for the same user and device', () => { + expect(decideLoginOutcome(record, { user_id: '@a:hs', device_id: 'DEV' })).toBe('keep'); + }); + it('wipes for another user or another device', () => { + expect(decideLoginOutcome(record, { user_id: '@b:hs', device_id: 'DEV' })).toBe('wipe'); + expect(decideLoginOutcome(record, { user_id: '@a:hs', device_id: 'NEW' })).toBe('wipe'); + }); +}); diff --git a/src/app/state/softLogout.ts b/src/app/state/softLogout.ts new file mode 100644 index 00000000..d7ea1180 --- /dev/null +++ b/src/app/state/softLogout.ts @@ -0,0 +1,91 @@ +import { useState } from 'react'; +import type { LoginRequest } from 'matrix-js-sdk'; +import { getFallbackSession } from './sessions'; + +// A soft logout is the server saying "this token is dead, the device is not": the local stores +// stay, only the access token goes, and this record says whose device to sign back in as. +const SOFT_LOGOUT_KEY = 'ratatoskr_soft_logout'; +const ACCESS_TOKEN_KEY = 'cinny_access_token'; + +export type SoftLogoutRecord = { + userId: string; + deviceId: string; + baseUrl: string; +}; + +export const getSoftLogout = (storage: Storage = localStorage): SoftLogoutRecord | undefined => { + const raw = storage.getItem(SOFT_LOGOUT_KEY); + if (!raw) return undefined; + try { + const { userId, deviceId, baseUrl } = JSON.parse(raw) as Partial; + if ( + typeof userId === 'string' && + typeof deviceId === 'string' && + typeof baseUrl === 'string' && + userId && + deviceId && + baseUrl + ) { + return { userId, deviceId, baseUrl }; + } + } catch { + // an unreadable record is no record + } + return undefined; +}; + +export const setSoftLogout = (record: SoftLogoutRecord, storage: Storage = localStorage) => { + storage.setItem(SOFT_LOGOUT_KEY, JSON.stringify(record)); +}; + +export const clearSoftLogout = (storage: Storage = localStorage) => { + storage.removeItem(SOFT_LOGOUT_KEY); +}; + +export type LogoutKind = 'soft' | 'hard'; + +/** Soft only when the server said so: soft_logout === true. Anything else is hard. */ +export const classifyLogout = (err: unknown): LogoutKind => { + const data = (err as { data?: { soft_logout?: unknown } } | null | undefined)?.data; + return data?.soft_logout === true ? 'soft' : 'hard'; +}; + +/** + * Enter the soft-logout state: remember the device, drop only the access token. Returns false + * (and changes nothing) when there is no session to remember, which the caller treats as hard. + */ +export const beginSoftLogout = (storage: Storage = localStorage): boolean => { + const session = getFallbackSession(storage); + if (!session) return false; + setSoftLogout( + { userId: session.userId, deviceId: session.deviceId, baseUrl: session.baseUrl }, + storage + ); + storage.removeItem(ACCESS_TOKEN_KEY); + return true; +}; + +/** Every login request while a record stands asks for the same device back. */ +export const withSoftLogoutDevice = ( + request: LoginRequest, + record: SoftLogoutRecord | undefined +): LoginRequest => (record ? { ...request, device_id: record.deviceId } : request); + +export type LoginOutcome = 'plain' | 'keep' | 'wipe'; + +/** + * What a finished login does to local data. No record: a plain login, as ever. A record that + * names this very user and device: keep the stores. Any other answer: the stores belong to + * someone else, wipe them first. + */ +export const decideLoginOutcome = ( + record: SoftLogoutRecord | undefined, + response: { user_id: string; device_id: string } +): LoginOutcome => { + if (!record) return 'plain'; + return record.userId === response.user_id && record.deviceId === response.device_id + ? 'keep' + : 'wipe'; +}; + +export const useSoftLogout = (): SoftLogoutRecord | undefined => useState(() => getSoftLogout())[0]; diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 871c186a..b6d22597 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -111,6 +111,45 @@ export const clearCacheAndReload = async (mx: MatrixClient) => { window.location.reload(); }; +// The rust crypto databases the SDK names, for browsers without indexedDB.databases(). +const KNOWN_DATABASES = [ + 'web-sync-store', + 'crypto-store', + 'matrix-js-sdk::matrix-sdk-crypto', + 'matrix-js-sdk::matrix-sdk-crypto-meta', +]; + +const deleteDatabase = (name: string) => + new Promise((resolve) => { + const req = window.indexedDB.deleteDatabase(name); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); + req.onblocked = () => resolve(); + }); + +/** + * The one wipe of everything this browser holds for the account: client stores, every IndexedDB + * database, localStorage. Hard logout, a soft logout answered by another user or device, and + * "sign in as someone else" all end here. Callers reload afterwards. + */ +export const wipeLocalData = async (mx?: MatrixClient) => { + try { + await mx?.clearStores(); + } catch { + // the databases go below regardless + } + let names: string[]; + try { + names = (await window.indexedDB.databases()) + .map((db) => db.name) + .filter((name): name is string => !!name); + } catch { + names = KNOWN_DATABASES; + } + await Promise.all(names.map(deleteDatabase)); + window.localStorage.clear(); +}; + export const logoutClient = async (mx: MatrixClient) => { pushSessionToSW(); mx.stopClient(); @@ -119,21 +158,11 @@ export const logoutClient = async (mx: MatrixClient) => { } catch { // ignore if failed to logout } - await mx.clearStores(); - window.localStorage.clear(); + await wipeLocalData(mx); window.location.reload(); }; export const clearLoginData = async () => { - const dbs = await window.indexedDB.databases(); - - dbs.forEach((idbInfo) => { - const { name } = idbInfo; - if (name) { - window.indexedDB.deleteDatabase(name); - } - }); - - window.localStorage.clear(); + await wipeLocalData(); window.location.reload(); }; From 3a5258c3ef7344a9211f3ef9dddd5450d7e5cb09 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 18:40:59 +0000 Subject: [PATCH 37/74] Soul fixes: idempotent logout handler, keep register consent entry under MAS, settle stores on register First SessionLoggedOut decides; later events are ignored so a second soft event cannot fall into the wipe. Under MAS a configured registrationEntry (with its training notice) is kept; the MAS notice replaces only the in-client form and bare link. Registration goes through the same keep-or-wipe settle as login. Soft record drops its unused baseUrl. One shared https-or-localhost predicate. Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/login/Login.tsx | 7 +- src/app/pages/auth/login/loginUtil.ts | 6 +- src/app/pages/auth/masAccount.test.ts | 13 +-- src/app/pages/auth/masAccount.ts | 20 +--- .../auth/register/RegisterRoute.test.tsx | 21 ++-- src/app/pages/auth/register/RegisterRoute.tsx | 9 +- src/app/pages/auth/register/registerUtil.ts | 13 ++- src/app/pages/auth/registrationEntry.ts | 15 +-- src/app/pages/client/ClientRoot.tsx | 20 ++-- src/app/state/softLogout.test.ts | 96 ++++++++++++++++++- src/app/state/softLogout.ts | 63 +++++++++--- src/app/utils/secureUrl.ts | 16 ++++ 12 files changed, 214 insertions(+), 85 deletions(-) create mode 100644 src/app/utils/secureUrl.ts diff --git a/src/app/pages/auth/login/Login.tsx b/src/app/pages/auth/login/Login.tsx index 05204779..3606a9a4 100644 --- a/src/app/pages/auth/login/Login.tsx +++ b/src/app/pages/auth/login/Login.tsx @@ -62,6 +62,7 @@ export function Login() { const parsedFlows = useParsedLoginFlows(loginFlows.flows); const masUrl = useMasAccountUrl(); + const registerEntry = registrationEntry(config); const softLogout = useSoftLogout(); return ( @@ -100,9 +101,9 @@ export function Login() { )} - {masUrl && } - {!masUrl && registrationEntry(config) && } - {!masUrl && !registrationEntry(config) && ( + {registerEntry && } + {!registerEntry && masUrl && } + {!registerEntry && !masUrl && ( Do not have an account? Register diff --git a/src/app/pages/auth/login/loginUtil.ts b/src/app/pages/auth/login/loginUtil.ts index f4124db7..7a9cfb38 100644 --- a/src/app/pages/auth/login/loginUtil.ts +++ b/src/app/pages/auth/login/loginUtil.ts @@ -11,7 +11,7 @@ import { } from '../../afterLoginRedirectPath'; import { getHomePath } from '../../pathUtils'; import { setFallbackSession } from '../../../state/sessions'; -import { clearSoftLogout, decideLoginOutcome, getSoftLogout } from '../../../state/softLogout'; +import { settleLocalData } from '../../../state/softLogout'; import { wipeLocalData } from '../../../../client/initMatrix'; export enum GetBaseUrlError { @@ -119,9 +119,7 @@ export const useLoginComplete = (data?: CustomLoginResponse) => { const finish = async () => { const afterLoginRedirectUrl = getAfterLoginRedirectPath(); // A soft logout left stores behind; they stay only for the same user on the same device. - const outcome = decideLoginOutcome(getSoftLogout(), loginRes); - if (outcome === 'wipe') await wipeLocalData(); - clearSoftLogout(); + await settleLocalData(loginRes, () => wipeLocalData()); setFallbackSession(loginRes.access_token, loginRes.device_id, loginRes.user_id, loginBaseUrl); deleteAfterLoginRedirectPath(); navigate(afterLoginRedirectUrl ?? getHomePath(), { replace: true }); diff --git a/src/app/pages/auth/masAccount.test.ts b/src/app/pages/auth/masAccount.test.ts index c45497f3..f2331365 100644 --- a/src/app/pages/auth/masAccount.test.ts +++ b/src/app/pages/auth/masAccount.test.ts @@ -41,12 +41,12 @@ describe('masAccountUrl', () => { const src = (p: string) => readFileSync(`${process.cwd()}/src/app/pages/auth/${p}`, 'utf8'); describe('MAS front guards', () => { - it('Login hides both register entries behind the MAS predicate', () => { + it('Login keeps a configured register entry, else MAS notice, else the bare link', () => { const s = src('login/Login.tsx'); expect(s).toContain('useMasAccountUrl()'); - expect(s).toContain('{masUrl && }'); - expect(s).toContain('{!masUrl && !registrationEntry(config) && ('); + expect(s).toContain('{registerEntry && }'); + expect(s).toContain('{!registerEntry && masUrl && { const s = src('login/PasswordLoginForm.tsx'); @@ -66,9 +66,10 @@ describe('MAS front guards', () => { }); it('the logout listener and login completion defer to the one wipe', () => { const root = readFileSync(`${process.cwd()}/src/app/pages/client/ClientRoot.tsx`, 'utf8'); - expect(root).toContain('classifyLogout(err)'); + expect(root).toContain('createLogoutHandler('); expect(root).toContain('wipeLocalData(mx)'); expect(root).not.toContain('localStorage.clear'); - expect(src('login/loginUtil.ts')).toContain('wipeLocalData()'); + expect(src('login/loginUtil.ts')).toContain('settleLocalData(loginRes'); + expect(src('register/registerUtil.ts')).toContain('settleLocalData('); }); }); diff --git a/src/app/pages/auth/masAccount.ts b/src/app/pages/auth/masAccount.ts index b131a916..6e65b2a4 100644 --- a/src/app/pages/auth/masAccount.ts +++ b/src/app/pages/auth/masAccount.ts @@ -1,23 +1,9 @@ import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo'; +import { isSecureOrLocalUrl } from '../../utils/secureUrl'; import type { AutoDiscoveryInfo } from '../../cs-api'; -// Plain http is for a developer's own machine only. -const isLocalHost = (hostname: string): boolean => - hostname === 'localhost' || - hostname.endsWith('.localhost') || - hostname === '127.0.0.1' || - hostname === '[::1]'; - -const usable = (value: unknown): string | undefined => { - if (typeof value !== 'string') return undefined; - try { - const { protocol, hostname } = new URL(value); - if (protocol === 'https:' || (protocol === 'http:' && isLocalHost(hostname))) return value; - } catch { - // not a url - } - return undefined; -}; +const usable = (value: unknown): string | undefined => + typeof value === 'string' && isSecureOrLocalUrl(value) ? value : undefined; /** * The account page of the sign-in service fronting this homeserver (MAS), when discovery diff --git a/src/app/pages/auth/register/RegisterRoute.test.tsx b/src/app/pages/auth/register/RegisterRoute.test.tsx index 1fde64cd..47ea7179 100644 --- a/src/app/pages/auth/register/RegisterRoute.test.tsx +++ b/src/app/pages/auth/register/RegisterRoute.test.tsx @@ -45,14 +45,19 @@ describe('the /register route', () => { ).toContain('in-client register form'); }); - it('under MAS shows the account page notice, not the form, with the switch on or off', () => { - [{}, { registration: { enabled: true, url: 'https://accounts.example/join' } }].forEach( - (config) => { - const html = render(config, mas); - expect(html).toContain('href="https://auth.example/account"'); - expect(html).not.toContain('in-client register form'); - expect(html).not.toContain('Continue to sign up'); - } + it('under MAS with no configured entry shows the account page notice, not the form', () => { + const html = render({}, mas); + expect(html).toContain('href="https://auth.example/account"'); + expect(html).not.toContain('in-client register form'); + }); + + it('under MAS a configured entry keeps its own page, not the notice or the form', () => { + const html = render( + { registration: { enabled: true, url: 'https://accounts.example/join' } }, + mas ); + expect(html).toContain('href="https://accounts.example/join"'); + expect(html).not.toContain('auth.example/account'); + expect(html).not.toContain('in-client register form'); }); }); diff --git a/src/app/pages/auth/register/RegisterRoute.tsx b/src/app/pages/auth/register/RegisterRoute.tsx index fa56527a..ee68dd45 100644 --- a/src/app/pages/auth/register/RegisterRoute.tsx +++ b/src/app/pages/auth/register/RegisterRoute.tsx @@ -13,10 +13,11 @@ export function RegisterRoute() { const entry = registrationEntry(useClientConfig()); const masUrl = useMasAccountUrl(); useEffect(() => { - if (entry && !masUrl) window.location.replace(entry.url); - }, [entry, masUrl]); - // Under MAS the in-client sign-up (UIA) cannot work; the account page is the only way. - if (masUrl) { + if (entry) window.location.replace(entry.url); + }, [entry]); + // Under MAS the in-client sign-up (UIA) cannot work; with no configured entry the account page + // is the only way. A configured entry keeps its own page and its training notice. + if (!entry && masUrl) { return ( diff --git a/src/app/pages/auth/register/registerUtil.ts b/src/app/pages/auth/register/registerUtil.ts index 02f530fb..9d5afa87 100644 --- a/src/app/pages/auth/register/registerUtil.ts +++ b/src/app/pages/auth/register/registerUtil.ts @@ -17,6 +17,8 @@ import { import { getHomePath, getLoginPath, withSearchParam } from '../../pathUtils'; import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix'; import { setFallbackSession } from '../../../state/sessions'; +import { settleLocalData } from '../../../state/softLogout'; +import { wipeLocalData } from '../../../../client/initMatrix'; import type { RegisterTrouble } from '../authMessages'; export enum RegisterError { @@ -141,10 +143,15 @@ export const useRegisterComplete = (data?: CustomRegisterResponse) => { const deviceId = response.device_id; if (accessToken && deviceId) { - setFallbackSession(accessToken, deviceId, userId, baseUrl); const afterLoginRedirectPath = getAfterLoginRedirectPath(); - deleteAfterLoginRedirectPath(); - navigate(afterLoginRedirectPath ?? getHomePath(), { replace: true }); + // A new account never boots on a soft-logged-out user's stores. + settleLocalData({ user_id: userId, device_id: deviceId }, () => wipeLocalData()).then( + () => { + setFallbackSession(accessToken, deviceId, userId, baseUrl); + deleteAfterLoginRedirectPath(); + navigate(afterLoginRedirectPath ?? getHomePath(), { replace: true }); + } + ); } else { const username = getMxIdLocalPart(userId); const userServer = getMxIdServer(userId); diff --git a/src/app/pages/auth/registrationEntry.ts b/src/app/pages/auth/registrationEntry.ts index 6c65732e..2aa0e34d 100644 --- a/src/app/pages/auth/registrationEntry.ts +++ b/src/app/pages/auth/registrationEntry.ts @@ -1,15 +1,9 @@ import type { ClientConfig } from '../../hooks/useClientConfig'; +import { isSecureOrLocalUrl } from '../../utils/secureUrl'; import { TRAINING_NOTICE_VERSION, trainingNoticeText } from '../../utils/trainingNotice'; export type RegistrationEntry = { url: string; notice: string; noticeVersion: number }; -// Plain http is for a developer's own machine only; a stranger's sign-up page is https. -const isLocalHost = (hostname: string): boolean => - hostname === 'localhost' || - hostname.endsWith('.localhost') || - hostname === '127.0.0.1' || - hostname === '[::1]'; - /** * The sign-up entry the sign-in page may show: the account service's registration page, when the * switch is on and the configured url is an https address (http only for localhost). Anything else is no entry. The @@ -18,12 +12,7 @@ const isLocalHost = (hostname: string): boolean => export const registrationEntry = (config: ClientConfig): RegistrationEntry | undefined => { const { enabled, url } = config.registration ?? {}; if (enabled !== true || typeof url !== 'string') return undefined; - try { - const { protocol, hostname } = new URL(url); - if (protocol !== 'https:' && !(protocol === 'http:' && isLocalHost(hostname))) return undefined; - } catch { - return undefined; - } + if (!isSecureOrLocalUrl(url)) return undefined; return { url, notice: trainingNoticeText(config.previewDemo), diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index dd63226a..2cb69ed5 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -36,7 +36,7 @@ import { stopPropagation } from '../../utils/keyboard'; import { SyncStatus } from './SyncStatus'; import { AuthMetadataProvider } from '../../hooks/useAuthMetadata'; import { getFallbackSession } from '../../state/sessions'; -import { beginSoftLogout, classifyLogout } from '../../state/softLogout'; +import { createLogoutHandler } from '../../state/softLogout'; import { pushSessionToSW } from '../../../sw-session'; import { AutoDiscovery } from './AutoDiscovery'; import { PhoneHit } from '../../styles/PhoneHit.css'; @@ -130,17 +130,15 @@ function ClientRootOptions({ mx }: { mx?: MatrixClient }) { const useLogoutListener = (mx?: MatrixClient) => { useEffect(() => { - const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async (err) => { - mx?.stopClient(); - // Soft: the token is dead, the device is not. Keep the stores and ask for a sign-in. - if (classifyLogout(err) === 'soft' && beginSoftLogout()) { - pushSessionToSW(); - window.location.reload(); - return; + const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = createLogoutHandler( + { + storage: window.localStorage, + stop: () => mx?.stopClient(), + wipe: () => wipeLocalData(mx), + reload: () => window.location.reload(), + forgetSessionInWorker: () => pushSessionToSW(), } - await wipeLocalData(mx); - window.location.reload(); - }; + ); mx?.on(HttpApiEvent.SessionLoggedOut, handleLogout); return () => { diff --git a/src/app/state/softLogout.test.ts b/src/app/state/softLogout.test.ts index e529288d..8df1a8f8 100644 --- a/src/app/state/softLogout.test.ts +++ b/src/app/state/softLogout.test.ts @@ -5,6 +5,8 @@ import { beginSoftLogout, classifyLogout, clearSoftLogout, + createLogoutHandler, + settleLocalData, decideLoginOutcome, getSoftLogout, setSoftLogout, @@ -29,7 +31,7 @@ const memoryStorage = (): Storage => { }; }; -const record = { userId: '@a:hs', deviceId: 'DEV', baseUrl: 'https://hs.example' }; +const record = { userId: '@a:hs', deviceId: 'DEV' }; describe('classifyLogout', () => { it('is soft only for soft_logout === true', () => { @@ -118,3 +120,95 @@ describe('decideLoginOutcome', () => { expect(decideLoginOutcome(record, { user_id: '@a:hs', device_id: 'NEW' })).toBe('wipe'); }); }); + +describe('the logout handler', () => { + const rig = (soft: boolean) => { + const storage = memoryStorage(); + storage.setItem('cinny_access_token', 'tok'); + storage.setItem('cinny_device_id', 'DEV'); + storage.setItem('cinny_user_id', '@a:hs'); + storage.setItem('cinny_hs_base_url', 'https://hs.example'); + const calls = { stop: 0, wipe: 0, reload: 0, worker: 0 }; + const handler = createLogoutHandler({ + storage, + stop: () => { + calls.stop += 1; + }, + wipe: async () => { + calls.wipe += 1; + }, + reload: () => { + calls.reload += 1; + }, + forgetSessionInWorker: () => { + calls.worker += 1; + }, + }); + const err = { data: { soft_logout: soft } }; + return { storage, calls, handler, err }; + }; + + it('soft twice: one begin, no wipe, record kept', async () => { + const { storage, calls, handler, err } = rig(true); + await handler(err); + await handler(err); + expect(calls).toEqual({ stop: 1, wipe: 0, reload: 1, worker: 1 }); + expect(getSoftLogout(storage)).toEqual(record); + expect(storage.getItem('cinny_device_id')).toBe('DEV'); + }); + it('soft then hard: the later event does not wipe', async () => { + const { calls, handler } = rig(true); + await handler({ data: { soft_logout: true } }); + await handler({ data: {} }); + expect(calls.wipe).toBe(0); + }); + it('a record already standing swallows a fresh handler too', async () => { + const { storage, calls, handler } = rig(false); + setSoftLogout(record, storage); + await handler({ data: {} }); + expect(calls).toEqual({ stop: 0, wipe: 0, reload: 0, worker: 0 }); + }); + it('hard twice: one wipe', async () => { + const { calls, handler, err } = rig(false); + await handler(err); + await handler(err); + expect(calls).toEqual({ stop: 1, wipe: 1, reload: 1, worker: 0 }); + }); +}); + +describe('settleLocalData', () => { + const run = async ( + rec: typeof record | undefined, + res: { user_id: string; device_id: string } + ) => { + const s = memoryStorage(); + if (rec) setSoftLogout(rec, s); + let wipes = 0; + const outcome = await settleLocalData( + res, + async () => { + wipes += 1; + }, + s + ); + return { outcome, wipes, left: getSoftLogout(s) }; + }; + it('same user and device: keep stores, retire the record', async () => { + expect(await run(record, { user_id: '@a:hs', device_id: 'DEV' })).toEqual({ + outcome: 'keep', + wipes: 0, + left: undefined, + }); + }); + it('another user or device (a new account too): wipe once, retire the record', async () => { + expect(await run(record, { user_id: '@new:hs', device_id: 'X' })).toEqual({ + outcome: 'wipe', + wipes: 1, + left: undefined, + }); + expect((await run(record, { user_id: '@a:hs', device_id: 'OTHER' })).wipes).toBe(1); + }); + it('no record: plain, no wipe', async () => { + expect((await run(undefined, { user_id: '@a:hs', device_id: 'D' })).wipes).toBe(0); + }); +}); diff --git a/src/app/state/softLogout.ts b/src/app/state/softLogout.ts index d7ea1180..645d0cc3 100644 --- a/src/app/state/softLogout.ts +++ b/src/app/state/softLogout.ts @@ -10,23 +10,15 @@ const ACCESS_TOKEN_KEY = 'cinny_access_token'; export type SoftLogoutRecord = { userId: string; deviceId: string; - baseUrl: string; }; export const getSoftLogout = (storage: Storage = localStorage): SoftLogoutRecord | undefined => { const raw = storage.getItem(SOFT_LOGOUT_KEY); if (!raw) return undefined; try { - const { userId, deviceId, baseUrl } = JSON.parse(raw) as Partial; - if ( - typeof userId === 'string' && - typeof deviceId === 'string' && - typeof baseUrl === 'string' && - userId && - deviceId && - baseUrl - ) { - return { userId, deviceId, baseUrl }; + const { userId, deviceId } = JSON.parse(raw) as Partial; + if (typeof userId === 'string' && typeof deviceId === 'string' && userId && deviceId) { + return { userId, deviceId }; } } catch { // an unreadable record is no record @@ -57,10 +49,7 @@ export const classifyLogout = (err: unknown): LogoutKind => { export const beginSoftLogout = (storage: Storage = localStorage): boolean => { const session = getFallbackSession(storage); if (!session) return false; - setSoftLogout( - { userId: session.userId, deviceId: session.deviceId, baseUrl: session.baseUrl }, - storage - ); + setSoftLogout({ userId: session.userId, deviceId: session.deviceId }, storage); storage.removeItem(ACCESS_TOKEN_KEY); return true; }; @@ -89,3 +78,47 @@ export const decideLoginOutcome = ( }; export const useSoftLogout = (): SoftLogoutRecord | undefined => useState(() => getSoftLogout())[0]; + +export type LogoutHandlerPorts = { + storage: Storage; + stop: () => void; + wipe: () => Promise; + reload: () => void; + forgetSessionInWorker: () => void; +}; + +/** + * The SessionLoggedOut handler. The SDK emits once per failing request and in-flight requests + * outlive stopClient, so the first event decides and every later one is ignored: a second soft + * event must never find the token already gone and fall into the wipe. + */ +export const createLogoutHandler = (ports: LogoutHandlerPorts) => { + let fired = false; + return async (err: unknown): Promise => { + if (fired || getSoftLogout(ports.storage)) return; + fired = true; + ports.stop(); + if (classifyLogout(err) === 'soft') { + if (beginSoftLogout(ports.storage)) ports.forgetSessionInWorker(); + ports.reload(); + return; + } + await ports.wipe(); + ports.reload(); + }; +}; + +/** + * Settle local data when a login or registration hands back a session: wipe first when a soft + * logout left stores that belong to someone else, then retire the record. + */ +export const settleLocalData = async ( + response: { user_id: string; device_id: string }, + wipe: () => Promise, + storage: Storage = localStorage +): Promise => { + const outcome = decideLoginOutcome(getSoftLogout(storage), response); + if (outcome === 'wipe') await wipe(); + clearSoftLogout(storage); + return outcome; +}; diff --git a/src/app/utils/secureUrl.ts b/src/app/utils/secureUrl.ts new file mode 100644 index 00000000..f1dacd9c --- /dev/null +++ b/src/app/utils/secureUrl.ts @@ -0,0 +1,16 @@ +// Plain http is for a developer's own machine only; a stranger's page is https. +const isLocalHost = (hostname: string): boolean => + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname === '127.0.0.1' || + hostname === '[::1]'; + +/** True for an https url, or an http url on localhost. Anything else, including non-urls, is false. */ +export const isSecureOrLocalUrl = (value: string): boolean => { + try { + const { protocol, hostname } = new URL(value); + return protocol === 'https:' || (protocol === 'http:' && isLocalHost(hostname)); + } catch { + return false; + } +}; From 31d58075db819a1b3c562829a464ac4d33cc2b9a Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 18:56:08 +0000 Subject: [PATCH 38/74] Second Soul pass: soft event without a session wipes, wipe never rejects, rendered MAS tests wipeLocalData moves to its own module with injectable ports: per-database failures are contained and localStorage clears in a finally. A soft logout with no session to remember is treated as hard. Login/PasswordLoginForm/ResetPassword MAS behavior is now tested by rendering. Login and register completion settle once per response. Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/authFront.test.tsx | 113 ++++++++++++++++++++ src/app/pages/auth/login/loginUtil.ts | 8 +- src/app/pages/auth/masAccount.test.ts | 37 ------- src/app/pages/auth/register/registerUtil.ts | 8 +- src/app/pages/client/ClientRoot.tsx | 2 +- src/app/state/softLogout.test.ts | 7 ++ src/app/state/softLogout.ts | 5 +- src/client/initMatrix.ts | 40 +------ src/client/wipeLocalData.test.ts | 78 ++++++++++++++ src/client/wipeLocalData.ts | 58 ++++++++++ 10 files changed, 271 insertions(+), 85 deletions(-) create mode 100644 src/app/pages/auth/authFront.test.tsx create mode 100644 src/client/wipeLocalData.test.ts create mode 100644 src/client/wipeLocalData.ts diff --git a/src/app/pages/auth/authFront.test.tsx b/src/app/pages/auth/authFront.test.tsx new file mode 100644 index 00000000..3e6abfd7 --- /dev/null +++ b/src/app/pages/auth/authFront.test.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderToString } from 'react-dom/server'; +import { MemoryRouter } from 'react-router-dom'; +import { AutoDiscoveryInfoProvider } from '../../hooks/useAutoDiscoveryInfo'; +import { AuthServerProvider } from '../../hooks/useAuthServer'; +import { AuthFlowsProvider } from '../../hooks/useAuthFlows'; +import { ClientConfigProvider } from '../../hooks/useClientConfig'; +import type { ClientConfig } from '../../hooks/useClientConfig'; +import type { AutoDiscoveryInfo } from '../../cs-api'; +import { setSoftLogout } from '../../state/softLogout'; +import { Login } from './login/Login'; +import { ResetPassword } from './reset-password/ResetPassword'; + +vi.mock('../../../client/initMatrix', () => ({ clearLoginData: vi.fn() })); +// folds' Overlay portals into document, which the server renderer has none of. +vi.mock('folds', async (importOriginal) => ({ + ...(await importOriginal()), + Overlay: () => null, +})); +vi.mock('./reset-password/PasswordResetForm', () => ({ + PasswordResetForm: () =>
in-client reset form
, +})); + +const plain: AutoDiscoveryInfo = { 'm.homeserver': { base_url: 'https://hs.example' } }; +const mas: AutoDiscoveryInfo = { + ...plain, + 'org.matrix.msc2965.authentication': { account: 'https://auth.example/account' }, +}; +const entryConfig: ClientConfig = { + registration: { enabled: true, url: 'https://accounts.example/join' }, +}; +const flows = { + loginFlows: { flows: [{ type: 'm.login.password' }] }, + registerFlows: { status: 400 }, +} as never; + +const render = (ui: React.ReactElement, info: AutoDiscoveryInfo, config: ClientConfig = {}) => + renderToString( + + + + + {ui} + + + + + ); + +const store = new Map(); +beforeEach(() => { + store.clear(); + vi.stubGlobal('window', { location: { origin: 'https://app.example', search: '' } }); + vi.stubGlobal('localStorage', { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => store.set(k, v), + removeItem: (k: string) => store.delete(k), + }); +}); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('Login register entries', () => { + it('non-MAS without an entry: the bare Register link, no notice', () => { + const html = render(, plain); + expect(html).toContain('Register'); + expect(html).not.toContain('managed by your sign-in service'); + }); + it('non-MAS with an entry: the entry and its notice', () => { + const html = render(, plain, entryConfig); + expect(html).toContain('href="https://accounts.example/join"'); + }); + it('MAS without an entry: the account page notice, no bare Register link', () => { + const html = render(, mas); + expect(html).toContain('managed by your sign-in service'); + expect(html).toContain('href="https://auth.example/account"'); + expect(html).not.toContain('Register'); + }); + it('MAS with an entry: the entry stays, no MAS notice', () => { + const html = render(, mas, entryConfig); + expect(html).toContain('href="https://accounts.example/join"'); + expect(html).not.toContain('managed by your sign-in service'); + }); +}); + +describe('PasswordLoginForm through Login', () => { + it('shows Forgot Password only without MAS', () => { + expect(render(, plain)).toContain('Forget Password?'); + expect(render(, mas)).not.toContain('Forget Password?'); + }); + it('fixes the username and offers someone else only with a soft record', () => { + expect(render(, plain)).not.toContain('readOnly'); + expect(render(, plain)).not.toContain('Sign in as someone else'); + setSoftLogout({ userId: '@a:hs.example', deviceId: 'DEV' }); + const html = render(, plain); + expect(html).toContain('value="@a:hs.example"'); + expect(html).toMatch(/readonly/i); + expect(html).toContain('Sign in as someone else'); + }); +}); + +describe('ResetPassword', () => { + it('shows the notice under MAS and the form otherwise', () => { + const under = render(, mas); + expect(under).toContain('href="https://auth.example/account"'); + expect(under).not.toContain('in-client reset form'); + const plainHtml = render(, plain); + expect(plainHtml).toContain('in-client reset form'); + expect(plainHtml).not.toContain('managed by your sign-in service'); + }); +}); diff --git a/src/app/pages/auth/login/loginUtil.ts b/src/app/pages/auth/login/loginUtil.ts index 7a9cfb38..d1cfea82 100644 --- a/src/app/pages/auth/login/loginUtil.ts +++ b/src/app/pages/auth/login/loginUtil.ts @@ -1,6 +1,6 @@ import to from 'await-to-js'; import { LoginRequest, LoginResponse, MatrixError, createClient } from 'matrix-js-sdk'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { ClientConfig, clientAllowedServer } from '../../../hooks/useClientConfig'; import { autoDiscovery, specVersions } from '../../../cs-api'; @@ -12,7 +12,7 @@ import { import { getHomePath } from '../../pathUtils'; import { setFallbackSession } from '../../../state/sessions'; import { settleLocalData } from '../../../state/softLogout'; -import { wipeLocalData } from '../../../../client/initMatrix'; +import { wipeLocalData } from '../../../../client/wipeLocalData'; export enum GetBaseUrlError { NotAllow = 'NotAllow', @@ -112,9 +112,11 @@ export const login = async ( export const useLoginComplete = (data?: CustomLoginResponse) => { const navigate = useNavigate(); + const settled = useRef(); useEffect(() => { - if (!data) return; + if (!data || settled.current === data) return; + settled.current = data; const { response: loginRes, baseUrl: loginBaseUrl } = data; const finish = async () => { const afterLoginRedirectUrl = getAfterLoginRedirectPath(); diff --git a/src/app/pages/auth/masAccount.test.ts b/src/app/pages/auth/masAccount.test.ts index f2331365..04bd6340 100644 --- a/src/app/pages/auth/masAccount.test.ts +++ b/src/app/pages/auth/masAccount.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from 'vitest'; -import { readFileSync } from 'fs'; import { masAccountUrl } from './masAccount'; import type { AutoDiscoveryInfo } from '../../cs-api'; @@ -37,39 +36,3 @@ describe('masAccountUrl', () => { expect(masAccountUrl(info({ account: 'ftp://a.example' }))).toBeUndefined(); }); }); - -const src = (p: string) => readFileSync(`${process.cwd()}/src/app/pages/auth/${p}`, 'utf8'); - -describe('MAS front guards', () => { - it('Login keeps a configured register entry, else MAS notice, else the bare link', () => { - const s = src('login/Login.tsx'); - expect(s).toContain('useMasAccountUrl()'); - expect(s).toContain('{registerEntry && }'); - expect(s).toContain('{!registerEntry && masUrl && { - const s = src('login/PasswordLoginForm.tsx'); - expect(s).toContain('useMasAccountUrl()'); - expect(s).toMatch(/\{!masUrl && \([\s\S]*getResetPasswordPath/); - }); - it('RegisterRoute and ResetPassword show the notice under MAS', () => { - ['register/RegisterRoute.tsx', 'reset-password/ResetPassword.tsx'].forEach((p) => { - const s = src(p); - expect(s).toContain('useMasAccountUrl()'); - expect(s).toContain(' { - expect(src('login/PasswordLoginForm.tsx').match(/withDevice\(\{/g)).toHaveLength(3); - expect(src('login/TokenLogin.tsx')).toContain('withSoftLogoutDevice('); - }); - it('the logout listener and login completion defer to the one wipe', () => { - const root = readFileSync(`${process.cwd()}/src/app/pages/client/ClientRoot.tsx`, 'utf8'); - expect(root).toContain('createLogoutHandler('); - expect(root).toContain('wipeLocalData(mx)'); - expect(root).not.toContain('localStorage.clear'); - expect(src('login/loginUtil.ts')).toContain('settleLocalData(loginRes'); - expect(src('register/registerUtil.ts')).toContain('settleLocalData('); - }); -}); diff --git a/src/app/pages/auth/register/registerUtil.ts b/src/app/pages/auth/register/registerUtil.ts index 9d5afa87..dca4dd17 100644 --- a/src/app/pages/auth/register/registerUtil.ts +++ b/src/app/pages/auth/register/registerUtil.ts @@ -6,7 +6,7 @@ import { RegisterRequest, RegisterResponse, } from 'matrix-js-sdk'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { LoginPathSearchParams } from '../../paths'; import { ErrorCode } from '../../../cs-errorcode'; @@ -18,7 +18,7 @@ import { getHomePath, getLoginPath, withSearchParam } from '../../pathUtils'; import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix'; import { setFallbackSession } from '../../../state/sessions'; import { settleLocalData } from '../../../state/softLogout'; -import { wipeLocalData } from '../../../../client/initMatrix'; +import { wipeLocalData } from '../../../../client/wipeLocalData'; import type { RegisterTrouble } from '../authMessages'; export enum RegisterError { @@ -133,9 +133,11 @@ export const register = async ( export const useRegisterComplete = (data?: CustomRegisterResponse) => { const navigate = useNavigate(); + const settled = useRef(); useEffect(() => { - if (data) { + if (data && settled.current !== data) { + settled.current = data; const { response, baseUrl } = data; const userId = response.user_id; diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 2cb69ed5..ba1876ce 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -19,11 +19,11 @@ import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useState } import { clearCacheAndReload, clearLoginData, - wipeLocalData, initClient, logoutClient, startClient, } from '../../../client/initMatrix'; +import { wipeLocalData } from '../../../client/wipeLocalData'; import { SplashScreen } from '../../components/splash-screen'; import { ServerConfigsLoader } from '../../components/ServerConfigsLoader'; import { CapabilitiesProvider } from '../../hooks/useCapabilities'; diff --git a/src/app/state/softLogout.test.ts b/src/app/state/softLogout.test.ts index 8df1a8f8..2f113dd4 100644 --- a/src/app/state/softLogout.test.ts +++ b/src/app/state/softLogout.test.ts @@ -168,6 +168,13 @@ describe('the logout handler', () => { await handler({ data: {} }); expect(calls).toEqual({ stop: 0, wipe: 0, reload: 0, worker: 0 }); }); + it('soft with no session in storage: one wipe, no record', async () => { + const { storage, calls, handler } = rig(true); + storage.removeItem('cinny_access_token'); + await handler({ data: { soft_logout: true } }); + expect(calls).toEqual({ stop: 1, wipe: 1, reload: 1, worker: 0 }); + expect(getSoftLogout(storage)).toBeUndefined(); + }); it('hard twice: one wipe', async () => { const { calls, handler, err } = rig(false); await handler(err); diff --git a/src/app/state/softLogout.ts b/src/app/state/softLogout.ts index 645d0cc3..bf0c3fd1 100644 --- a/src/app/state/softLogout.ts +++ b/src/app/state/softLogout.ts @@ -98,8 +98,9 @@ export const createLogoutHandler = (ports: LogoutHandlerPorts) => { if (fired || getSoftLogout(ports.storage)) return; fired = true; ports.stop(); - if (classifyLogout(err) === 'soft') { - if (beginSoftLogout(ports.storage)) ports.forgetSessionInWorker(); + // Soft needs a session to remember; without one nothing can vouch for the stores, so wipe. + if (classifyLogout(err) === 'soft' && beginSoftLogout(ports.storage)) { + ports.forgetSessionInWorker(); ports.reload(); return; } diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index b6d22597..a5cc2b35 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -7,6 +7,7 @@ import { SyncState, } from 'matrix-js-sdk'; +import { wipeLocalData } from './wipeLocalData'; import { cryptoCallbacks } from './secretStorageKeys'; import { clearNavToActivePathStore } from '../app/state/navToActivePath'; import { pushSessionToSW } from '../sw-session'; @@ -111,45 +112,6 @@ export const clearCacheAndReload = async (mx: MatrixClient) => { window.location.reload(); }; -// The rust crypto databases the SDK names, for browsers without indexedDB.databases(). -const KNOWN_DATABASES = [ - 'web-sync-store', - 'crypto-store', - 'matrix-js-sdk::matrix-sdk-crypto', - 'matrix-js-sdk::matrix-sdk-crypto-meta', -]; - -const deleteDatabase = (name: string) => - new Promise((resolve) => { - const req = window.indexedDB.deleteDatabase(name); - req.onsuccess = () => resolve(); - req.onerror = () => resolve(); - req.onblocked = () => resolve(); - }); - -/** - * The one wipe of everything this browser holds for the account: client stores, every IndexedDB - * database, localStorage. Hard logout, a soft logout answered by another user or device, and - * "sign in as someone else" all end here. Callers reload afterwards. - */ -export const wipeLocalData = async (mx?: MatrixClient) => { - try { - await mx?.clearStores(); - } catch { - // the databases go below regardless - } - let names: string[]; - try { - names = (await window.indexedDB.databases()) - .map((db) => db.name) - .filter((name): name is string => !!name); - } catch { - names = KNOWN_DATABASES; - } - await Promise.all(names.map(deleteDatabase)); - window.localStorage.clear(); -}; - export const logoutClient = async (mx: MatrixClient) => { pushSessionToSW(); mx.stopClient(); diff --git a/src/client/wipeLocalData.test.ts b/src/client/wipeLocalData.test.ts new file mode 100644 index 00000000..2d3faa0a --- /dev/null +++ b/src/client/wipeLocalData.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { KNOWN_DATABASES, wipeLocalData } from './wipeLocalData'; + +type Mode = 'success' | 'blocked' | 'error' | 'throws'; + +const rig = (opts: { databases?: () => Promise<{ name?: string }[]>; mode?: Mode }) => { + const deleted: string[] = []; + let cleared = 0; + const indexedDB = { + databases: opts.databases, + deleteDatabase: (name: string) => { + deleted.push(name); + if (opts.mode === 'throws') throw new Error('sync boom'); + const req: Record void) | undefined> = {}; + setTimeout(() => { + if (opts.mode === 'blocked') req.onblocked?.(); + else if (opts.mode === 'error') req.onerror?.(); + else req.onsuccess?.(); + }, 0); + return req as unknown as IDBOpenDBRequest; + }, + }; + const storage = { + clear: () => { + cleared += 1; + }, + }; + return { deleted, cleared: () => cleared, ports: { indexedDB, storage } }; +}; + +describe('wipeLocalData', () => { + it('deletes every listed database and clears storage', async () => { + const r = rig({ databases: async () => [{ name: 'a' }, { name: 'b' }, {}] }); + await wipeLocalData(undefined, r.ports); + expect(r.deleted).toEqual(['a', 'b']); + expect(r.cleared()).toBe(1); + }); + it('falls back to the known names when databases() is absent or throws', async () => { + const absent = rig({}); + await wipeLocalData(undefined, absent.ports); + expect(absent.deleted).toEqual(KNOWN_DATABASES); + expect(absent.cleared()).toBe(1); + const throws = rig({ + databases: async () => { + throw new Error('nope'); + }, + }); + await wipeLocalData(undefined, throws.ports); + expect(throws.deleted).toEqual(KNOWN_DATABASES); + expect(throws.cleared()).toBe(1); + }); + it('a synchronous deleteDatabase throw does not abort the rest or the storage clear', async () => { + const r = rig({ databases: async () => [{ name: 'a' }, { name: 'b' }], mode: 'throws' }); + await expect(wipeLocalData(undefined, r.ports)).resolves.toBeUndefined(); + expect(r.deleted).toEqual(['a', 'b']); + expect(r.cleared()).toBe(1); + }); + it('blocked and errored deletions settle and still clear storage', async () => { + await Promise.all( + (['blocked', 'error'] as const).map(async (mode) => { + const r = rig({ databases: async () => [{ name: 'a' }], mode }); + await wipeLocalData(undefined, r.ports); + expect(r.cleared()).toBe(1); + }) + ); + }); + it('a throwing clearStores does not stop the wipe', async () => { + const r = rig({ databases: async () => [{ name: 'a' }] }); + const mx = { + clearStores: async () => { + throw new Error('x'); + }, + }; + await wipeLocalData(mx as never, r.ports); + expect(r.deleted).toEqual(['a']); + expect(r.cleared()).toBe(1); + }); +}); diff --git a/src/client/wipeLocalData.ts b/src/client/wipeLocalData.ts new file mode 100644 index 00000000..dc24951c --- /dev/null +++ b/src/client/wipeLocalData.ts @@ -0,0 +1,58 @@ +import type { MatrixClient } from 'matrix-js-sdk'; + +// The rust crypto databases the SDK names, for browsers without indexedDB.databases(). +export const KNOWN_DATABASES = [ + 'web-sync-store', + 'crypto-store', + 'matrix-js-sdk::matrix-sdk-crypto', + 'matrix-js-sdk::matrix-sdk-crypto-meta', +]; + +export type WipePorts = { + indexedDB: Pick & { + databases?: () => Promise<{ name?: string }[]>; + }; + storage: Pick; +}; + +const deleteDatabase = (idb: WipePorts['indexedDB'], name: string) => + new Promise((resolve) => { + try { + const req = idb.deleteDatabase(name); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); + req.onblocked = () => resolve(); + } catch { + // one database failing must not stop the rest + resolve(); + } + }); + +/** + * The one wipe of everything this browser holds for the account: client stores, every IndexedDB + * database, localStorage. Hard logout, a soft logout answered by another user or device, and + * "sign in as someone else" all end here. It never rejects, and localStorage (the access token + * with it) is cleared whatever the databases do. Callers reload afterwards. + */ +export const wipeLocalData = async ( + mx?: MatrixClient, + ports: WipePorts = { indexedDB: window.indexedDB, storage: window.localStorage } +): Promise => { + try { + try { + await mx?.clearStores(); + } catch { + // the databases go below regardless + } + let names = KNOWN_DATABASES; + try { + const listed = await ports.indexedDB.databases?.(); + if (listed) names = listed.map((db) => db.name).filter((n): n is string => !!n); + } catch { + // keep the known names + } + await Promise.all(names.map((name) => deleteDatabase(ports.indexedDB, name))); + } finally { + ports.storage.clear(); + } +}; From 8fb95402f59a87b60176e66e395e279b4b1a2994 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 19:13:04 +0000 Subject: [PATCH 39/74] wipeLocalData: bounded clearStores, storage clear that cannot reject; tests that bite Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/auth/authFront.test.tsx | 2 +- src/client/wipeLocalData.test.ts | 25 +++++++++++++++++++++++++ src/client/wipeLocalData.ts | 20 +++++++++++++++++--- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/app/pages/auth/authFront.test.tsx b/src/app/pages/auth/authFront.test.tsx index 3e6abfd7..df15bbd5 100644 --- a/src/app/pages/auth/authFront.test.tsx +++ b/src/app/pages/auth/authFront.test.tsx @@ -91,7 +91,7 @@ describe('PasswordLoginForm through Login', () => { expect(render(, mas)).not.toContain('Forget Password?'); }); it('fixes the username and offers someone else only with a soft record', () => { - expect(render(, plain)).not.toContain('readOnly'); + expect(render(, plain)).not.toMatch(/readonly/i); expect(render(, plain)).not.toContain('Sign in as someone else'); setSoftLogout({ userId: '@a:hs.example', deviceId: 'DEV' }); const html = render(, plain); diff --git a/src/client/wipeLocalData.test.ts b/src/client/wipeLocalData.test.ts index 2d3faa0a..1cff5f16 100644 --- a/src/client/wipeLocalData.test.ts +++ b/src/client/wipeLocalData.test.ts @@ -39,6 +39,12 @@ describe('wipeLocalData', () => { const absent = rig({}); await wipeLocalData(undefined, absent.ports); expect(absent.deleted).toEqual(KNOWN_DATABASES); + expect(KNOWN_DATABASES).toEqual([ + 'web-sync-store', + 'crypto-store', + 'matrix-js-sdk::matrix-sdk-crypto', + 'matrix-js-sdk::matrix-sdk-crypto-meta', + ]); expect(absent.cleared()).toBe(1); const throws = rig({ databases: async () => { @@ -75,4 +81,23 @@ describe('wipeLocalData', () => { expect(r.deleted).toEqual(['a']); expect(r.cleared()).toBe(1); }); + it('a storage that refuses to clear does not make the wipe reject', async () => { + const r = rig({ databases: async () => [] }); + const ports = { + ...r.ports, + storage: { + clear: () => { + throw new Error('SecurityError'); + }, + }, + }; + await expect(wipeLocalData(undefined, ports)).resolves.toBeUndefined(); + }); + it('a client store that never closes holds the wipe only for the timeout, then storage is cleared', async () => { + const r = rig({ databases: async () => [{ name: 'a' }] }); + const mx = { clearStores: () => new Promise(() => undefined) } as never; + await wipeLocalData(mx, { ...r.ports, clearStoresTimeoutMs: 10 }); + expect(r.deleted).toEqual(['a']); + expect(r.cleared()).toBe(1); + }); }); diff --git a/src/client/wipeLocalData.ts b/src/client/wipeLocalData.ts index dc24951c..d0a2da86 100644 --- a/src/client/wipeLocalData.ts +++ b/src/client/wipeLocalData.ts @@ -13,8 +13,12 @@ export type WipePorts = { databases?: () => Promise<{ name?: string }[]>; }; storage: Pick; + /** How long a client store that will not close may hold the wipe up. */ + clearStoresTimeoutMs?: number; }; +const CLEAR_STORES_TIMEOUT_MS = 5000; + const deleteDatabase = (idb: WipePorts['indexedDB'], name: string) => new Promise((resolve) => { try { @@ -32,7 +36,8 @@ const deleteDatabase = (idb: WipePorts['indexedDB'], name: string) => * The one wipe of everything this browser holds for the account: client stores, every IndexedDB * database, localStorage. Hard logout, a soft logout answered by another user or device, and * "sign in as someone else" all end here. It never rejects, and localStorage (the access token - * with it) is cleared whatever the databases do. Callers reload afterwards. + * with it) is cleared whatever the databases do; a client store that hangs is given + * `clearStoresTimeoutMs`, then the wipe goes on. Callers reload afterwards. */ export const wipeLocalData = async ( mx?: MatrixClient, @@ -40,7 +45,12 @@ export const wipeLocalData = async ( ): Promise => { try { try { - await mx?.clearStores(); + await Promise.race([ + mx?.clearStores(), + new Promise((resolve) => { + setTimeout(resolve, ports.clearStoresTimeoutMs ?? CLEAR_STORES_TIMEOUT_MS); + }), + ]); } catch { // the databases go below regardless } @@ -53,6 +63,10 @@ export const wipeLocalData = async ( } await Promise.all(names.map((name) => deleteDatabase(ports.indexedDB, name))); } finally { - ports.storage.clear(); + try { + ports.storage.clear(); + } catch { + // storage that refuses to clear cannot be helped; the reload still has to follow + } } }; From cd30c3dc40e6bc56616297a63ef2a13fc93504ad Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 19:13:14 +0000 Subject: [PATCH 40/74] wipeLocalData test: lint-clean never-settling store Co-Authored-By: Claude Sonnet 5.5 --- src/client/wipeLocalData.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/client/wipeLocalData.test.ts b/src/client/wipeLocalData.test.ts index 1cff5f16..82c835de 100644 --- a/src/client/wipeLocalData.test.ts +++ b/src/client/wipeLocalData.test.ts @@ -95,7 +95,12 @@ describe('wipeLocalData', () => { }); it('a client store that never closes holds the wipe only for the timeout, then storage is cleared', async () => { const r = rig({ databases: async () => [{ name: 'a' }] }); - const mx = { clearStores: () => new Promise(() => undefined) } as never; + const mx = { + clearStores: () => + new Promise(() => { + // never settles + }), + } as never; await wipeLocalData(mx, { ...r.ports, clearStoresTimeoutMs: 10 }); expect(r.deleted).toEqual(['a']); expect(r.cleared()).toBe(1); From 53a724ae27278c628a428a85cb7dfa98a6db8a2c Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 21:45:00 +0000 Subject: [PATCH 41/74] Files desk cut 1 under Matrix authority: folders by album title path, share into the current folder Albums titled a/b/c group into folders in AlbumView; ShareSet prefixes the current folder onto the album title it sends. No wire change. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/albums/AlbumView.tsx | 60 +++++++-- src/app/features/albums/ShareSet.tsx | 10 +- src/app/features/albums/albumFolders.test.ts | 129 +++++++++++++++++++ src/app/features/albums/albumFolders.ts | 73 +++++++++++ 4 files changed, 257 insertions(+), 15 deletions(-) create mode 100644 src/app/features/albums/albumFolders.test.ts create mode 100644 src/app/features/albums/albumFolders.ts diff --git a/src/app/features/albums/AlbumView.tsx b/src/app/features/albums/AlbumView.tsx index b670ee2b..20c1e27c 100644 --- a/src/app/features/albums/AlbumView.tsx +++ b/src/app/features/albums/AlbumView.tsx @@ -15,6 +15,7 @@ import { getBlobSafeMimeType } from '../../utils/mimeTypes'; import { timeDayMonYear } from '../../utils/time'; import { getMemberDisplayName } from '../../utils/room'; import { Album, AlbumFile } from './buildAlbums'; +import { folderOf, listFolder, pathLabel } from './albumFolders'; import { FileReadBy } from './FileReadBy'; import { ShareSet, useShareEnabled } from './ShareSet'; import { useAlbums } from './useAlbums'; @@ -91,6 +92,7 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) { const { loading, error, albums } = useAlbums(room); const [dateFormat] = useSetting(settingsAtom, 'dateFormatString'); const [openId, setOpenId] = useState(); + const [path, setPath] = useState([]); const open: Album | undefined = albums.find((a) => a.id === openId); const authorOf = (a: Album) => getMemberDisplayName(room, a.author) ?? a.author; const encrypted = room.hasEncryptionStateEvent(); @@ -103,8 +105,17 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) { const images = open.files.length > 0 && open.files.every((f) => f.kind === 'image'); body = ( <> - {open.title} @@ -122,14 +133,41 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) { body = This zone is encrypted, so its albums cannot be listed here.; else if (albums.length === 0) body = No albums yet.; else { - body = albums.map((a) => ( - - )); + const here = listFolder(albums, path); + body = ( + <> + {path.length > 0 && ( + + )} + {path.length > 0 && {pathLabel(path)}} + {here.folders.map((f) => ( + + ))} + {here.albums.map(({ album: a, leaf }) => ( + + ))} + {here.folders.length === 0 && here.albums.length === 0 && Nothing here.} + + ); } return ( @@ -139,7 +177,7 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) { {/* Stays mounted while an album is open, so a running send keeps its progress. */} {canShare && ( )} ))} {here.folders.length === 0 && here.albums.length === 0 && Nothing here.} + {truncated && ( + + The list is cut short: older albums are not loaded. + + )} ); } diff --git a/src/app/features/albums/ShareSet.folder.test.tsx b/src/app/features/albums/ShareSet.folder.test.tsx new file mode 100644 index 00000000..7f23fd63 --- /dev/null +++ b/src/app/features/albums/ShareSet.folder.test.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MatrixClientProvider } from '../../hooks/useMatrixClient'; + +// ShareSet hands its folder to the title the send lands under. The controller is held still and its +// `run` captured, and sendShareSet records the title it was given, so the path from the `folder` +// prop to the album root's title is read without a homeserver or a DOM. +const captured = vi.hoisted(() => ({ + run: undefined as undefined | ((files: unknown[], title: string, onFile: () => void) => unknown), + titles: [] as string[], +})); + +vi.mock('./shareController', () => ({ + createShareController: (deps: { run: NonNullable }) => { + captured.run = deps.run; + return { + subscribe: () => () => undefined, + getState: () => ({ files: [], title: '', phase: { kind: 'idle' }, epoch: 0 }), + pick: () => undefined, + setTitle: () => undefined, + send: () => undefined, + cancel: () => undefined, + }; + }, +})); +vi.mock('./shareSet', async (importOriginal) => ({ + ...(await importOriginal()), + sendShareSet: async (_port: unknown, title: string) => { + captured.titles.push(title); + return { albumId: '$a', sent: 0, failed: [], failedAt: [] }; + }, +})); +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal(); + // The server renderer has no snapshot for an external store; read the client one. + const useSyncExternalStore = (_sub: unknown, get: () => unknown) => get(); + return { ...actual, default: { ...actual, useSyncExternalStore }, useSyncExternalStore }; +}); + +// eslint-disable-next-line import/first +import { ShareSet } from './ShareSet'; + +const mount = (folder?: string[]) => { + const room = { roomId: '!r:x', hasEncryptionStateEvent: () => false } as never; + renderToStaticMarkup( + + ({ event_id: '$e' }) } as never}> + + + + ); +}; +const send = async (typed: string) => { + await captured.run?.([], typed, () => undefined); + return captured.titles.at(-1); +}; + +describe('ShareSet: the folder reaches the album title', () => { + beforeEach(() => { + captured.run = undefined; + captured.titles.length = 0; + }); + + it('at the top, the typed title goes as typed', async () => { + mount(); + expect(await send('Poses')).toBe('Poses'); + }); + + it('inside a folder, the path prefixes the typed title', async () => { + mount(['cast-art', 'poses']); + expect(await send('Set 2')).toBe('cast-art/poses/Set 2'); + }); + + it('an empty typed title falls back to the default, still under the folder', async () => { + mount(['handoff']); + expect(await send(' ')).toBe('handoff/Shared files'); + }); + + it('a typed title containing a slash goes deeper', async () => { + mount(['a']); + expect(await send('b/c')).toBe('a/b/c'); + }); +}); diff --git a/src/app/features/albums/readPages.test.ts b/src/app/features/albums/readPages.test.ts new file mode 100644 index 00000000..124a32a6 --- /dev/null +++ b/src/app/features/albums/readPages.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { Page, readPages } from './readPages'; + +const pager = (pages: Page[]) => { + const froms: (string | undefined)[] = []; + return { + froms, + fetchPage: async (from: string | undefined) => { + froms.push(from); + return pages[froms.length - 1] ?? {}; + }, + }; +}; + +describe('readPages', () => { + it('one page without a token is the whole read', async () => { + const p = pager([{ chunk: [1, 2] }]); + expect(await readPages(p.fetchPage)).toEqual({ items: [1, 2], truncated: false }); + expect(p.froms).toEqual([undefined]); + }); + + it('follows next_batch in order, passing each token back', async () => { + const p = pager([ + { chunk: [1], next_batch: 'a' }, + { chunk: [2], next_batch: 'b' }, + { chunk: [3] }, + ]); + expect(await readPages(p.fetchPage)).toEqual({ items: [1, 2, 3], truncated: false }); + expect(p.froms).toEqual([undefined, 'a', 'b']); + }); + + it('a missing chunk reads as empty', async () => { + expect(await readPages(async () => ({}))).toEqual({ items: [], truncated: false }); + }); + + it('an empty page ends the read even when it carries a token', async () => { + const p = pager([ + { chunk: [1], next_batch: 'a' }, + { chunk: [], next_batch: 'b' }, + ]); + expect(await readPages(p.fetchPage)).toEqual({ items: [1], truncated: false }); + expect(p.froms).toHaveLength(2); + }); + + it('the cap stops the read and says it was cut short', async () => { + const p = pager(Array.from({ length: 10 }, (_, i) => ({ chunk: [i], next_batch: `t${i}` }))); + expect(await readPages(p.fetchPage, 3)).toEqual({ items: [0, 1, 2], truncated: true }); + expect(p.froms).toHaveLength(3); + }); + + it('a last page exactly at the cap is not truncated', async () => { + const p = pager([{ chunk: [1], next_batch: 'a' }, { chunk: [2] }]); + expect((await readPages(p.fetchPage, 2)).truncated).toBe(false); + }); +}); diff --git a/src/app/features/albums/readPages.ts b/src/app/features/albums/readPages.ts new file mode 100644 index 00000000..a2b888e5 --- /dev/null +++ b/src/app/features/albums/readPages.ts @@ -0,0 +1,27 @@ +/** One page of a Matrix paginated endpoint: its events and the token for the next page, if any. */ +export type Page = { chunk?: T[]; next_batch?: string }; + +/** Pages read per endpoint at most, so a runaway zone cannot hold the phone in a loop. */ +export const MAX_PAGES = 20; + +/** + * Reads every page of an endpoint by following `next_batch`, up to `maxPages`. `truncated` is true + * when the cap stopped the read with more still to come, so a caller can say the list is partial. + */ +export const readPages = async ( + fetchPage: (from: string | undefined) => Promise>, + maxPages = MAX_PAGES +): Promise<{ items: T[]; truncated: boolean }> => { + const items: T[] = []; + let from: string | undefined; + for (let n = 0; n < maxPages; n += 1) { + // eslint-disable-next-line no-await-in-loop + const page = await fetchPage(from); + items.push(...(page.chunk ?? [])); + // An empty page ends the read even if a token is returned: a server that keeps handing out + // tokens with nothing behind them would otherwise spend every page of the cap. + if (!page.next_batch || !page.chunk?.length) return { items, truncated: false }; + from = page.next_batch; + } + return { items, truncated: true }; +}; diff --git a/src/app/features/albums/useAlbums.ts b/src/app/features/albums/useAlbums.ts index 7c1d5a57..13c199ec 100644 --- a/src/app/features/albums/useAlbums.ts +++ b/src/app/features/albums/useAlbums.ts @@ -7,12 +7,11 @@ import { useClientConfig } from '../../hooks/useClientConfig'; import { Album, ZoneEvent, buildAlbums } from './buildAlbums'; import { isFileZone } from './isFileZone'; import { mapLimit } from './mapLimit'; +import { Page, readPages } from './readPages'; const REPLY_FETCHES_IN_FLIGHT = 6; const REFRESH_MS = 30_000; -type Chunk = { chunk?: ZoneEvent[] }; - /** True when the room is the file zone: by its create type, or listed in the client config. */ export const useIsFileZone = (room: Room): boolean => { const config = useClientConfig(); @@ -29,37 +28,49 @@ export const useIsFileZone = (room: Room): boolean => { ); }; -export type AlbumsState = { loading: boolean; error: Error | null; albums: Album[] }; +export type AlbumsState = { + loading: boolean; + error: Error | null; + albums: Album[]; + /** A read hit the page cap (readPages), so older albums or files are not in the list. */ + truncated: boolean; +}; + +const PAGE_LIMIT = '100'; /** - * Slice 1 reads ONE page of each endpoint (threads: 100 roots; replies per root: 100). A zone with - * more albums or files than that is truncated; pagination is a later slice. Reads are made with - * the logged-in client, so the room must be unencrypted: encrypted events carry no msgtype and - * are skipped. + * Reads every page of the zone's threads, then every page of each thread's replies, each capped at + * MAX_PAGES (readPages). Reads are made with the logged-in client, so the room must be + * unencrypted: encrypted events carry no msgtype and are skipped. */ export const useAlbums = (room: Room): AlbumsState => { const mx = useMatrixClient(); const { roomId } = room; const query = useQuery({ queryKey: ['albums', roomId], - queryFn: async (): Promise => { + queryFn: async (): Promise<{ albums: Album[]; truncated: boolean }> => { const get = (path: string, params: Record) => - mx.http.authedRequest(Method.Get, path, params, undefined, { + mx.http.authedRequest>(Method.Get, path, params, undefined, { prefix: ClientPrefix.V1, }); + const paged = (path: string, params: Record) => + readPages((from) => get(path, from ? { ...params, from } : params)); const room_ = encodeURIComponent(roomId); - const roots = (await get(`/rooms/${room_}/threads`, { limit: '100' })).chunk ?? []; - const inputs = await mapLimit(roots, REPLY_FETCHES_IN_FLIGHT, async (root: ZoneEvent) => ({ - root, - replies: - ( - await get( - `/rooms/${room_}/relations/${encodeURIComponent(root.event_id ?? '')}/m.thread`, - { limit: '100', dir: 'f' } - ) - ).chunk ?? [], - })); - return buildAlbums(inputs, mx.getDomain() ?? ''); + const roots = await paged(`/rooms/${room_}/threads`, { limit: PAGE_LIMIT }); + let { truncated } = roots; + const inputs = await mapLimit( + roots.items, + REPLY_FETCHES_IN_FLIGHT, + async (root: ZoneEvent) => { + const replies = await paged( + `/rooms/${room_}/relations/${encodeURIComponent(root.event_id ?? '')}/m.thread`, + { limit: PAGE_LIMIT, dir: 'f' } + ); + truncated = truncated || replies.truncated; + return { root, replies: replies.items }; + } + ); + return { albums: buildAlbums(inputs, mx.getDomain() ?? ''), truncated }; }, retry: 1, refetchInterval: REFRESH_MS, @@ -68,6 +79,7 @@ export const useAlbums = (room: Room): AlbumsState => { loading: query.isLoading, // A failed refetch keeps the albums already loaded; the error shows only when there are none. error: query.data ? null : (query.error as Error | null), - albums: query.data ?? [], + albums: query.data?.albums ?? [], + truncated: query.data?.truncated ?? false, }; }; From 6aaee8badd8dc40a6801775ccb48bc86653652f0 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 22:59:33 +0000 Subject: [PATCH 45/74] Cut 2: doc names the page cap; truncation note says albums or files Co-Authored-By: Claude Sonnet 5.5 --- docs/commons-files-desk.md | 6 +++--- src/app/features/albums/AlbumView.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/commons-files-desk.md b/docs/commons-files-desk.md index 0018a432..98696766 100644 --- a/docs/commons-files-desk.md +++ b/docs/commons-files-desk.md @@ -21,7 +21,7 @@ Read from this repo at dev `8ba407cd`: - **Zone = a room** whose `m.room.create` type is `org.gamecult.file_zone` (`isFileZone.ts`), or one listed in config `fileZoneRooms`. One per Mind, `#-files:`, found by alias or by the carried id in the root Space's `org.gamecult.stewardships` (`mediaRoom.ts`, `MediaRow.tsx`); rendered as the Media row of each Mind's group. - **Album = a thread root** (a text event; its `body`, or its latest `m.replace` edit, is the title); **file = a thread reply** with `msgtype` `m.audio|m.image|m.video|m.file` (`buildAlbums.ts`). A redacted event or an `m.replace` is never a file. Loose room messages are not shown. -- **Read** = `GET /rooms/{id}/threads` and `/relations/{root}/m.thread`, one page of 100 each (`useAlbums.ts`; pagination is a known gap). Unencrypted rooms only. +- **Read** = `GET /rooms/{id}/threads` and `/relations/{root}/m.thread`, every page, 100 per request, at most 20 pages per endpoint (`useAlbums.ts`, `readPages.ts`; a capped read shows a note in the list). Unencrypted rooms only. - **Write** = `sendShareSet` (`shareSet.ts`, behind `fileZoneShare.enabled`): a root, then one thread reply per file, content from `getMsgContent` with `info.sha256`. - **Media** = authenticated Matrix media fetched to a blob (`downloadMedia` in `FileContent.tsx`): so the "media element cannot send a header" problem that Option B needed a ticket for is already solved this way, at the cost that a file is fully downloaded before it plays. - **Receipts** = `org.gamecult.file_received` from a Mind's mouth after it fetches and verifies the sha256 (`docs/file-zone-mind-contract.md`); the mouth enforces the Mind's membership in the room. @@ -103,7 +103,7 @@ What A adds to `ShareSet`: sharing into an existing album ("add files", listed a Under both, the phone design is the same shape (one column, rows 56 px or more, hidden controls, in-place preview; see the Option B sections below, which are written against the host but whose layout applies). -- **A, new cost:** the Commons room is one more room in sync and it must be muted; albums and files are one page of 100 each until pagination exists (a Commons of hundreds of documents needs it first); a folder tree is a title-path grouping, so a deep tree is a long list of albums with breadcrumbs, not true directories; a file plays after a full download (small audio and images are fine; a 40 MB clip is not). +- **A, new cost:** the Commons room is one more room in sync and it must be muted; albums and files are read to a cap of 20 pages of 100 per endpoint (a Commons past that shows a truncation note); a folder tree is a title-path grouping, so a deep tree is a long list of albums with breadcrumbs, not true directories; a file plays after a full download (small audio and images are fine; a 40 MB clip is not). - **A, no cost:** no second sign-in, no ticket, no CORS, no new fetch layer, no host-unreachable state beyond the homeserver's own. The album list, album view, share panel and receipts exist and are tested; the view is their extension. - **B, new cost:** every screen is a second client against a second service with its own errors, token refresh and unreachable state; media needs the ticket or the blob; the tree and preview are new UI. - **B, benefit:** a tree that is live and complete, ranges, and no Matrix size limit. @@ -121,7 +121,7 @@ Under A the paused service is not built, and most of its list disappears: Status 2026-11-13: cut 1 merged to dev as bb615570 (PR #147); cuts 2-5 follow. Cut 1's shape was: a pure `albumFolders` view (folders from `/`-separated album titles, leaf name, counts), folder navigation and breadcrumb in `AlbumView` through a presentational list component tested by static render, `ShareSet` sending into the current folder by prefixing the album title, and a quiet `Commons` row (alias `#commons:gamecult.org` or the carried `media.commons` id, through `lookupMediaRoom`, hidden while unset or looking, behind a dark `filesDesk.enabled` flag, and filtered out of the tiers so it is not listed twice). Muting is provisioning's (Q-E3), not the client's. 1. **Commons entry**: a Commons row in the navigator (like `MediaRow`, found by alias or carried id), opening the room's album view. Flagged, no new data layer. -2. **Pagination** of albums and files in `useAlbums` (a prerequisite for a Commons of any size). +2. **Pagination** of albums and files in `useAlbums` (done, PR #148). 3. **Folders by title path** and the album list grouped by it. 4. **Versions**: read `org.gamecult.publish.replaces`, show the latest of a chain with the earlier versions folded under it. 5. **Add files to an existing album**, with per-file progress, cancel and the refusal messages above (the upload flow). diff --git a/src/app/features/albums/AlbumView.tsx b/src/app/features/albums/AlbumView.tsx index dbb9f8e0..73e63e7c 100644 --- a/src/app/features/albums/AlbumView.tsx +++ b/src/app/features/albums/AlbumView.tsx @@ -168,7 +168,7 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) { {here.folders.length === 0 && here.albums.length === 0 && Nothing here.} {truncated && ( - The list is cut short: older albums are not loaded. + The list is cut short: some albums or files are not loaded. )} From ac6018b57cc635c9ef203f32d1d39723c15802fc Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 22:59:10 +0000 Subject: [PATCH 46/74] Files desk cut 3: show the latest version of a file chain, earlier versions folded under it A file event naming org.gamecult.publish.replaces supersedes the event it names. foldVersions reads the chain from the events alone; buildAlbums lists heads (count too) and AlbumView unfolds 'N earlier versions'. Both events stay ordinary files for other clients. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/albums/AlbumView.tsx | 22 ++++++- src/app/features/albums/buildAlbums.test.ts | 14 +++++ src/app/features/albums/buildAlbums.ts | 11 +++- src/app/features/albums/versions.test.ts | 65 +++++++++++++++++++++ src/app/features/albums/versions.ts | 46 +++++++++++++++ 5 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 src/app/features/albums/versions.test.ts create mode 100644 src/app/features/albums/versions.ts diff --git a/src/app/features/albums/AlbumView.tsx b/src/app/features/albums/AlbumView.tsx index 73e63e7c..944cc761 100644 --- a/src/app/features/albums/AlbumView.tsx +++ b/src/app/features/albums/AlbumView.tsx @@ -68,6 +68,26 @@ function FileRow({ room, file, author }: FileRowProps) { ); } +/** A file, and under it a row that unfolds the versions it supersedes. */ +function VersionedFileRow({ room, file, author }: FileRowProps) { + const [shown, setShown] = useState(false); + const n = file.earlier.length; + return ( + <> + + {n > 0 && ( + + )} + {shown && + file.earlier.map((e) => )} + + ); +} + const countLabel = (n: number) => `${n} ${n === 1 ? 'file' : 'files'}`; /** The zone never mounts RoomTimeline, so it sends the read receipt the timeline would. */ @@ -124,7 +144,7 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) { {open.count === 0 && This album has no files.}
{open.files.map((f) => ( - + ))}
diff --git a/src/app/features/albums/buildAlbums.test.ts b/src/app/features/albums/buildAlbums.test.ts index e98e4f51..d8d8ed9f 100644 --- a/src/app/features/albums/buildAlbums.test.ts +++ b/src/app/features/albums/buildAlbums.test.ts @@ -215,6 +215,20 @@ describe('buildAlbums', () => { }); }); +describe('versions', () => { + const v = (id: string, ts: number, replaces?: string) => + reply(id, ts, 'm.file', id, replaces ? { 'org.gamecult.publish': { replaces } } : {}); + + it('lists the latest of a chain and folds the earlier versions under it', () => { + const a = one(root('$r', 1), [v('$v1', 10), v('$v2', 20, '$v1'), reply('$x', 30, 'm.file')]); + expect(a.files.map((f) => [f.id, f.earlier.map((e) => e.id)])).toEqual([ + ['$v2', ['$v1']], + ['$x', []], + ]); + expect(a.count).toBe(2); + }); +}); + describe('Mind receipts', () => { const HASH = 'a'.repeat(64); const file = (id: string, sha?: string) => diff --git a/src/app/features/albums/buildAlbums.ts b/src/app/features/albums/buildAlbums.ts index 6fe8a452..69fe82b4 100644 --- a/src/app/features/albums/buildAlbums.ts +++ b/src/app/features/albums/buildAlbums.ts @@ -3,6 +3,7 @@ // into albums. See docs/file-zone.md for the rulings this encodes. import { readersOf } from './fileReceipt'; import type { FileReader } from './fileReceipt'; +import { foldVersions } from './versions'; /** The slice of a Matrix event this module reads. Every field may be missing. */ export type ZoneEvent = { @@ -27,6 +28,8 @@ export type AlbumFile = { content: Record; /** Receipts in this album that name this file, one per sender (the latest), oldest first. */ readBy: FileReader[]; + /** The versions this file supersedes, nearest first (`org.gamecult.publish.replaces`). */ + earlier: AlbumFile[]; }; export type Album = { @@ -75,6 +78,7 @@ const toFile = (e: ZoneEvent): AlbumFile | undefined => { ts: tsOf(e.origin_server_ts), content: e.content, readBy: [], + earlier: [], }; }; @@ -103,14 +107,15 @@ const toAlbum = (input: AlbumInput, server: string): Album | undefined => { }) .sort((a, b) => a.ts - b.ts) .map((f) => ({ ...f, readBy: readersOf(list, f.id, f.content, server) })); + const heads = foldVersions(files); return { id: root.event_id, title: titleOf(root), author: root.sender ?? '', ts: tsOf(root.origin_server_ts), - cover: files.find((f) => f.kind === 'image'), - files, - count: files.length, + cover: heads.find((f) => f.kind === 'image'), + files: heads, + count: heads.length, }; }; diff --git a/src/app/features/albums/versions.test.ts b/src/app/features/albums/versions.test.ts new file mode 100644 index 00000000..645f409d --- /dev/null +++ b/src/app/features/albums/versions.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { PUBLISH_KEY, foldVersions, replacesOf } from './versions'; + +type F = { id: string; content: Record; earlier: F[] }; +const f = (id: string, replaces?: string): F => ({ + id, + content: replaces ? { [PUBLISH_KEY]: { replaces } } : {}, + earlier: [], +}); +const shape = (files: F[]): unknown => files.map((x) => [x.id, x.earlier.map((e) => e.id)]); + +describe('replacesOf', () => { + it('reads the id the publisher key names', () => { + expect(replacesOf({ [PUBLISH_KEY]: { replaces: '$a' } })).toBe('$a'); + }); + it('anything else says nothing', () => { + expect(replacesOf({})).toBeUndefined(); + expect(replacesOf({ [PUBLISH_KEY]: {} })).toBeUndefined(); + expect(replacesOf({ [PUBLISH_KEY]: { replaces: 3 } })).toBeUndefined(); + expect(replacesOf({ [PUBLISH_KEY]: { replaces: '' } })).toBeUndefined(); + expect(replacesOf({ [PUBLISH_KEY]: 'x' })).toBeUndefined(); + }); +}); + +describe('foldVersions', () => { + it('files that replace nothing pass through in order, with nothing folded', () => { + expect(shape(foldVersions([f('$a'), f('$b')]))).toEqual([ + ['$a', []], + ['$b', []], + ]); + }); + + it('shows the latest of a chain with earlier versions nearest first', () => { + expect(shape(foldVersions([f('$a'), f('$b', '$a'), f('$c', '$b')]))).toEqual([ + ['$c', ['$b', '$a']], + ]); + }); + + it('keeps unrelated files beside a chain, in order', () => { + expect(shape(foldVersions([f('$a'), f('$x'), f('$b', '$a')]))).toEqual([ + ['$x', []], + ['$b', ['$a']], + ]); + }); + + it('a replacement whose target is not in the list stands alone', () => { + expect(shape(foldVersions([f('$b', '$gone')]))).toEqual([['$b', []]]); + }); + + it('a file two others replace sits under both', () => { + expect(shape(foldVersions([f('$a'), f('$b', '$a'), f('$c', '$a')]))).toEqual([ + ['$b', ['$a']], + ['$c', ['$a']], + ]); + }); + + it('a malformed loop shows no head and does not hang', () => { + const out = foldVersions([f('$a', '$b'), f('$b', '$a'), f('$s', '$s')]); + expect(out.map((x) => x.id)).toEqual(['$s']); + }); + + it('an empty list is empty', () => { + expect(foldVersions([])).toEqual([]); + }); +}); diff --git a/src/app/features/albums/versions.ts b/src/app/features/albums/versions.ts new file mode 100644 index 00000000..f38af62b --- /dev/null +++ b/src/app/features/albums/versions.ts @@ -0,0 +1,46 @@ +// Pure: a file event may name the event it supersedes in `org.gamecult.publish.replaces` (the +// publisher writes it on a later final version; docs/commons-files-desk.md, "replaces, not +// m.replace"). Both events stay ordinary files for every other client; this view shows the latest +// of a chain with the earlier ones folded under it. A chain is read from the events alone. + +/** Key the publisher writes its provenance under. */ +export const PUBLISH_KEY = 'org.gamecult.publish'; + +/** The event id a file says it replaces, if it says one. */ +export const replacesOf = (content: Record): string | undefined => { + const id = (content[PUBLISH_KEY] as { replaces?: unknown } | undefined)?.replaces; + return typeof id === 'string' && id ? id : undefined; +}; + +type Versioned = F & { earlier: F[] }; + +/** + * Files not replaced by another file in the list, oldest first as given, each carrying the files it + * supersedes (nearest first) in `earlier`. A replacement whose target is missing from the list (a + * redacted event, another thread) replaces nothing and stands as a head. A file two others claim to + * replace sits under both. The walk refuses to revisit a file, so a malformed chain cannot loop (an event id hashes its content, so a real one cannot cycle). + */ +export const foldVersions = }>( + files: Versioned[] +): Versioned[] => { + const byId = new Map(files.map((f) => [f.id, f])); + const target = (f: F): F | undefined => { + const id = replacesOf(f.content); + return id && id !== f.id ? byId.get(id) : undefined; + }; + const replaced = new Set(); + files.forEach((f) => { + const t = target(f); + if (t) replaced.add(t.id); + }); + const chainOf = (head: Versioned): Versioned[] => { + const out: Versioned[] = []; + const seen = new Set([head.id]); + for (let t = target(head); t && !seen.has(t.id); t = target(t)) { + seen.add(t.id); + out.push({ ...t, earlier: [] }); + } + return out; + }; + return files.filter((f) => !replaced.has(f.id)).map((h) => ({ ...h, earlier: chainOf(h) })); +}; From 796bc161a9bfae6fd133ad38475cdf45feda9546 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 23:00:10 +0000 Subject: [PATCH 47/74] Cut 3: doc marks versions done Co-Authored-By: Claude Sonnet 5.5 --- docs/commons-files-desk.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/commons-files-desk.md b/docs/commons-files-desk.md index 98696766..836af638 100644 --- a/docs/commons-files-desk.md +++ b/docs/commons-files-desk.md @@ -123,7 +123,7 @@ Status 2026-11-13: cut 1 merged to dev as bb615570 (PR #147); cuts 2-5 follow. C 1. **Commons entry**: a Commons row in the navigator (like `MediaRow`, found by alias or carried id), opening the room's album view. Flagged, no new data layer. 2. **Pagination** of albums and files in `useAlbums` (done, PR #148). 3. **Folders by title path** and the album list grouped by it. -4. **Versions**: read `org.gamecult.publish.replaces`, show the latest of a chain with the earlier versions folded under it. +4. **Versions**: read `org.gamecult.publish.replaces`, show the latest of a chain with the earlier versions folded under it (done, PR #149). 5. **Add files to an existing album**, with per-file progress, cancel and the refusal messages above (the upload flow). PR #143 (Option B cut 1: contract types, host client, fake host) is **held**; under A it is not needed and would be closed. Its Soul pass found, and a revived B would have to close, an unvalidated `host` that could send the bearer to the wrong origin, hostile-looking entry names beyond separators, loose numeric checks and missing CSP and `Content-Disposition` headers in the fake. From 5835e565cd1fcb9b11b7b0b4633466ce417cb10b Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 23:03:33 +0000 Subject: [PATCH 48/74] Cut 3 Soul fixes: replaces counts only from the same sender; tests for the loop guard and the cover Co-Authored-By: Claude Sonnet 5.5 --- docs/commons-files-desk.md | 2 +- src/app/features/albums/buildAlbums.test.ts | 10 ++++++++++ src/app/features/albums/buildAlbums.ts | 2 ++ src/app/features/albums/versions.test.ts | 18 ++++++++++++++++-- src/app/features/albums/versions.ts | 10 +++++++--- 5 files changed, 36 insertions(+), 6 deletions(-) diff --git a/docs/commons-files-desk.md b/docs/commons-files-desk.md index 836af638..361624bd 100644 --- a/docs/commons-files-desk.md +++ b/docs/commons-files-desk.md @@ -82,7 +82,7 @@ What it buys: one identity, one audience, one audit trail, immutable files, exis - **When it posts.** Handoff documents are edited in place, so mirroring every save would spam the room with drafts. The rule (Eureka's lean, adopted): **post once, when the document is marked final**; a later final version posts again with a `replaces` relation to the earlier event. "Final" is a sibling `NAME.final` holding the document's sha256 (answered Q-E2); the client must not know. - **Allowlist.** Lives on the publisher: a list of source roots and patterns, read by the publisher only. `handoff-private` is not on it and the publisher never lists, reads or posts from it. The client holds no path to anything on disk. - **The event.** A file event in the thread of the folder's album, built the way `getMsgContent` builds them (`msgtype`, `body`, `filename`, `url` from the media repo, `info.mimetype|size|sha256`), plus one extra key `org.gamecult.publish`: `{ source, sha256, replaces? }` where `source` is a path relative to the allowlisted root, never absolute, and `replaces` is the event id of the version it supersedes. -- **`replaces`, not `m.replace`.** `m.replace` is the stock edit relation, but Ratatoskr's `buildAlbums` deliberately never treats an edit as a file, several clients do not render an edited file, and an edit hides the earlier version. A custom key keeps both events as files; the view shows the latest of a chain and a "N earlier versions" row under it, and other Matrix clients see two ordinary file posts. A chain is read from the events themselves (each carries its `replaces`), not from a table. +- **`replaces`, not `m.replace`.** `m.replace` is the stock edit relation, but Ratatoskr's `buildAlbums` deliberately never treats an edit as a file, several clients do not render an edited file, and an edit hides the earlier version. A custom key keeps both events as files; the view shows the latest of a chain and a "N earlier versions" row under it, and other Matrix clients see two ordinary file posts. A chain is read from the events themselves (each carries its `replaces`), not from a table, and a `replaces` counts only when its event has the same sender as the event it names, so nobody else in the thread can fold a file away. - **Idempotence.** The publisher's own state (a typed CultCache document of `source`, `sha256`, `event_id`) says what it posted; if that is lost, it rebuilds by reading its own events back from the room (`org.gamecult.publish.source` plus `sha256`) rather than guessing. The same `sha256` never posts twice. - **Receipts and fetch** work unchanged: the event is an ordinary file event in a file-zone room, so a Mind's mouth can fetch it by room id and event id (5 MiB cap there) and tick `fetched for`. diff --git a/src/app/features/albums/buildAlbums.test.ts b/src/app/features/albums/buildAlbums.test.ts index d8d8ed9f..3053a493 100644 --- a/src/app/features/albums/buildAlbums.test.ts +++ b/src/app/features/albums/buildAlbums.test.ts @@ -229,6 +229,16 @@ describe('versions', () => { }); }); +describe('versions: cover and sender', () => { + it('the cover comes from the shown version, not a folded image', () => { + const img = (id: string, ts: number, type: string, replaces?: string) => + reply(id, ts, type, id, replaces ? { 'org.gamecult.publish': { replaces } } : {}); + const a = one(root('$r', 1), [img('$v1', 10, 'm.image'), img('$v2', 20, 'm.file', '$v1')]); + expect(a.files.map((f) => f.id)).toEqual(['$v2']); + expect(a.cover).toBeUndefined(); + }); +}); + describe('Mind receipts', () => { const HASH = 'a'.repeat(64); const file = (id: string, sha?: string) => diff --git a/src/app/features/albums/buildAlbums.ts b/src/app/features/albums/buildAlbums.ts index 69fe82b4..636ede38 100644 --- a/src/app/features/albums/buildAlbums.ts +++ b/src/app/features/albums/buildAlbums.ts @@ -23,6 +23,7 @@ export type AlbumFile = { id: string; kind: FileKind; name: string; + sender: string; ts: number; /** The event content, as RenderMessageContent reads it. */ content: Record; @@ -75,6 +76,7 @@ const toFile = (e: ZoneEvent): AlbumFile | undefined => { id: e.event_id, kind, name: asString(e.content.filename) || asString(e.content.body), + sender: e.sender ?? '', ts: tsOf(e.origin_server_ts), content: e.content, readBy: [], diff --git a/src/app/features/albums/versions.test.ts b/src/app/features/albums/versions.test.ts index 645f409d..e8ae64b8 100644 --- a/src/app/features/albums/versions.test.ts +++ b/src/app/features/albums/versions.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from 'vitest'; import { PUBLISH_KEY, foldVersions, replacesOf } from './versions'; -type F = { id: string; content: Record; earlier: F[] }; -const f = (id: string, replaces?: string): F => ({ +type F = { id: string; sender: string; content: Record; earlier: F[] }; +const f = (id: string, replaces?: string, sender = '@m:x'): F => ({ id, + sender, content: replaces ? { [PUBLISH_KEY]: { replaces } } : {}, earlier: [], }); @@ -59,6 +60,19 @@ describe('foldVersions', () => { expect(out.map((x) => x.id)).toEqual(['$s']); }); + it('a loop reached from a head is walked once and ends', () => { + const out = foldVersions([f('$a', '$b'), f('$b', '$a'), f('$h', '$a')]); + expect(shape(out)).toEqual([['$h', ['$a', '$b']]]); + }); + + it('a replacement by another sender folds nothing', () => { + const out = foldVersions([f('$victim', undefined, '@m:x'), f('$evil', '$victim', '@e:x')]); + expect(shape(out)).toEqual([ + ['$victim', []], + ['$evil', []], + ]); + }); + it('an empty list is empty', () => { expect(foldVersions([])).toEqual([]); }); diff --git a/src/app/features/albums/versions.ts b/src/app/features/albums/versions.ts index f38af62b..8c7055db 100644 --- a/src/app/features/albums/versions.ts +++ b/src/app/features/albums/versions.ts @@ -17,16 +17,20 @@ type Versioned = F & { earlier: F[] }; /** * Files not replaced by another file in the list, oldest first as given, each carrying the files it * supersedes (nearest first) in `earlier`. A replacement whose target is missing from the list (a - * redacted event, another thread) replaces nothing and stands as a head. A file two others claim to + * redacted event, another thread) replaces nothing and stands as a head. A replacement by another sender is ignored. A file two others claim to * replace sits under both. The walk refuses to revisit a file, so a malformed chain cannot loop (an event id hashes its content, so a real one cannot cycle). */ -export const foldVersions = }>( +export const foldVersions = < + F extends { id: string; sender: string; content: Record } +>( files: Versioned[] ): Versioned[] => { const byId = new Map(files.map((f) => [f.id, f])); const target = (f: F): F | undefined => { const id = replacesOf(f.content); - return id && id !== f.id ? byId.get(id) : undefined; + const t = id && id !== f.id ? byId.get(id) : undefined; + // Only the sender of a file can supersede it, so no one else in the thread can fold it away. + return t && t.sender === f.sender ? t : undefined; }; const replaced = new Set(); files.forEach((f) => { From 0622d7d65dfa92f2f82b7d7bff70eca1b7d29a05 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 23:05:37 +0000 Subject: [PATCH 49/74] Files desk cut 4: add files to an open album; name the upload refusals sendShareSet takes a title or an existing album (no new root; the first file threads after the album's newest event). Failed files carry the refusal for M_TOO_LARGE, M_FORBIDDEN and M_LIMIT_EXCEEDED in the result note. AlbumView mounts an Add files panel inside an open album. Co-Authored-By: Claude Sonnet 5.5 --- docs/commons-files-desk.md | 2 +- docs/file-zone.md | 2 +- src/app/features/albums/AlbumView.tsx | 9 +++- src/app/features/albums/ShareSet.test.tsx | 15 +++++- src/app/features/albums/ShareSet.tsx | 42 +++++++++------ src/app/features/albums/buildAlbums.test.ts | 13 ++++- src/app/features/albums/buildAlbums.ts | 7 +++ src/app/features/albums/shareSet.test.ts | 58 ++++++++++++++++++++- src/app/features/albums/shareSet.ts | 42 ++++++++++++--- 9 files changed, 161 insertions(+), 29 deletions(-) diff --git a/docs/commons-files-desk.md b/docs/commons-files-desk.md index 361624bd..511b0dbc 100644 --- a/docs/commons-files-desk.md +++ b/docs/commons-files-desk.md @@ -124,7 +124,7 @@ Status 2026-11-13: cut 1 merged to dev as bb615570 (PR #147); cuts 2-5 follow. C 2. **Pagination** of albums and files in `useAlbums` (done, PR #148). 3. **Folders by title path** and the album list grouped by it. 4. **Versions**: read `org.gamecult.publish.replaces`, show the latest of a chain with the earlier versions folded under it (done, PR #149). -5. **Add files to an existing album**, with per-file progress, cancel and the refusal messages above (the upload flow). +5. **Add files to an existing album**, with per-file progress, cancel and the refusal messages above (the upload flow). Done: adding to an open album and the three refusal messages (PR #150); cancel of a send in flight is not, and needs an abort path through `uploadContent`. PR #143 (Option B cut 1: contract types, host client, fake host) is **held**; under A it is not needed and would be closed. Its Soul pass found, and a revived B would have to close, an unvalidated `host` that could send the bearer to the wrong origin, hostile-looking entry names beyond separators, loose numeric checks and missing CSP and `Content-Disposition` headers in the fake. diff --git a/docs/file-zone.md b/docs/file-zone.md index 7990485d..0a895226 100644 --- a/docs/file-zone.md +++ b/docs/file-zone.md @@ -120,4 +120,4 @@ Client, `src/app/features/albums`: Flag: `fileZoneShare.enabled` in `config.json`, default `false`. With it off nothing mounts. -Not built: a Mind's own path into the zone (outbox, Persona decision, mouth post) belongs to gamecult-ops / Huginn / the mouth, not this client; sharing into an existing album ("add files"); upload cancel; pagination beyond 100 albums/files. +Not built: a Mind's own path into the zone (outbox, Persona decision, mouth post) belongs to gamecult-ops / Huginn / the mouth, not this client; cancel of a send in flight (a pick can be cancelled before it goes). Built since: pagination to 20 pages of 100 per endpoint (PR #148), adding files to an open album with named refusals (`M_TOO_LARGE`, `M_FORBIDDEN`, `M_LIMIT_EXCEEDED`). diff --git a/src/app/features/albums/AlbumView.tsx b/src/app/features/albums/AlbumView.tsx index 944cc761..fc76744a 100644 --- a/src/app/features/albums/AlbumView.tsx +++ b/src/app/features/albums/AlbumView.tsx @@ -14,7 +14,7 @@ import { markAsRead } from '../../utils/notifications'; import { getBlobSafeMimeType } from '../../utils/mimeTypes'; import { timeDayMonYear } from '../../utils/time'; import { getMemberDisplayName } from '../../utils/room'; -import { Album, AlbumFile } from './buildAlbums'; +import { Album, AlbumFile, lastEventId } from './buildAlbums'; import { folderOf, listFolder, pathLabel } from './albumFolders'; import { FileReadBy } from './FileReadBy'; import { ShareSet, useShareEnabled } from './ShareSet'; @@ -142,6 +142,13 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) { {authorOf(open)} · {countLabel(open.count)} · {timeDayMonYear(open.ts, dateFormat)}
{open.count === 0 && This album has no files.} + {canShare && ( + + )}
{open.files.map((f) => ( diff --git a/src/app/features/albums/ShareSet.test.tsx b/src/app/features/albums/ShareSet.test.tsx index bfa9a619..6ae47613 100644 --- a/src/app/features/albums/ShareSet.test.tsx +++ b/src/app/features/albums/ShareSet.test.tsx @@ -14,7 +14,7 @@ const text = (html: string) => const noop = () => undefined; const file = (name: string) => ({ name, type: 'audio/ogg' } as File); -const view = (over: Partial> = {}) => +const view = (over: Partial> = {}, adding = false) => renderToStaticMarkup( > = {}) => onTitle={noop} onSend={noop} onCancel={noop} + adding={adding} /> ); @@ -76,6 +77,18 @@ describe('the share panel, drawn', () => { }); }); +describe('adding to an existing album', () => { + it('the idle row says Add files', () => { + expect(text(view({}, true))).toBe('Add files ›'); + }); + + it('picked: no title field, the names, Send and Cancel', () => { + const html = view({ files: [file('a.ogg')] }, true); + expect(html).not.toContain('aria-label="Album title"'); + expect(text(html)).toBe('a.ogg Send 1 file Cancel'); + }); +}); + describe('the flag', () => { it('off, missing or empty offers no share', () => { expect(shareEnabled({}, false)).toBe(false); diff --git a/src/app/features/albums/ShareSet.tsx b/src/app/features/albums/ShareSet.tsx index 1d76753a..a8e65b4f 100644 --- a/src/app/features/albums/ShareSet.tsx +++ b/src/app/features/albums/ShareSet.tsx @@ -10,7 +10,7 @@ import { getMsgContent } from '../room/msgContent'; import { FILE_HASH_KEY, hashForSend } from './fileReceipt'; import { ShareController, ShareState, createShareController } from './shareController'; import { titleIn } from './albumFolders'; -import { DEFAULT_TITLE, sendShareSet } from './shareSet'; +import { DEFAULT_TITLE, ExistingAlbum, sendShareSet } from './shareSet'; import * as css from './AlbumView.css'; /** Off unless `fileZoneShare.enabled` is true. An encrypted zone is never offered the share. */ @@ -29,6 +29,8 @@ type ShareViewProps = { onTitle: (title: string) => void; onSend: () => void; onCancel: () => void; + /** Adding to an existing album: the files join it, so there is no title to ask for. */ + adding?: boolean; inputRef?: React.Ref; }; @@ -43,6 +45,7 @@ export function ShareSetView({ onTitle, onSend, onCancel, + adding, inputRef, }: ShareViewProps) { const { files, title, phase, epoch } = state; @@ -58,7 +61,7 @@ export function ShareSetView({ /> {files.length === 0 && phase.kind !== 'sending' && ( )} {phase.kind === 'done' && ( @@ -68,14 +71,16 @@ export function ShareSetView({ )} {files.length > 0 && ( <> - onTitle(e.target.value)} - /> + {!adding && ( + onTitle(e.target.value)} + /> + )} {files.map((f) => f.name).join(' · ')} @@ -101,15 +106,21 @@ export function ShareSetView({ ); } -/** `folder` is the path the share lands in: its albums are titled under it (docs/commons-files-desk.md). */ -export function ShareSet({ room, folder = [] }: { room: Room; folder?: string[] }) { +type ShareSetProps = { + room: Room; + /** The path a new album lands in: its title is prefixed by it (docs/commons-files-desk.md). */ + folder?: string[]; + /** Add the files to this album instead of making one. */ + into?: ExistingAlbum; +}; +export function ShareSet({ room, folder = [], into }: ShareSetProps) { const mx = useMatrixClient(); const queryClient = useQueryClient(); const input = useRef(null); // Built once per mount (AlbumView keys the panel by room): the pick lives in the controller, so a // rerender with a new client or Room object must not build another. Live values go through `live`. - const live = useRef({ mx, room, queryClient, folder }); - live.current = { mx, room, queryClient, folder }; + const live = useRef({ mx, room, queryClient, folder, into }); + live.current = { mx, room, queryClient, folder, into }; const [controller]: [ShareController, unknown] = useState(() => createShareController({ roomId: room.roomId, @@ -142,7 +153,7 @@ export function ShareSet({ room, folder = [] }: { room: Room; folder?: string[] }, onFile, }, - titleIn(live.current.folder, title, DEFAULT_TITLE), + live.current.into ?? titleIn(live.current.folder, title, DEFAULT_TITLE), files.map((file) => ({ name: file.name, mimeType: file.type, file })) ), }) @@ -157,6 +168,7 @@ export function ShareSet({ room, folder = [] }: { room: Room; folder?: string[] onTitle={controller.setTitle} onSend={controller.send} onCancel={controller.cancel} + adding={!!into} /> ); } diff --git a/src/app/features/albums/buildAlbums.test.ts b/src/app/features/albums/buildAlbums.test.ts index 3053a493..4bf72d0e 100644 --- a/src/app/features/albums/buildAlbums.test.ts +++ b/src/app/features/albums/buildAlbums.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { AlbumInput, ZoneEvent, buildAlbums } from './buildAlbums'; +import { AlbumInput, ZoneEvent, buildAlbums, lastEventId } from './buildAlbums'; const root = (id: string, ts: number, over: Partial = {}): ZoneEvent => ({ event_id: id, @@ -239,6 +239,17 @@ describe('versions: cover and sender', () => { }); }); +describe('lastEventId', () => { + it('is the newest event of the thread, a folded version included; the root when empty', () => { + const v = (id: string, ts: number, replaces?: string) => + reply(id, ts, 'm.file', id, replaces ? { 'org.gamecult.publish': { replaces } } : {}); + expect(lastEventId(one(root('$r', 1), [v('$a', 10), v('$b', 20, '$a'), v('$c', 15)]))).toBe( + '$b' + ); + expect(lastEventId(one(root('$r', 1), []))).toBe('$r'); + }); +}); + describe('Mind receipts', () => { const HASH = 'a'.repeat(64); const file = (id: string, sha?: string) => diff --git a/src/app/features/albums/buildAlbums.ts b/src/app/features/albums/buildAlbums.ts index 636ede38..0fe2d113 100644 --- a/src/app/features/albums/buildAlbums.ts +++ b/src/app/features/albums/buildAlbums.ts @@ -134,3 +134,10 @@ export const buildAlbums = (inputs: AlbumInput[], server = ''): Album[] => { }) .sort((a, b) => b.ts - a.ts); }; + +/** The newest event in an album's thread (shown or folded), else its root: where a new file threads after. */ +export const lastEventId = (album: Album): string => + album.files + .flatMap((f) => [f, ...f.earlier]) + .reduce((a, f) => (!a || f.ts >= a.ts ? f : a), undefined)?.id ?? + album.id; diff --git a/src/app/features/albums/shareSet.test.ts b/src/app/features/albums/shareSet.test.ts index f43e5a35..d1db0b2e 100644 --- a/src/app/features/albums/shareSet.test.ts +++ b/src/app/features/albums/shareSet.test.ts @@ -5,6 +5,7 @@ import { DEFAULT_TITLE, ShareFile, SharePort, + refusalOf, remainingAfter, resultNote, sendShareSet, @@ -56,7 +57,7 @@ describe('sendShareSet', () => { it('reads back as one album of the files in pick order', async () => { const r = room(); const res = await sendShareSet(r.port, 'Take 4', clips); - expect(res).toEqual({ albumId: '$e1', sent: 3, failed: [], failedAt: [] }); + expect(res).toEqual({ albumId: '$e1', sent: 3, failed: [], failedAt: [], reasons: [] }); const album = r.album(); expect(album.title).toBe('Take 4'); expect(album.files.map((f) => [f.name, f.kind])).toEqual([ @@ -158,3 +159,58 @@ describe('after a send', () => { expect(resultNote(res)).toBe('Shared 3 files.'); }); }); + +describe('adding to an existing album', () => { + it('sends no root; each file threads under the album, the first after `lastId`', async () => { + const r = room(); + const sent: Record[] = []; + const port = { + ...r.port, + send: async (c: Record) => { + sent.push(c); + return `$n${sent.length}`; + }, + }; + const res = await sendShareSet(port, { albumId: '$root', lastId: '$last' }, clips.slice(0, 2)); + expect(res.albumId).toBe('$root'); + expect(res.sent).toBe(2); + expect(sent.map((c) => c.msgtype)).toEqual(['m.audio', 'm.audio']); + expect(sent.map((c) => c['m.relates_to'])).toEqual([ + threadRelation('$root', '$last'), + threadRelation('$root', '$n1'), + ]); + }); +}); + +describe('refusals', () => { + const refuse = (errcode: string): SharePort => ({ + send: async () => '$e', + contentOf: async () => { + throw Object.assign(new Error('raw server text'), { errcode }); + }, + }); + + it('names the three upload refusals and nothing else', () => { + expect(refusalOf({ errcode: 'M_TOO_LARGE' })).toBe('too large for this server'); + expect(refusalOf({ errcode: 'M_FORBIDDEN' })).toBe('not allowed to upload here'); + expect(refusalOf({ errcode: 'M_LIMIT_EXCEEDED' })).toBe( + 'uploading too fast, try again shortly' + ); + expect(refusalOf({ errcode: 'M_UNKNOWN' })).toBeUndefined(); + expect(refusalOf({ errcode: 'constructor' })).toBeUndefined(); + expect(refusalOf(new Error('x'))).toBeUndefined(); + expect(refusalOf(null)).toBeUndefined(); + }); + + it('the note carries the reason beside each refused file', async () => { + const res = await sendShareSet(refuse('M_TOO_LARGE'), 't', clips.slice(0, 2)); + expect(resultNote(res)).toBe( + 'Nothing was shared. Not sent: a.ogg (too large for this server), b.ogg (too large for this server)' + ); + }); + + it('an unnamed failure lists the bare name', async () => { + const res = await sendShareSet(refuse('M_UNKNOWN'), 't', clips.slice(0, 1)); + expect(resultNote(res)).toBe('Nothing was shared. Not sent: a.ogg'); + }); +}); diff --git a/src/app/features/albums/shareSet.ts b/src/app/features/albums/shareSet.ts index 3e572595..c3652f62 100644 --- a/src/app/features/albums/shareSet.ts +++ b/src/app/features/albums/shareSet.ts @@ -22,6 +22,23 @@ export type ShareResult = { failed: string[]; /** Positions in the files array of those that failed; names can repeat, positions cannot. */ failedAt: number[]; + /** Why each failed file failed, parallel to `failed`, when the homeserver's refusal is one we name. */ + reasons?: (string | undefined)[]; +}; + +/** An album that already exists: new files join its thread after `lastId`, with no new root. */ +export type ExistingAlbum = { albumId: string; lastId: string }; + +const REFUSALS: Record = { + M_TOO_LARGE: 'too large for this server', + M_FORBIDDEN: 'not allowed to upload here', + M_LIMIT_EXCEEDED: 'uploading too fast, try again shortly', +}; + +/** The sentence for a homeserver refusal we know, from the error's Matrix errcode. */ +export const refusalOf = (e: unknown): string | undefined => { + const code = (e as { errcode?: unknown } | null)?.errcode; + return typeof code === 'string' && Object.hasOwn(REFUSALS, code) ? REFUSALS[code] : undefined; }; /** The reply relation Matrix clients expect for a thread message: root, with the last event as fallback reply. */ @@ -44,12 +61,15 @@ export const titleOrDefault = (title: string): string => title.trim() || DEFAULT */ export const sendShareSet = async ( port: SharePort, - title: string, + into: string | ExistingAlbum, files: F[] ): Promise => { - const albumId = await port.send({ msgtype: 'm.text', body: titleOrDefault(title) }); - const result: ShareResult = { albumId, sent: 0, failed: [], failedAt: [] }; - let lastId = albumId; + const albumId = + typeof into === 'string' + ? await port.send({ msgtype: 'm.text', body: titleOrDefault(into) }) + : into.albumId; + const result: ShareResult = { albumId, sent: 0, failed: [], failedAt: [], reasons: [] }; + let lastId = typeof into === 'string' ? albumId : into.lastId; for (let i = 0; i < files.length; i += 1) { try { // eslint-disable-next-line no-await-in-loop @@ -58,8 +78,9 @@ export const sendShareSet = async ( lastId = await port.send({ ...content, 'm.relates_to': threadRelation(albumId, lastId) }); result.sent += 1; port.onFile?.(i, 'sent'); - } catch { + } catch (e) { result.failed.push(files[i].name); + result.reasons?.push(refusalOf(e)); result.failedAt.push(i); port.onFile?.(i, 'failed'); } @@ -69,15 +90,20 @@ export const sendShareSet = async ( /** * What stays picked after a send: only the files that did not go, so a retry is one tap. A retry - * makes a second album (adding to an existing one is not built), and a share where nothing went + * of a new album makes a second album, and a share where nothing went * leaves its root as a lone message the album list does not show (it lists threads with replies). */ export const remainingAfter = (files: T[], result: ShareResult): T[] => files.filter((_, i) => result.failedAt.includes(i)); +const failedList = (result: ShareResult): string => + result.failed + .map((name, i) => (result.reasons?.[i] ? `${name} (${result.reasons[i]})` : name)) + .join(', '); + export const resultNote = (result: ShareResult): string => { if (result.failed.length === 0) return `Shared ${result.sent} ${result.sent === 1 ? 'file' : 'files'}.`; - if (result.sent === 0) return `Nothing was shared. Not sent: ${result.failed.join(', ')}`; - return `Shared ${result.sent}. Not sent: ${result.failed.join(', ')}`; + if (result.sent === 0) return `Nothing was shared. Not sent: ${failedList(result)}`; + return `Shared ${result.sent}. Not sent: ${failedList(result)}`; }; From 90c6d5e2788071ec0dfeea4b28fb1e7675040d20 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 23:11:33 +0000 Subject: [PATCH 50/74] Cut 4: pin that ShareSet sends into an existing album Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/albums/ShareSet.folder.test.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/app/features/albums/ShareSet.folder.test.tsx b/src/app/features/albums/ShareSet.folder.test.tsx index 7f23fd63..a19f6e16 100644 --- a/src/app/features/albums/ShareSet.folder.test.tsx +++ b/src/app/features/albums/ShareSet.folder.test.tsx @@ -27,8 +27,8 @@ vi.mock('./shareController', () => ({ })); vi.mock('./shareSet', async (importOriginal) => ({ ...(await importOriginal()), - sendShareSet: async (_port: unknown, title: string) => { - captured.titles.push(title); + sendShareSet: async (_port: unknown, into: string | { albumId: string }) => { + captured.titles.push(typeof into === 'string' ? into : `into:${into.albumId}`); return { albumId: '$a', sent: 0, failed: [], failedAt: [] }; }, })); @@ -42,12 +42,12 @@ vi.mock('react', async (importOriginal) => { // eslint-disable-next-line import/first import { ShareSet } from './ShareSet'; -const mount = (folder?: string[]) => { +const mount = (folder?: string[], into?: { albumId: string; lastId: string }) => { const room = { roomId: '!r:x', hasEncryptionStateEvent: () => false } as never; renderToStaticMarkup( ({ event_id: '$e' }) } as never}> - + ); @@ -82,4 +82,9 @@ describe('ShareSet: the folder reaches the album title', () => { mount(['a']); expect(await send('b/c')).toBe('a/b/c'); }); + + it('an existing album takes the files instead of a title, whatever the folder', async () => { + mount(['a'], { albumId: '$alb', lastId: '$last' }); + expect(await send('ignored')).toBe('into:$alb'); + }); }); From e406ec7dc4b011be47794806f9166ed9abfdddc5 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 23:14:42 +0000 Subject: [PATCH 51/74] Files desk: Stop a send in flight; files in a replaces loop all show; Add files threads after the freshest event Stop aborts the running upload (onPromise + mx.cancelUpload) and leaves it and the rest picked as 'stopped'. foldVersions shows files caught in a replaces loop with no head instead of hiding them. An add reads the room timeline at send time for the newest event in the thread. Co-Authored-By: Claude Sonnet 5.5 --- docs/commons-files-desk.md | 2 +- docs/file-zone.md | 2 +- src/app/features/albums/AlbumView.tsx | 4 +- .../features/albums/ShareSet.folder.test.tsx | 34 +++++-- src/app/features/albums/ShareSet.test.tsx | 2 + src/app/features/albums/ShareSet.tsx | 44 +++++++-- src/app/features/albums/buildAlbums.test.ts | 13 +-- src/app/features/albums/buildAlbums.ts | 8 +- .../features/albums/shareController.test.ts | 26 +++++- src/app/features/albums/shareController.ts | 21 +++-- src/app/features/albums/shareSet.test.ts | 89 ++++++++++++++++++- src/app/features/albums/shareSet.ts | 40 ++++++++- src/app/features/albums/versions.test.ts | 8 +- src/app/features/albums/versions.ts | 10 ++- 14 files changed, 262 insertions(+), 41 deletions(-) diff --git a/docs/commons-files-desk.md b/docs/commons-files-desk.md index 511b0dbc..7b06bb9d 100644 --- a/docs/commons-files-desk.md +++ b/docs/commons-files-desk.md @@ -124,7 +124,7 @@ Status 2026-11-13: cut 1 merged to dev as bb615570 (PR #147); cuts 2-5 follow. C 2. **Pagination** of albums and files in `useAlbums` (done, PR #148). 3. **Folders by title path** and the album list grouped by it. 4. **Versions**: read `org.gamecult.publish.replaces`, show the latest of a chain with the earlier versions folded under it (done, PR #149). -5. **Add files to an existing album**, with per-file progress, cancel and the refusal messages above (the upload flow). Done: adding to an open album and the three refusal messages (PR #150); cancel of a send in flight is not, and needs an abort path through `uploadContent`. +5. **Add files to an existing album**, with per-file progress, cancel and the refusal messages above (the upload flow). Done: adding to an open album and the three refusal messages (PR #150); Stop for a send in flight is in too (PR for follow-up `files-desk-release-146-and-send-cancel`): it cancels the running upload through `onPromise` and `mx.cancelUpload`. PR #143 (Option B cut 1: contract types, host client, fake host) is **held**; under A it is not needed and would be closed. Its Soul pass found, and a revived B would have to close, an unvalidated `host` that could send the bearer to the wrong origin, hostile-looking entry names beyond separators, loose numeric checks and missing CSP and `Content-Disposition` headers in the fake. diff --git a/docs/file-zone.md b/docs/file-zone.md index 0a895226..420f7016 100644 --- a/docs/file-zone.md +++ b/docs/file-zone.md @@ -120,4 +120,4 @@ Client, `src/app/features/albums`: Flag: `fileZoneShare.enabled` in `config.json`, default `false`. With it off nothing mounts. -Not built: a Mind's own path into the zone (outbox, Persona decision, mouth post) belongs to gamecult-ops / Huginn / the mouth, not this client; cancel of a send in flight (a pick can be cancelled before it goes). Built since: pagination to 20 pages of 100 per endpoint (PR #148), adding files to an open album with named refusals (`M_TOO_LARGE`, `M_FORBIDDEN`, `M_LIMIT_EXCEEDED`). +Not built: a Mind's own path into the zone (outbox, Persona decision, mouth post) belongs to gamecult-ops / Huginn / the mouth, not this client; Built since: Stop for a send in flight (aborts the running upload, leaves it and the rest picked as "stopped"), pagination to 20 pages of 100 per endpoint (PR #148), adding files to an open album with named refusals (`M_TOO_LARGE`, `M_FORBIDDEN`, `M_LIMIT_EXCEEDED`). diff --git a/src/app/features/albums/AlbumView.tsx b/src/app/features/albums/AlbumView.tsx index fc76744a..d6ee37c9 100644 --- a/src/app/features/albums/AlbumView.tsx +++ b/src/app/features/albums/AlbumView.tsx @@ -14,7 +14,7 @@ import { markAsRead } from '../../utils/notifications'; import { getBlobSafeMimeType } from '../../utils/mimeTypes'; import { timeDayMonYear } from '../../utils/time'; import { getMemberDisplayName } from '../../utils/room'; -import { Album, AlbumFile, lastEventId } from './buildAlbums'; +import { Album, AlbumFile, lastEvent } from './buildAlbums'; import { folderOf, listFolder, pathLabel } from './albumFolders'; import { FileReadBy } from './FileReadBy'; import { ShareSet, useShareEnabled } from './ShareSet'; @@ -146,7 +146,7 @@ export function AlbumView({ room, onTimeline }: AlbumViewProps) { )}
diff --git a/src/app/features/albums/ShareSet.folder.test.tsx b/src/app/features/albums/ShareSet.folder.test.tsx index a19f6e16..109d538c 100644 --- a/src/app/features/albums/ShareSet.folder.test.tsx +++ b/src/app/features/albums/ShareSet.folder.test.tsx @@ -27,8 +27,8 @@ vi.mock('./shareController', () => ({ })); vi.mock('./shareSet', async (importOriginal) => ({ ...(await importOriginal()), - sendShareSet: async (_port: unknown, into: string | { albumId: string }) => { - captured.titles.push(typeof into === 'string' ? into : `into:${into.albumId}`); + sendShareSet: async (_port: unknown, into: string | { albumId: string; lastId: string }) => { + captured.titles.push(typeof into === 'string' ? into : `into:${into.albumId}@${into.lastId}`); return { albumId: '$a', sent: 0, failed: [], failedAt: [] }; }, })); @@ -42,8 +42,19 @@ vi.mock('react', async (importOriginal) => { // eslint-disable-next-line import/first import { ShareSet } from './ShareSet'; -const mount = (folder?: string[], into?: { albumId: string; lastId: string }) => { - const room = { roomId: '!r:x', hasEncryptionStateEvent: () => false } as never; +const timeline: unknown[] = []; +const ev = (id: string, ts: number, threadRootId?: string, status: string | null = null) => ({ + getId: () => id, + getTs: () => ts, + threadRootId, + status, +}); +const mount = (folder?: string[], into?: { albumId: string; lastId: string; lastTs: number }) => { + const room = { + roomId: '!r:x', + hasEncryptionStateEvent: () => false, + getLiveTimeline: () => ({ getEvents: () => timeline }), + } as never; renderToStaticMarkup( ({ event_id: '$e' }) } as never}> @@ -61,6 +72,7 @@ describe('ShareSet: the folder reaches the album title', () => { beforeEach(() => { captured.run = undefined; captured.titles.length = 0; + timeline.length = 0; }); it('at the top, the typed title goes as typed', async () => { @@ -84,7 +96,17 @@ describe('ShareSet: the folder reaches the album title', () => { }); it('an existing album takes the files instead of a title, whatever the folder', async () => { - mount(['a'], { albumId: '$alb', lastId: '$last' }); - expect(await send('ignored')).toBe('into:$alb'); + mount(['a'], { albumId: '$alb', lastId: '$last', lastTs: 5 }); + expect(await send('ignored')).toBe('into:$alb@$last'); + }); + + it('the freshest sent event of that thread in the room timeline is where the files thread after', async () => { + timeline.push( + ev('$other', 99, '$else'), + ev('$local', 98, '$alb', 'sending'), + ev('$new', 50, '$alb') + ); + mount(['a'], { albumId: '$alb', lastId: '$last', lastTs: 5 }); + expect(await send('x')).toBe('into:$alb@$new'); }); }); diff --git a/src/app/features/albums/ShareSet.test.tsx b/src/app/features/albums/ShareSet.test.tsx index 6ae47613..b3da7a3d 100644 --- a/src/app/features/albums/ShareSet.test.tsx +++ b/src/app/features/albums/ShareSet.test.tsx @@ -23,6 +23,7 @@ const view = (over: Partial> = {}, adding = false) => onTitle={noop} onSend={noop} onCancel={noop} + onStop={noop} adding={adding} /> ); @@ -55,6 +56,7 @@ describe('the share panel, drawn', () => { phase: { kind: 'sending', done: 2 }, }); expect(text(html)).toContain('Sending 2 of 3…'); + expect(text(html)).toContain('Stop'); expect(text(html)).not.toContain('Send 3 files'); expect(text(html)).not.toContain('Cancel'); expect(html).toMatch( diff --git a/src/app/features/albums/ShareSet.tsx b/src/app/features/albums/ShareSet.tsx index a8e65b4f..6bce53c7 100644 --- a/src/app/features/albums/ShareSet.tsx +++ b/src/app/features/albums/ShareSet.tsx @@ -10,7 +10,7 @@ import { getMsgContent } from '../room/msgContent'; import { FILE_HASH_KEY, hashForSend } from './fileReceipt'; import { ShareController, ShareState, createShareController } from './shareController'; import { titleIn } from './albumFolders'; -import { DEFAULT_TITLE, ExistingAlbum, sendShareSet } from './shareSet'; +import { DEFAULT_TITLE, ExistingAlbum, freshest, sendShareSet, ThreadEvent } from './shareSet'; import * as css from './AlbumView.css'; /** Off unless `fileZoneShare.enabled` is true. An encrypted zone is never offered the share. */ @@ -29,6 +29,7 @@ type ShareViewProps = { onTitle: (title: string) => void; onSend: () => void; onCancel: () => void; + onStop: () => void; /** Adding to an existing album: the files join it, so there is no title to ask for. */ adding?: boolean; inputRef?: React.Ref; @@ -45,6 +46,7 @@ export function ShareSetView({ onTitle, onSend, onCancel, + onStop, adding, inputRef, }: ShareViewProps) { @@ -85,9 +87,14 @@ export function ShareSetView({ {files.map((f) => f.name).join(' · ')} {phase.kind === 'sending' ? ( - - Sending {phase.done} of {files.length}… - + <> + + Sending {phase.done} of {files.length}… + + + ) : ( <>