@bobfrankston/rmfmail 1.0.470 → 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.
- package/client/app.js +41 -37
- package/client/app.js.map +1 -1
- package/client/app.ts +39 -36
- package/client/components/folder-tree.js +5 -3
- package/client/components/folder-tree.js.map +1 -1
- package/client/components/folder-tree.ts +5 -3
- package/client/components/message-list.js +485 -448
- package/client/components/message-list.js.map +1 -1
- package/client/components/message-list.ts +474 -427
- package/client/components/message-viewer.js +36 -41
- package/client/components/message-viewer.js.map +1 -1
- package/client/components/message-viewer.ts +36 -38
- package/client/lib/message-state.js +46 -65
- package/client/lib/message-state.js.map +1 -1
- package/client/lib/message-state.ts +67 -74
- package/package.json +1 -1
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared message
|
|
2
|
+
* Shared message store — the messages currently rendered in the list.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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(
|
|
28
|
+
function notify() {
|
|
23
29
|
for (const fn of listeners) {
|
|
24
30
|
try {
|
|
25
|
-
fn(
|
|
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(
|
|
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
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
67
|
+
nextSurvivor = messages[i];
|
|
85
68
|
break;
|
|
86
69
|
}
|
|
87
70
|
}
|
|
88
|
-
if (!
|
|
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
|
-
|
|
74
|
+
nextSurvivor = messages[i];
|
|
93
75
|
break;
|
|
94
76
|
}
|
|
95
77
|
}
|
|
96
78
|
}
|
|
97
79
|
}
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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":"AAAA
|
|
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
|
|
2
|
+
* Shared message store — the messages currently rendered in the list.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
/**
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
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(
|
|
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
|
|
95
|
+
const focusedKey = currentlyFocused ? `${currentlyFocused.accountId}:${currentlyFocused.uid}` : null;
|
|
96
|
+
const focusedWasRemoved = focusedKey !== null && removeSet.has(focusedKey);
|
|
93
97
|
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
106
|
+
nextSurvivor = messages[i];
|
|
110
107
|
break;
|
|
111
108
|
}
|
|
112
109
|
}
|
|
113
|
-
if (!
|
|
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
|
-
|
|
113
|
+
nextSurvivor = messages[i];
|
|
118
114
|
break;
|
|
119
115
|
}
|
|
120
116
|
}
|
|
121
117
|
}
|
|
122
118
|
}
|
|
123
119
|
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
}
|