Closes#277. Raised on #174 as the other half of a migration — import, notice something is wrong, empty the book, correct the export, import again.
The gap was wider than the ask. Contacts had no multi-select at all: the only delete in the module was the cross on a single card's pane, one card and one confirmation at a time. destroyCards has taken a list and batched it against maxObjectsInSet since #218, and nothing in the UI ever handed it more than one id. So "empty this address book" was missing, and so was "delete these fourteen".
What is here
Selection in the list. Checkboxes revealed on hover as the message list's are, always visible on a touchscreen where there is no hover. Shift-click takes the run between two rows. The search box gives way to a selection bar rather than sitting beside it — what the count promises is what the search left on screen. A selection clears when the book being shown changes, since carrying it across leaves a count describing rows that are no longer there and a Delete aimed at them.
Empty address book, in the book's own menu beside the import and export that moved there in #226, and separate from Delete, which takes the book with it. A default book cannot be deleted and can perfectly well be emptied, which is most of why it earns its own entry.
Three decisions worth reviewing
A card filed in two books is patched out of this one, not destroyed. This is the whole reason emptying is not one destroy over everything in the book: ContactCard/set destroy takes a card away from every book at once, so emptying one book would quietly have emptied another. Reported separately afterwards, or it looks like contacts that refused to go.
destroyCards answers rather than throws. A refusal that took half a selection with it still deleted the other half; an error saying only that it failed sends you looking for contacts that are already gone. Both callers report the count and the reason apart.
Emptying a shared book is not offered — the open question on the issue. The cards live in the owner's account and this client has no path to write there, so showing the option would be showing something that cannot work.
The bug the tests would not have caught
Found by driving the built app, and worth recording because of where it hid. The range a shift-click covers was measured inside the setPicked updater — which React runs when it gets round to rendering, by which time the anchor ref has already been moved to the row that ended the range. Every shift-click selected exactly one row, and every store assertion still passed, because nothing was wrong below the component.
The anchor is read before the updater now, and the contacts view has its first component tests: 10 of them, 6 of which fail if the measurement moves back inside. Verified by reinstating the bug and re-running.
Checks
1071 web tests (22 new: 12 store, 10 component), 142 server tests, typecheck and npm run build clean. Twelve new strings in all nine catalogues — fallbacks are back at the pre-change baseline of 24, so nothing new renders English.
Driven end to end against the mock: selection bar, shift-range forwards and backwards, the Empty “Personal”? dialog reporting "6 contacts will be deleted", and the list emptying with "Deleted 6 contacts".
Closes #277. Raised on #174 as the other half of a migration — import, notice something is wrong, empty the book, correct the export, import again.
The gap was wider than the ask. **Contacts had no multi-select at all**: the only delete in the module was the cross on a single card's pane, one card and one confirmation at a time. `destroyCards` has taken a list and batched it against `maxObjectsInSet` since #218, and nothing in the UI ever handed it more than one id. So "empty this address book" was missing, and so was "delete these fourteen".
## What is here
**Selection in the list.** Checkboxes revealed on hover as the message list's are, always visible on a touchscreen where there is no hover. Shift-click takes the run between two rows. The search box gives way to a selection bar rather than sitting beside it — what the count promises is what the search left on screen. A selection clears when the book being shown changes, since carrying it across leaves a count describing rows that are no longer there and a Delete aimed at them.
**Empty address book**, in the book's own menu beside the import and export that moved there in #226, and separate from Delete, which takes the book with it. A default book cannot be deleted and can perfectly well be emptied, which is most of why it earns its own entry.
## Three decisions worth reviewing
**A card filed in two books is patched out of this one, not destroyed.** This is the whole reason emptying is not one `destroy` over everything in the book: `ContactCard/set destroy` takes a card away from *every* book at once, so emptying one book would quietly have emptied another. Reported separately afterwards, or it looks like contacts that refused to go.
**`destroyCards` answers rather than throws.** A refusal that took half a selection with it still deleted the other half; an error saying only that it failed sends you looking for contacts that are already gone. Both callers report the count and the reason apart.
**Emptying a shared book is not offered** — the open question on the issue. The cards live in the owner's account and this client has no path to write there, so showing the option would be showing something that cannot work.
## The bug the tests would not have caught
Found by driving the built app, and worth recording because of where it hid. The range a shift-click covers was measured *inside* the `setPicked` updater — which React runs when it gets round to rendering, by which time the anchor ref has already been moved to the row that **ended** the range. Every shift-click selected exactly one row, and every store assertion still passed, because nothing was wrong below the component.
The anchor is read before the updater now, and the contacts view has its first component tests: **10 of them, 6 of which fail if the measurement moves back inside**. Verified by reinstating the bug and re-running.
## Checks
1071 web tests (22 new: 12 store, 10 component), 142 server tests, typecheck and `npm run build` clean. Twelve new strings in all nine catalogues — fallbacks are back at the pre-change baseline of 24, so nothing new renders English.
Driven end to end against the mock: selection bar, shift-range forwards and backwards, the *Empty “Personal”?* dialog reporting "6 contacts will be deleted", and the list emptying with "Deleted 6 contacts".
**Merged** 2026-09-04 as coffey-labs/ihasmail@0c9a15a6912c
<sub>Rebuilt from: GH Archive, git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #277. Raised on #174 as the other half of a migration — import, notice something is wrong, empty the book, correct the export, import again.
The gap was wider than the ask. Contacts had no multi-select at all: the only delete in the module was the cross on a single card's pane, one card and one confirmation at a time.
destroyCardshas taken a list and batched it againstmaxObjectsInSetsince #218, and nothing in the UI ever handed it more than one id. So "empty this address book" was missing, and so was "delete these fourteen".What is here
Selection in the list. Checkboxes revealed on hover as the message list's are, always visible on a touchscreen where there is no hover. Shift-click takes the run between two rows. The search box gives way to a selection bar rather than sitting beside it — what the count promises is what the search left on screen. A selection clears when the book being shown changes, since carrying it across leaves a count describing rows that are no longer there and a Delete aimed at them.
Empty address book, in the book's own menu beside the import and export that moved there in #226, and separate from Delete, which takes the book with it. A default book cannot be deleted and can perfectly well be emptied, which is most of why it earns its own entry.
Three decisions worth reviewing
A card filed in two books is patched out of this one, not destroyed. This is the whole reason emptying is not one
destroyover everything in the book:ContactCard/set destroytakes a card away from every book at once, so emptying one book would quietly have emptied another. Reported separately afterwards, or it looks like contacts that refused to go.destroyCardsanswers rather than throws. A refusal that took half a selection with it still deleted the other half; an error saying only that it failed sends you looking for contacts that are already gone. Both callers report the count and the reason apart.Emptying a shared book is not offered — the open question on the issue. The cards live in the owner's account and this client has no path to write there, so showing the option would be showing something that cannot work.
The bug the tests would not have caught
Found by driving the built app, and worth recording because of where it hid. The range a shift-click covers was measured inside the
setPickedupdater — which React runs when it gets round to rendering, by which time the anchor ref has already been moved to the row that ended the range. Every shift-click selected exactly one row, and every store assertion still passed, because nothing was wrong below the component.The anchor is read before the updater now, and the contacts view has its first component tests: 10 of them, 6 of which fail if the measurement moves back inside. Verified by reinstating the bug and re-running.
Checks
1071 web tests (22 new: 12 store, 10 component), 142 server tests, typecheck and
npm run buildclean. Twelve new strings in all nine catalogues — fallbacks are back at the pre-change baseline of 24, so nothing new renders English.Driven end to end against the mock: selection bar, shift-range forwards and backwards, the Empty “Personal”? dialog reporting "6 contacts will be deleted", and the list emptying with "Deleted 6 contacts".
Merged 2026-09-04 as coffey-labs/ihasmail@0c9a15a691
Rebuilt from: GH Archive, git history, session transcript.