@bobfrankston/rmfmail 1.0.469 → 1.0.471

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,22 @@
1
1
  /**
2
- * Shared message statesingle source of truth for the message list and viewer.
2
+ * Shared message storethe messages currently rendered in the list.
3
3
  *
4
- * The message list and viewer both reference message objects from this store.
5
- * Operations (move, delete, sync) mutate the store; UI components subscribe
6
- * and update themselves. No manual DOM cleanup, no custom events.
4
+ * Selection state used to live here too, with a broadcast-subscribe model
5
+ * that let the viewer subscribe to "selected changed" events independently
6
+ * of the list. That made drift between the highlighted row and the preview
7
+ * pane possible: two subscribers, two render paths, two opportunities for
8
+ * a missed update or stale read. Selection is now owned by message-list
9
+ * (the only thing that has DOM rows in the first place); the viewer is a
10
+ * passive `show(msg)` / `clear()` API the list calls directly. With one
11
+ * code path, drift is structurally impossible.
12
+ *
13
+ * What remains here: the array of currently-loaded messages, plus the
14
+ * removeMessages helper that filters the array and lets the caller decide
15
+ * which row to focus next (if any) — that policy is the list's, not the
16
+ * store's.
7
17
  */
8
- /** The currently loaded messages (what the list shows) */
9
18
  let messages = [];
10
- /** The currently selected/viewed message (same ref as an entry in messages[]) */
11
- let selected = null;
12
19
  const listeners = [];
