Take the message you were reading into the selection

Ctrl-clicking a second message selected only the second. The first stayed
highlighted, because it was the one open -- which is a different state
wearing a similar colour -- and was never actually selected. So both looked
picked, one was, and every action that followed applied to half of what the
screen showed, silently.

The cause is visible once the two rules sit together: shift-click took the
whole run *including* the row it started from, and ctrl-click took only the
row clicked. Two branches of one handler that had drifted apart, with
nothing asserting they agreed.

So they are one function now, and tested. Ctrl-click brings the current row
with it while nothing is selected yet; once there is a selection it toggles
exactly one row, which is what it is for. Shift-click is unchanged, and
keeps its anchor where it is so extending a range twice grows it from the
same place rather than from wherever it last reached.

The last test asserts the property that failed rather than the branches:
whichever modifier begins a selection, the anchor is in it.
This commit is contained in:
2026-09-01 13:41:04 -07:00
parent fa55636fb0
commit f25b559cb5
3 changed files with 188 additions and 19 deletions
+70
View File
@@ -0,0 +1,70 @@
import type { Id } from "@/jmap/types";
/**
* What a click on a message row means.
*
* Lifted out of the list so the rules sit together and can be tested. They had
* drifted apart while they were two branches of one handler: shift-click
* selected the whole range including the row it started from, and ctrl-click
* selected only the row clicked, leaving the message you had open highlighted
* but unticked. Both looked picked; one was. That is issue #186, and the reason
* this is a function rather than a comment asking the next person to be careful.
*/
export type RowClick =
| { kind: "open" }
| { kind: "select"; ids: Id[]; on: boolean; moveAnchor: boolean };
export function rowClick(opts: {
/** The row clicked. */
rowId: Id;
/** Every row on screen, in the order they are shown. */
ids: Id[];
/** The row a range would extend from: the last one clicked without shift. */
anchor: Id | null;
selected: Record<Id, boolean>;
modifiers: { shift: boolean; ctrl: boolean };
isMobile: boolean;
}): RowClick {
const { rowId, ids, anchor, selected, modifiers, isMobile } = opts;
const selectedCount = Object.keys(selected).length;
// A range, from the anchor to here, inclusive at both ends.
if (modifiers.shift && anchor) {
const from = ids.indexOf(anchor);
const to = ids.indexOf(rowId);
if (from >= 0 && to >= 0) {
const [start, end] = from < to ? [from, to] : [to, from];
// The anchor stays where it is, so extending the range again grows it
// from the same place rather than from wherever it last reached.
return { kind: "select", ids: ids.slice(start, end + 1), on: true, moveAnchor: false };
}
}
if (modifiers.ctrl) {
/*
* The row that was already current joins the selection.
*
* Opening a message does not select it -- it is highlighted because it is
* the one being read, which is a different state -- so picking a second one
* with ctrl used to select only the second, and every action that followed
* quietly applied to half of what the screen showed.
*
* Only while nothing is selected yet. Once there is a selection, ctrl-click
* toggles exactly one row, which is the whole point of it.
*/
if (!selectedCount && anchor && anchor !== rowId && ids.includes(anchor)) {
return { kind: "select", ids: [anchor, rowId], on: true, moveAnchor: true };
}
return { kind: "select", ids: [rowId], on: !selected[rowId], moveAnchor: true };
}
// On a touchscreen, once anything is selected a plain tap goes on selecting:
// there is no modifier to hold, and opening a message mid-selection is almost
// never what the tap meant.
if (selectedCount > 0 && isMobile) {
return { kind: "select", ids: [rowId], on: !selected[rowId], moveAnchor: true };
}
return { kind: "open" };
}