13
- /** Subscribe to state changes. Returns unsubscribe function. */
14
20
  export function subscribe(fn) {
15
21
  listeners.push(fn);
16
22
  return () => {
@@ -19,61 +25,38 @@ export function subscribe(fn) {
19
25
  listeners.splice(i, 1);
20
26
  };
21
27
  }
22
- function notify(change) {
28
+ function notify() {
23
29
  for (const fn of listeners) {
24
30
  try {
25
- fn(change);
31
+ fn();
26
32
  }
27
33
  catch { /* don't let one subscriber break others */ }
28
34
  }
29
35
  }
30
- /** Replace the entire message list (folder load, search, unified inbox).
31
- * Selection stays even when the previously-selected message isn't in
32
- * the new list — that case happens whenever a periodic sync re-fetches
33
- * page 1 of the unified inbox and a row the user opened earlier has
34
- * scrolled off, or when the same row's accountId/uid pair changes
35
- * shape after dedup. Yanking the viewer mid-read was a user-reported
36
- * surprise (2026-04-30: "I was reading a message but now it just
37
- * jumped to Select a message to read"). The viewer keeps the message
38
- * it has; it'll only clear when the user explicitly clicks elsewhere
39
- * or the message is removed via removeMessages (real delete/move). */
36
+ /** Replace the entire message list (folder load, search, unified inbox). */
40
37
  export function setMessages(msgs) {
41
38
  messages = msgs;
42
- notify("messages");
39
+ notify();
43
40
  }
44
- /** Get current messages */
45
41
  export function getMessages() {
46
42
  return messages;
47
43
  }
48
- /** Select a message (by reference or null to deselect) */
49
- export function select(msg) {
50
- selected = msg;
51
- notify("selected");
52
- }
53
- /** Get the selected message */
54
- export function getSelected() {
55
- return selected;
56
- }
57
44
  /**
58
45
  * Remove messages from the list (after move or delete).
59
- * If the selected message is removed, auto-selects the next message or null.
60
- * Decrements dupeCount on any remaining messages that shared a Message-ID
61
- * with one of the removed rows so the unified-inbox marker disappears
62
- * when one half of a duplicate pair is deleted, instead of pointing at a
63
- * sibling that no longer exists.
46
+ *
47
+ * Returns a RemovalOutcome the caller uses to drive focus handoff. We
48
+ * pre-capture the next-survivor *by identity* (not by index) before the
49
+ * filter mutates the array, so subsequent index shifts don't matter if
50
+ * every deleted row had a survivor below it, the chosen survivor is the
51
+ * same regardless of how many got deleted. Falls back to walking
52
+ * backward when the deletion is at the bottom.
64
53
  */
65
- export function removeMessages(uids) {
54
+ export function removeMessages(uids, currentlyFocused) {
66
55
  const removeSet = new Set(uids.map(u => `${u.accountId}:${u.uid}`));
67
- const wasSelectedRemoved = selected && removeSet.has(`${selected.accountId}:${selected.uid}`);
68
- // Identity-based pre-capture of the row that should become selected
69
- // after the batch. Walk forward from the last removed row to the first
70
- // surviving row — that's the message the user would have navigated to
71
- // next. Capturing by reference (not index) so the post-filter shift
72
- // doesn't matter; if every deleted row had a survivor below it, the
73
- // chosen survivor is the same regardless of how many got deleted.
74
- // Falls back to walking backward if the deletion is at the bottom.
75
- let nextAfterRemoval = null;
76
- if (wasSelectedRemoved) {
56
+ const focusedKey = currentlyFocused ? `${currentlyFocused.accountId}:${currentlyFocused.uid}` : null;
57
+ const focusedWasRemoved = focusedKey !== null && removeSet.has(focusedKey);
58
+ let nextSurvivor = null;
59
+ if (focusedWasRemoved) {
77
60
  let lastRemovedIdx = -1;
78
61
  for (let i = 0; i < messages.length; i++) {
79
62
  if (removeSet.has(`${messages[i].accountId}:${messages[i].uid}`))
@@ -81,24 +64,23 @@ export function removeMessages(uids) {
81
64
  }
82
65
  for (let i = lastRemovedIdx + 1; i < messages.length; i++) {
83
66
  if (!removeSet.has(`${messages[i].accountId}:${messages[i].uid}`)) {
84
- nextAfterRemoval = messages[i];
67
+ nextSurvivor = messages[i];
85
68
  break;
86
69
  }
87
70
  }
88
- if (!nextAfterRemoval) {
89
- // No survivor below — fall back to the closest survivor above.
71
+ if (!nextSurvivor) {
90
72
  for (let i = lastRemovedIdx - 1; i >= 0; i--) {
91
73
  if (!removeSet.has(`${messages[i].accountId}:${messages[i].uid}`)) {
92
- nextAfterRemoval = messages[i];
74
+ nextSurvivor = messages[i];
93
75
  break;
94
76
  }
95
77
  }
96
78
  }
97
79
  }
98
- // Capture Message-IDs of the rows about to leave, so we can fix up the
99
- // dupeCount on any remaining siblings. Only IDs that were in the list
100
- // and had a non-empty messageId count empty IDs would match every
101
- // headerless row and falsely "merge" them.
80
+ // dupeCount fix-up: if we removed one half of a unified-inbox duplicate
81
+ // pair, the surviving row's marker should drop now rather than wait
82
+ // for the next server fetch. Decrement once per remaining row whose
83
+ // messageId matches a removed one.
102
84
  const removedIds = new Set();
103
85
  for (const m of messages) {
104
86
  if (removeSet.has(`${m.accountId}:${m.uid}`) && m.messageId) {
@@ -106,10 +88,6 @@ export function removeMessages(uids) {
106
88
  }
107
89
  }
108
90
  messages = messages.filter(m => !removeSet.has(`${m.accountId}:${m.uid}`));
109
- // Sibling dupeCount adjustment. dupeCount is server-recomputed on the
110
- // next getUnifiedInbox fetch, but we don't want to wait for that —
111
- // user just deleted, the marker should drop now. Decrement once per
112
- // remaining message whose messageId matches a removed one.
113
91
  if (removedIds.size > 0) {
114
92
  for (const m of messages) {
115
93
  if (m.messageId && removedIds.has(m.messageId) && typeof m.dupeCount === "number") {
@@ -117,16 +95,19 @@ export function removeMessages(uids) {
117
95
  }
118
96
  }
119
97
  }
120
- if (wasSelectedRemoved) {
121
- selected = nextAfterRemoval;
122
- }
123
- notify("removed");
98
+ notify();
99
+ return {
100
+ focusedWasRemoved,
101
+ nextSurvivor: nextSurvivor
102
+ ? { accountId: nextSurvivor.accountId, uid: nextSurvivor.uid, folderId: nextSurvivor.folderId }
103
+ : null,
104
+ };
124
105
  }
125
- /** Update flags on a message in the list */
106
+ /** Update flags on a message in the list. List re-renders the row's
107
+ * flag/unread classes inline; no broadcast. */
126
108
  export function updateMessageFlags(accountId, uid, flags) {
127
109
  const msg = messages.find(m => m.uid === uid && m.accountId === accountId);
128
110
  if (msg)
129
111
  msg.flags = flags;
130
- // No notify — flag changes are cosmetic, handled inline by the list
131
112
  }
132
113
  //# sourceMappingURL=message-state.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"message-state.js","sourceRoot":"","sources":["message-state.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"message-state.js","sourceRoot":"","sources":["message-state.ts"],"names":[],"mappings":"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"}
@@ -1,9 +1,19 @@
1
1
  /**
2
- * Shared message statesingle source of truth for the message list and viewer.
2
+ * Shared message storethe messages currently rendered in the list.
3
3
  *
4
- * The message list and viewer both reference message objects from this store.
5
- * Operations (move, delete, sync) mutate the store; UI components subscribe
6
- * and update themselves. No manual DOM cleanup, no custom events.
4
+ * Selection state used to live here too, with a broadcast-subscribe model
5
+ * that let the viewer subscribe to "selected changed" events independently
6
+ * of the list. That made drift between the highlighted row and the preview
7
+ * pane possible: two subscribers, two render paths, two opportunities for
8
+ * a missed update or stale read. Selection is now owned by message-list
9
+ * (the only thing that has DOM rows in the first place); the viewer is a
10
+ * passive `show(msg)` / `clear()` API the list calls directly. With one
11
+ * code path, drift is structurally impossible.
12
+ *
13
+ * What remains here: the array of currently-loaded messages, plus the
14
+ * removeMessages helper that filters the array and lets the caller decide
15
+ * which row to focus next (if any) — that policy is the list's, not the
16
+ * store's.
7
17
  */
8
18
 
9
19
  export interface ListMessage {
@@ -22,122 +32,102 @@ export interface ListMessage {
22
32
  [key: string]: any;
23
33
  }
24
34
 
25
- type ChangeType = "messages" | "selected" | "removed";
26
- type Listener = (change: ChangeType) => void;
27
-
28
- /** The currently loaded messages (what the list shows) */
29
35
  let messages: ListMessage[] = [];
30
36
 
31
- /** The currently selected/viewed message (same ref as an entry in messages[]) */
32
- let selected: ListMessage | null = null;
33
-
34
- const listeners: Listener[] = [];
37
+ /** Subscribers fire when the messages array is replaced or filtered.
38
+ * Selection state is NOT broadcast — that lives in message-list and
39
+ * reaches the viewer through a single direct call, never through this
40
+ * fan-out. Subscribers here are observers only (status bar, thread
41
+ * filter, etc.); they don't drive the preview pane. */
42
+ type MessagesListener = () => void;
43
+ const listeners: MessagesListener[] = [];
35
44
 
36
- /** Subscribe to state changes. Returns unsubscribe function. */
37
- export function subscribe(fn: Listener): () => void {
45
+ export function subscribe(fn: MessagesListener): () => void {
38
46
  listeners.push(fn);
39
47
  return () => {
40
48
  const i = listeners.indexOf(fn);
41
49
  if (i >= 0) listeners.splice(i, 1);
42
50
  };
43
51
  }
44
-
45
- function notify(change: ChangeType): void {
52
+ function notify(): void {
46
53
  for (const fn of listeners) {
47
- try { fn(change); } catch { /* don't let one subscriber break others */ }
54
+ try { fn(); } catch { /* don't let one subscriber break others */ }
48
55
  }
49
56
  }
50
57
 
51
- /** Replace the entire message list (folder load, search, unified inbox).
52
- * Selection stays even when the previously-selected message isn't in
53
- * the new list — that case happens whenever a periodic sync re-fetches
54
- * page 1 of the unified inbox and a row the user opened earlier has
55
- * scrolled off, or when the same row's accountId/uid pair changes
56
- * shape after dedup. Yanking the viewer mid-read was a user-reported
57
- * surprise (2026-04-30: "I was reading a message but now it just
58
- * jumped to Select a message to read"). The viewer keeps the message
59
- * it has; it'll only clear when the user explicitly clicks elsewhere
60
- * or the message is removed via removeMessages (real delete/move). */
58
+ /** Replace the entire message list (folder load, search, unified inbox). */
61
59
  export function setMessages(msgs: ListMessage[]): void {
62
60
  messages = msgs;
63
- notify("messages");
61
+ notify();
64
62
  }
65
63
 
66
- /** Get current messages */
67
64
  export function getMessages(): ListMessage[] {
68
65
  return messages;
69
66
  }
70
67
 
71
- /** Select a message (by reference or null to deselect) */
72
- export function select(msg: ListMessage | null): void {
73
- selected = msg;
74
- notify("selected");
75
- }
76
-
77
- /** Get the selected message */
78
- export function getSelected(): ListMessage | null {
79
- return selected;
68
+ /** Result of a removeMessages call: the list-controller uses this to
69
+ * decide what to focus after a delete/move. */
70
+ export interface RemovalOutcome {
71
+ /** True if any of the removed messages was previously focused — caller
72
+ * must transition the focus (to nextSurvivor or clear the viewer). */
73
+ focusedWasRemoved: boolean;
74
+ /** Identity of the message that should become the new focus, or null
75
+ * when the list is now empty / no survivor exists. The list looks up
76
+ * its row by this identity and calls its own focus-row method. */
77
+ nextSurvivor: { accountId: string; uid: number; folderId: number } | null;
80
78
  }
81
79
 
82
80
  /**
83
81
  * Remove messages from the list (after move or delete).
84
- * If the selected message is removed, auto-selects the next message or null.
85
- * Decrements dupeCount on any remaining messages that shared a Message-ID
86
- * with one of the removed rows so the unified-inbox marker disappears
87
- * when one half of a duplicate pair is deleted, instead of pointing at a
88
- * sibling that no longer exists.
82
+ *
83
+ * Returns a RemovalOutcome the caller uses to drive focus handoff. We
84
+ * pre-capture the next-survivor *by identity* (not by index) before the
85
+ * filter mutates the array, so subsequent index shifts don't matter if
86
+ * every deleted row had a survivor below it, the chosen survivor is the
87
+ * same regardless of how many got deleted. Falls back to walking
88
+ * backward when the deletion is at the bottom.
89
89
  */
90
- export function removeMessages(uids: { accountId: string; uid: number }[]): void {
90
+ export function removeMessages(
91
+ uids: { accountId: string; uid: number }[],
92
+ currentlyFocused: { accountId: string; uid: number } | null,
93
+ ): RemovalOutcome {
91
94
  const removeSet = new Set(uids.map(u => `${u.accountId}:${u.uid}`));
92
- const wasSelectedRemoved = selected && removeSet.has(`${selected.accountId}:${selected.uid}`);
95
+ const focusedKey = currentlyFocused ? `${currentlyFocused.accountId}:${currentlyFocused.uid}` : null;
96
+ const focusedWasRemoved = focusedKey !== null && removeSet.has(focusedKey);
93
97
 
94
- // Identity-based pre-capture of the row that should become selected
95
- // after the batch. Walk forward from the last removed row to the first
96
- // surviving row — that's the message the user would have navigated to
97
- // next. Capturing by reference (not index) so the post-filter shift
98
- // doesn't matter; if every deleted row had a survivor below it, the
99
- // chosen survivor is the same regardless of how many got deleted.
100
- // Falls back to walking backward if the deletion is at the bottom.
101
- let nextAfterRemoval: ListMessage | null = null;
102
- if (wasSelectedRemoved) {
98
+ let nextSurvivor: ListMessage | null = null;
99
+ if (focusedWasRemoved) {
103
100
  let lastRemovedIdx = -1;
104
101
  for (let i = 0; i < messages.length; i++) {
105
102
  if (removeSet.has(`${messages[i].accountId}:${messages[i].uid}`)) lastRemovedIdx = i;
106
103
  }
107
104
  for (let i = lastRemovedIdx + 1; i < messages.length; i++) {
108
105
  if (!removeSet.has(`${messages[i].accountId}:${messages[i].uid}`)) {
109
- nextAfterRemoval = messages[i];
106
+ nextSurvivor = messages[i];
110
107
  break;
111
108
  }
112
109
  }
113
- if (!nextAfterRemoval) {
114
- // No survivor below — fall back to the closest survivor above.
110
+ if (!nextSurvivor) {
115
111
  for (let i = lastRemovedIdx - 1; i >= 0; i--) {
116
112
  if (!removeSet.has(`${messages[i].accountId}:${messages[i].uid}`)) {
117
- nextAfterRemoval = messages[i];
113
+ nextSurvivor = messages[i];
118
114
  break;
119
115
  }
120
116
  }
121
117
  }
122
118
  }
123
119
 
124
- // Capture Message-IDs of the rows about to leave, so we can fix up the
125
- // dupeCount on any remaining siblings. Only IDs that were in the list
126
- // and had a non-empty messageId count empty IDs would match every
127
- // headerless row and falsely "merge" them.
120
+ // dupeCount fix-up: if we removed one half of a unified-inbox duplicate
121
+ // pair, the surviving row's marker should drop now rather than wait
122
+ // for the next server fetch. Decrement once per remaining row whose
123
+ // messageId matches a removed one.
128
124
  const removedIds = new Set<string>();
129
125
  for (const m of messages) {
130
126
  if (removeSet.has(`${m.accountId}:${m.uid}`) && m.messageId) {
131
127
  removedIds.add(m.messageId);
132
128
  }
133
129
  }
134
-
135
130
  messages = messages.filter(m => !removeSet.has(`${m.accountId}:${m.uid}`));
136
-
137
- // Sibling dupeCount adjustment. dupeCount is server-recomputed on the
138
- // next getUnifiedInbox fetch, but we don't want to wait for that —
139
- // user just deleted, the marker should drop now. Decrement once per
140
- // remaining message whose messageId matches a removed one.
141
131
  if (removedIds.size > 0) {
142
132
  for (const m of messages) {
143
133
  if (m.messageId && removedIds.has(m.messageId) && typeof (m as any).dupeCount === "number") {
@@ -145,16 +135,19 @@ export function removeMessages(uids: { accountId: string; uid: number }[]): void
145
135
  }
146
136
  }
147
137
  }
138
+ notify();
148
139
 
149
- if (wasSelectedRemoved) {
150
- selected = nextAfterRemoval;
151
- }
152
- notify("removed");
140
+ return {
141
+ focusedWasRemoved,
142
+ nextSurvivor: nextSurvivor
143
+ ? { accountId: nextSurvivor.accountId, uid: nextSurvivor.uid, folderId: nextSurvivor.folderId }
144
+ : null,
145
+ };
153
146
  }
154
147
 
155
- /** Update flags on a message in the list */
148
+ /** Update flags on a message in the list. List re-renders the row's
149
+ * flag/unread classes inline; no broadcast. */
156
150
  export function updateMessageFlags(accountId: string, uid: number, flags: string[]): void {
157
151
  const msg = messages.find(m => m.uid === uid && m.accountId === accountId);
158
152
  if (msg) msg.flags = flags;
159
- // No notify — flag changes are cosmetic, handled inline by the list
160
153
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/rmfmail",
3
- "version": "1.0.469",
3
+ "version": "1.0.471",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -32,7 +32,6 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@bobfrankston/iflow-direct": "^0.1.27",
35
- "@bobfrankston/iflow-node": "^0.1.8",
36
35
  "@bobfrankston/miscinfo": "^1.0.10",
37
36
  "@bobfrankston/oauthsupport": "^1.0.25",
38
37
  "@bobfrankston/msger": "^0.1.369",
@@ -74,7 +73,6 @@
74
73
  },
75
74
  ".dependencies": {
76
75
  "@bobfrankston/iflow-direct": "file:../../MailApps/iflow-direct",
77
- "@bobfrankston/iflow-node": "file:../../MailApps/iflow-node",
78
76
  "@bobfrankston/miscinfo": "file:../../../projects/npm/miscinfo",
79
77
  "@bobfrankston/oauthsupport": "file:../../../projects/oauth/oauthsupport",
80
78
  "@bobfrankston/msger": "file:../../../utils/msgx/msger",
@@ -98,7 +96,6 @@
98
96
  ".transformedSnapshot": {
99
97
  "dependencies": {
100
98
  "@bobfrankston/iflow-direct": "^0.1.27",
101
- "@bobfrankston/iflow-node": "^0.1.8",
102
99
  "@bobfrankston/miscinfo": "^1.0.10",
103
100
  "@bobfrankston/oauthsupport": "^1.0.25",
104
101
  "@bobfrankston/msger": "^0.1.369",