@bobfrankston/rmfmail 1.0.467

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.
Files changed (330) hide show
  1. package/README.md +429 -0
  2. package/bin/build-icon-ico.js +283 -0
  3. package/bin/lean-accounts.js +172 -0
  4. package/bin/mailx.js +1580 -0
  5. package/bin/mailx.js.map +1 -0
  6. package/bin/mailx.ts +1549 -0
  7. package/bin/postinstall.js +39 -0
  8. package/bin/postinstall.js.map +1 -0
  9. package/bin/postinstall.ts +41 -0
  10. package/bin/tsconfig.json +10 -0
  11. package/client/.gitattributes +10 -0
  12. package/client/.msger-window.json +1 -0
  13. package/client/android.html +226 -0
  14. package/client/app.js +3400 -0
  15. package/client/app.js.map +1 -0
  16. package/client/app.ts +3190 -0
  17. package/client/components/address-book.js +199 -0
  18. package/client/components/address-book.js.map +1 -0
  19. package/client/components/address-book.ts +204 -0
  20. package/client/components/alarms.js +299 -0
  21. package/client/components/alarms.js.map +1 -0
  22. package/client/components/alarms.ts +276 -0
  23. package/client/components/calendar-sidebar.js +508 -0
  24. package/client/components/calendar-sidebar.js.map +1 -0
  25. package/client/components/calendar-sidebar.ts +474 -0
  26. package/client/components/calendar.js +217 -0
  27. package/client/components/calendar.js.map +1 -0
  28. package/client/components/calendar.ts +211 -0
  29. package/client/components/context-menu.js +80 -0
  30. package/client/components/context-menu.js.map +1 -0
  31. package/client/components/context-menu.ts +95 -0
  32. package/client/components/folder-picker.js +119 -0
  33. package/client/components/folder-picker.js.map +1 -0
  34. package/client/components/folder-picker.ts +127 -0
  35. package/client/components/folder-tree.js +1093 -0
  36. package/client/components/folder-tree.js.map +1 -0
  37. package/client/components/folder-tree.ts +1069 -0
  38. package/client/components/message-list.js +1140 -0
  39. package/client/components/message-list.js.map +1 -0
  40. package/client/components/message-list.ts +1129 -0
  41. package/client/components/message-viewer.js +1291 -0
  42. package/client/components/message-viewer.js.map +1 -0
  43. package/client/components/message-viewer.ts +1257 -0
  44. package/client/components/outbox-view.js +104 -0
  45. package/client/components/outbox-view.js.map +1 -0
  46. package/client/components/outbox-view.ts +102 -0
  47. package/client/components/tasks.js +261 -0
  48. package/client/components/tasks.js.map +1 -0
  49. package/client/components/tasks.ts +234 -0
  50. package/client/compose/compose.css +560 -0
  51. package/client/compose/compose.html +52 -0
  52. package/client/compose/compose.js +1289 -0
  53. package/client/compose/compose.js.map +1 -0
  54. package/client/compose/compose.ts +1231 -0
  55. package/client/compose/editor.js +616 -0
  56. package/client/compose/editor.js.map +1 -0
  57. package/client/compose/editor.ts +599 -0
  58. package/client/compose/ghost-text.js +122 -0
  59. package/client/compose/ghost-text.js.map +1 -0
  60. package/client/compose/ghost-text.ts +140 -0
  61. package/client/favicon.svg +11 -0
  62. package/client/icon.ico +0 -0
  63. package/client/icon.png +0 -0
  64. package/client/index.html +230 -0
  65. package/client/lib/android-bootstrap.js +9 -0
  66. package/client/lib/android-bootstrap.js.map +1 -0
  67. package/client/lib/android-bootstrap.ts +9 -0
  68. package/client/lib/api-client.js +378 -0
  69. package/client/lib/api-client.js.map +1 -0
  70. package/client/lib/api-client.ts +439 -0
  71. package/client/lib/local-service.js +603 -0
  72. package/client/lib/local-service.js.map +1 -0
  73. package/client/lib/local-service.ts +646 -0
  74. package/client/lib/local-store.js +214 -0
  75. package/client/lib/local-store.js.map +1 -0
  76. package/client/lib/local-store.ts +283 -0
  77. package/client/lib/mailxapi.js +304 -0
  78. package/client/lib/message-state.js +132 -0
  79. package/client/lib/message-state.js.map +1 -0
  80. package/client/lib/message-state.ts +160 -0
  81. package/client/package.json +16 -0
  82. package/client/styles/components.css +2049 -0
  83. package/client/styles/layout.css +332 -0
  84. package/client/styles/variables.css +85 -0
  85. package/client/tsconfig.json +19 -0
  86. package/package.json +124 -0
  87. package/packages/mailx-api/.gitattributes +10 -0
  88. package/packages/mailx-api/index.d.ts +10 -0
  89. package/packages/mailx-api/index.d.ts.map +1 -0
  90. package/packages/mailx-api/index.js +322 -0
  91. package/packages/mailx-api/index.js.map +1 -0
  92. package/packages/mailx-api/index.ts +283 -0
  93. package/packages/mailx-api/package.json +27 -0
  94. package/packages/mailx-api/tsconfig.json +9 -0
  95. package/packages/mailx-api/tsconfig.tsbuildinfo +1 -0
  96. package/packages/mailx-compose/.gitattributes +10 -0
  97. package/packages/mailx-compose/index.d.ts +21 -0
  98. package/packages/mailx-compose/index.d.ts.map +1 -0
  99. package/packages/mailx-compose/index.js +61 -0
  100. package/packages/mailx-compose/index.js.map +1 -0
  101. package/packages/mailx-compose/index.ts +85 -0
  102. package/packages/mailx-compose/package.json +18 -0
  103. package/packages/mailx-compose/tsconfig.json +9 -0
  104. package/packages/mailx-core/index.d.ts +139 -0
  105. package/packages/mailx-core/index.d.ts.map +1 -0
  106. package/packages/mailx-core/index.js +403 -0
  107. package/packages/mailx-core/index.js.map +1 -0
  108. package/packages/mailx-core/index.ts +424 -0
  109. package/packages/mailx-core/ipc.d.ts +13 -0
  110. package/packages/mailx-core/ipc.d.ts.map +1 -0
  111. package/packages/mailx-core/ipc.js +56 -0
  112. package/packages/mailx-core/ipc.js.map +1 -0
  113. package/packages/mailx-core/ipc.ts +62 -0
  114. package/packages/mailx-core/package.json +18 -0
  115. package/packages/mailx-core/tsconfig.json +9 -0
  116. package/packages/mailx-host/.gitattributes +10 -0
  117. package/packages/mailx-host/index.d.ts +21 -0
  118. package/packages/mailx-host/index.d.ts.map +1 -0
  119. package/packages/mailx-host/index.js +29 -0
  120. package/packages/mailx-host/index.js.map +1 -0
  121. package/packages/mailx-host/index.ts +38 -0
  122. package/packages/mailx-host/package.json +23 -0
  123. package/packages/mailx-host/tsconfig.json +9 -0
  124. package/packages/mailx-host/types-shim.d.ts +14 -0
  125. package/packages/mailx-imap/.gitattributes +10 -0
  126. package/packages/mailx-imap/index.d.ts +442 -0
  127. package/packages/mailx-imap/index.d.ts.map +1 -0
  128. package/packages/mailx-imap/index.js +3684 -0
  129. package/packages/mailx-imap/index.js.map +1 -0
  130. package/packages/mailx-imap/index.ts +3652 -0
  131. package/packages/mailx-imap/package-lock.json +131 -0
  132. package/packages/mailx-imap/package.json +28 -0
  133. package/packages/mailx-imap/providers/gmail-api.d.ts +8 -0
  134. package/packages/mailx-imap/providers/gmail-api.d.ts.map +1 -0
  135. package/packages/mailx-imap/providers/gmail-api.js +8 -0
  136. package/packages/mailx-imap/providers/gmail-api.js.map +1 -0
  137. package/packages/mailx-imap/providers/gmail-api.ts +8 -0
  138. package/packages/mailx-imap/providers/outlook-api.ts +7 -0
  139. package/packages/mailx-imap/providers/types.d.ts +9 -0
  140. package/packages/mailx-imap/providers/types.d.ts.map +1 -0
  141. package/packages/mailx-imap/providers/types.js +9 -0
  142. package/packages/mailx-imap/providers/types.js.map +1 -0
  143. package/packages/mailx-imap/providers/types.ts +9 -0
  144. package/packages/mailx-imap/tsconfig.json +9 -0
  145. package/packages/mailx-imap/tsconfig.tsbuildinfo +1 -0
  146. package/packages/mailx-send/.gitattributes +10 -0
  147. package/packages/mailx-send/README.md +217 -0
  148. package/packages/mailx-send/cli-queue.d.ts +11 -0
  149. package/packages/mailx-send/cli-queue.d.ts.map +1 -0
  150. package/packages/mailx-send/cli-queue.js +59 -0
  151. package/packages/mailx-send/cli-queue.js.map +1 -0
  152. package/packages/mailx-send/cli-queue.ts +62 -0
  153. package/packages/mailx-send/cli-send.d.ts +17 -0
  154. package/packages/mailx-send/cli-send.d.ts.map +1 -0
  155. package/packages/mailx-send/cli-send.js +75 -0
  156. package/packages/mailx-send/cli-send.js.map +1 -0
  157. package/packages/mailx-send/cli-send.ts +83 -0
  158. package/packages/mailx-send/cli.d.ts +22 -0
  159. package/packages/mailx-send/cli.d.ts.map +1 -0
  160. package/packages/mailx-send/cli.js +115 -0
  161. package/packages/mailx-send/cli.js.map +1 -0
  162. package/packages/mailx-send/cli.ts +126 -0
  163. package/packages/mailx-send/index.d.ts +77 -0
  164. package/packages/mailx-send/index.d.ts.map +1 -0
  165. package/packages/mailx-send/index.js +264 -0
  166. package/packages/mailx-send/index.js.map +1 -0
  167. package/packages/mailx-send/index.ts +333 -0
  168. package/packages/mailx-send/mailsend/README.md +133 -0
  169. package/packages/mailx-send/mailsend/cli.d.ts +12 -0
  170. package/packages/mailx-send/mailsend/cli.d.ts.map +1 -0
  171. package/packages/mailx-send/mailsend/cli.js +70 -0
  172. package/packages/mailx-send/mailsend/cli.js.map +1 -0
  173. package/packages/mailx-send/mailsend/cli.ts +81 -0
  174. package/packages/mailx-send/mailsend/index.d.ts +77 -0
  175. package/packages/mailx-send/mailsend/index.d.ts.map +1 -0
  176. package/packages/mailx-send/mailsend/index.js +264 -0
  177. package/packages/mailx-send/mailsend/index.js.map +1 -0
  178. package/packages/mailx-send/mailsend/index.ts +333 -0
  179. package/packages/mailx-send/mailsend/package-lock.json +65 -0
  180. package/packages/mailx-send/mailsend/package.json +26 -0
  181. package/packages/mailx-send/mailsend/tsconfig.json +21 -0
  182. package/packages/mailx-send/package-lock.json +65 -0
  183. package/packages/mailx-send/package.json +37 -0
  184. package/packages/mailx-send/tsconfig.json +21 -0
  185. package/packages/mailx-server/.gitattributes +10 -0
  186. package/packages/mailx-server/index.d.ts +8 -0
  187. package/packages/mailx-server/index.d.ts.map +1 -0
  188. package/packages/mailx-server/index.js +397 -0
  189. package/packages/mailx-server/index.js.map +1 -0
  190. package/packages/mailx-server/index.ts +429 -0
  191. package/packages/mailx-server/package.json +28 -0
  192. package/packages/mailx-server/tsconfig.json +9 -0
  193. package/packages/mailx-server/tsconfig.tsbuildinfo +1 -0
  194. package/packages/mailx-service/google-sync.d.ts +96 -0
  195. package/packages/mailx-service/google-sync.d.ts.map +1 -0
  196. package/packages/mailx-service/google-sync.js +153 -0
  197. package/packages/mailx-service/google-sync.js.map +1 -0
  198. package/packages/mailx-service/google-sync.ts +238 -0
  199. package/packages/mailx-service/index.d.ts +366 -0
  200. package/packages/mailx-service/index.d.ts.map +1 -0
  201. package/packages/mailx-service/index.js +2589 -0
  202. package/packages/mailx-service/index.js.map +1 -0
  203. package/packages/mailx-service/index.ts +2512 -0
  204. package/packages/mailx-service/jsonrpc.d.ts +25 -0
  205. package/packages/mailx-service/jsonrpc.d.ts.map +1 -0
  206. package/packages/mailx-service/jsonrpc.js +238 -0
  207. package/packages/mailx-service/jsonrpc.js.map +1 -0
  208. package/packages/mailx-service/jsonrpc.ts +268 -0
  209. package/packages/mailx-service/package.json +22 -0
  210. package/packages/mailx-service/tsconfig.json +9 -0
  211. package/packages/mailx-settings/.gitattributes +10 -0
  212. package/packages/mailx-settings/cloud.d.ts +47 -0
  213. package/packages/mailx-settings/cloud.d.ts.map +1 -0
  214. package/packages/mailx-settings/cloud.js +408 -0
  215. package/packages/mailx-settings/cloud.js.map +1 -0
  216. package/packages/mailx-settings/cloud.ts +393 -0
  217. package/packages/mailx-settings/index.d.ts +140 -0
  218. package/packages/mailx-settings/index.d.ts.map +1 -0
  219. package/packages/mailx-settings/index.js +872 -0
  220. package/packages/mailx-settings/index.js.map +1 -0
  221. package/packages/mailx-settings/index.ts +908 -0
  222. package/packages/mailx-settings/package.json +20 -0
  223. package/packages/mailx-settings/tsconfig.json +9 -0
  224. package/packages/mailx-settings/tsconfig.tsbuildinfo +1 -0
  225. package/packages/mailx-store/.gitattributes +10 -0
  226. package/packages/mailx-store/db.d.ts +346 -0
  227. package/packages/mailx-store/db.d.ts.map +1 -0
  228. package/packages/mailx-store/db.js +1796 -0
  229. package/packages/mailx-store/db.js.map +1 -0
  230. package/packages/mailx-store/db.ts +2007 -0
  231. package/packages/mailx-store/file-store.d.ts +36 -0
  232. package/packages/mailx-store/file-store.d.ts.map +1 -0
  233. package/packages/mailx-store/file-store.js +80 -0
  234. package/packages/mailx-store/file-store.js.map +1 -0
  235. package/packages/mailx-store/file-store.ts +82 -0
  236. package/packages/mailx-store/index.d.ts +7 -0
  237. package/packages/mailx-store/index.d.ts.map +1 -0
  238. package/packages/mailx-store/index.js +7 -0
  239. package/packages/mailx-store/index.js.map +1 -0
  240. package/packages/mailx-store/index.ts +7 -0
  241. package/packages/mailx-store/package.json +19 -0
  242. package/packages/mailx-store/tsconfig.json +9 -0
  243. package/packages/mailx-store/tsconfig.tsbuildinfo +1 -0
  244. package/packages/mailx-store-web/android-bootstrap.d.ts +16 -0
  245. package/packages/mailx-store-web/android-bootstrap.d.ts.map +1 -0
  246. package/packages/mailx-store-web/android-bootstrap.js +1247 -0
  247. package/packages/mailx-store-web/android-bootstrap.js.map +1 -0
  248. package/packages/mailx-store-web/android-bootstrap.ts +1262 -0
  249. package/packages/mailx-store-web/db.d.ts +132 -0
  250. package/packages/mailx-store-web/db.d.ts.map +1 -0
  251. package/packages/mailx-store-web/db.js +642 -0
  252. package/packages/mailx-store-web/db.js.map +1 -0
  253. package/packages/mailx-store-web/db.ts +756 -0
  254. package/packages/mailx-store-web/gmail-api-web.d.ts +11 -0
  255. package/packages/mailx-store-web/gmail-api-web.d.ts.map +1 -0
  256. package/packages/mailx-store-web/gmail-api-web.js +11 -0
  257. package/packages/mailx-store-web/gmail-api-web.js.map +1 -0
  258. package/packages/mailx-store-web/gmail-api-web.ts +11 -0
  259. package/packages/mailx-store-web/imap-web-provider.d.ts +33 -0
  260. package/packages/mailx-store-web/imap-web-provider.d.ts.map +1 -0
  261. package/packages/mailx-store-web/imap-web-provider.js +140 -0
  262. package/packages/mailx-store-web/imap-web-provider.js.map +1 -0
  263. package/packages/mailx-store-web/imap-web-provider.ts +156 -0
  264. package/packages/mailx-store-web/index.d.ts +10 -0
  265. package/packages/mailx-store-web/index.d.ts.map +1 -0
  266. package/packages/mailx-store-web/index.js +10 -0
  267. package/packages/mailx-store-web/index.js.map +1 -0
  268. package/packages/mailx-store-web/index.ts +10 -0
  269. package/packages/mailx-store-web/main-thread-host.d.ts +15 -0
  270. package/packages/mailx-store-web/main-thread-host.d.ts.map +1 -0
  271. package/packages/mailx-store-web/main-thread-host.js +292 -0
  272. package/packages/mailx-store-web/main-thread-host.js.map +1 -0
  273. package/packages/mailx-store-web/main-thread-host.ts +322 -0
  274. package/packages/mailx-store-web/package.json +23 -0
  275. package/packages/mailx-store-web/provider-types.d.ts +7 -0
  276. package/packages/mailx-store-web/provider-types.d.ts.map +1 -0
  277. package/packages/mailx-store-web/provider-types.js +7 -0
  278. package/packages/mailx-store-web/provider-types.js.map +1 -0
  279. package/packages/mailx-store-web/provider-types.ts +7 -0
  280. package/packages/mailx-store-web/sql-wasm-esm.js +10 -0
  281. package/packages/mailx-store-web/sql.js.d.ts +29 -0
  282. package/packages/mailx-store-web/sync-manager.d.ts +68 -0
  283. package/packages/mailx-store-web/sync-manager.d.ts.map +1 -0
  284. package/packages/mailx-store-web/sync-manager.js +506 -0
  285. package/packages/mailx-store-web/sync-manager.js.map +1 -0
  286. package/packages/mailx-store-web/sync-manager.ts +508 -0
  287. package/packages/mailx-store-web/tsconfig.json +10 -0
  288. package/packages/mailx-store-web/web-jsonrpc.d.ts +20 -0
  289. package/packages/mailx-store-web/web-jsonrpc.d.ts.map +1 -0
  290. package/packages/mailx-store-web/web-jsonrpc.js +102 -0
  291. package/packages/mailx-store-web/web-jsonrpc.js.map +1 -0
  292. package/packages/mailx-store-web/web-jsonrpc.ts +116 -0
  293. package/packages/mailx-store-web/web-message-store.d.ts +16 -0
  294. package/packages/mailx-store-web/web-message-store.d.ts.map +1 -0
  295. package/packages/mailx-store-web/web-message-store.js +89 -0
  296. package/packages/mailx-store-web/web-message-store.js.map +1 -0
  297. package/packages/mailx-store-web/web-message-store.ts +97 -0
  298. package/packages/mailx-store-web/web-service.d.ts +111 -0
  299. package/packages/mailx-store-web/web-service.d.ts.map +1 -0
  300. package/packages/mailx-store-web/web-service.js +542 -0
  301. package/packages/mailx-store-web/web-service.js.map +1 -0
  302. package/packages/mailx-store-web/web-service.ts +616 -0
  303. package/packages/mailx-store-web/web-settings.d.ts +83 -0
  304. package/packages/mailx-store-web/web-settings.d.ts.map +1 -0
  305. package/packages/mailx-store-web/web-settings.js +491 -0
  306. package/packages/mailx-store-web/web-settings.js.map +1 -0
  307. package/packages/mailx-store-web/web-settings.ts +522 -0
  308. package/packages/mailx-store-web/worker-bundle.js +6838 -0
  309. package/packages/mailx-store-web/worker-entry.d.ts +8 -0
  310. package/packages/mailx-store-web/worker-entry.d.ts.map +1 -0
  311. package/packages/mailx-store-web/worker-entry.js +187 -0
  312. package/packages/mailx-store-web/worker-entry.js.map +1 -0
  313. package/packages/mailx-store-web/worker-entry.ts +215 -0
  314. package/packages/mailx-store-web/worker-tcp-transport.d.ts +28 -0
  315. package/packages/mailx-store-web/worker-tcp-transport.d.ts.map +1 -0
  316. package/packages/mailx-store-web/worker-tcp-transport.js +98 -0
  317. package/packages/mailx-store-web/worker-tcp-transport.js.map +1 -0
  318. package/packages/mailx-store-web/worker-tcp-transport.ts +101 -0
  319. package/packages/mailx-types/.gitattributes +10 -0
  320. package/packages/mailx-types/index.d.ts +319 -0
  321. package/packages/mailx-types/index.d.ts.map +1 -0
  322. package/packages/mailx-types/index.js +243 -0
  323. package/packages/mailx-types/index.js.map +1 -0
  324. package/packages/mailx-types/index.ts +498 -0
  325. package/packages/mailx-types/package.json +16 -0
  326. package/packages/mailx-types/tsconfig.json +9 -0
  327. package/packages/mailx-types/tsconfig.tsbuildinfo +1 -0
  328. package/todo.json +13 -0
  329. package/tsconfig.base.json +31 -0
  330. package/tsconfig.json +9 -0
@@ -0,0 +1,1140 @@
1
+ /**
2
+ * Message list component — renders paginated message rows.
3
+ * Reads from message-state; operations mutate state, list reacts.
4
+ */
5
+ import { getMessages as apiGetMessages, getUnifiedInbox as apiGetUnifiedInbox, searchMessages, updateFlags, getThreadMessages, moveMessages as apiMoveMessages } from "../lib/api-client.js";
6
+ import * as state from "../lib/message-state.js";
7
+ import { showContextMenu } from "./context-menu.js";
8
+ import { pickFolder } from "./folder-picker.js";
9
+ let onMessageSelect;
10
+ let currentAccountId;
11
+ let currentFolderId;
12
+ let currentSpecialUse = ""; // Cached for reloads — an empty value on reload used to reset Sent/Drafts/Outbox to "From"
13
+ let lastClickedRow = null;
14
+ let currentPage;
15
+ let totalMessages;
16
+ let loading = false;
17
+ let unifiedMode = false;
18
+ let searchMode = false;
19
+ let currentSearchQuery = "";
20
+ let showToInsteadOfFrom = false;
21
+ let touchWasScroll = false;
22
+ // Current sort column/direction — cycled by clicking the ml-header columns.
23
+ // "date desc" is the default (newest first). Clicking a column flips direction
24
+ // if it's already active, or switches to that column with its own default dir
25
+ // (text columns default asc, date defaults desc).
26
+ let currentSort = "date";
27
+ let currentSortDir = "desc";
28
+ /** S56 refactor slice — per-row focus/unfocus.
29
+ *
30
+ * Every rendered message row has an associated Row record tracking the DOM
31
+ * element + account/msg pair. `focusRow` runs the atomic transition:
32
+ *
33
+ * 1. unfocus the previously-focused row (clears `.selected` class)
34
+ * 2. mark the new row `.selected`
35
+ * 3. update shared state.selected + notify viewer
36
+ *
37
+ * The viewer's `showMessageGeneration` token still cancels stale body
38
+ * fetches during the transition — that's the "async cancellation" piece of
39
+ * S56. The "atomic DOM transition" piece is now owned here.
40
+ *
41
+ * Full abort-signal plumbing through getMessage → fetchMessageBody is a
42
+ * separate follow-up; for now the gen token does the job. */
43
+ let currentFocusedRow = null;
44
+ function focusRow(row, accountId, msg) {
45
+ if (currentFocusedRow && currentFocusedRow !== row) {
46
+ // Unfocus the previous atomically — clearing .selected AND
47
+ // triggering the viewer's stale-fetch cancel (via the bump inside
48
+ // the next showMessage() call).
49
+ currentFocusedRow.classList.remove("selected");
50
+ }
51
+ if (!row.classList.contains("selected"))
52
+ row.classList.add("selected");
53
+ currentFocusedRow = row;
54
+ state.select(msg);
55
+ onMessageSelect(accountId, msg.uid, msg.folderId);
56
+ }
57
+ /** Back-compat shim — some call sites still use `focusMessage(accountId, msg)`
58
+ * without a DOM row (e.g. thread-popup click). Fall back to the previous
59
+ * behavior (state + viewer only) so nothing regresses. */
60
+ function focusMessage(accountId, msg) {
61
+ state.select(msg);
62
+ onMessageSelect(accountId, msg.uid, msg.folderId);
63
+ // Clear the focused-row reference since we don't have a DOM row here
64
+ // — the next row click will unfocus whatever was selected anyway.
65
+ if (currentFocusedRow)
66
+ currentFocusedRow.classList.remove("selected");
67
+ currentFocusedRow = null;
68
+ }
69
+ /** Flip the "not-downloaded" indicator off for rows whose bodies just cached.
70
+ * Called from the bodyCached service event — covers both background prefetch
71
+ * and on-demand fetch. No-op for rows not currently rendered. */
72
+ export function markBodiesCached(items) {
73
+ const body = document.getElementById("ml-body");
74
+ if (!body || items.length === 0)
75
+ return;
76
+ for (const { accountId, uid } of items) {
77
+ const row = body.querySelector(`.ml-row[data-uid="${uid}"][data-account-id="${CSS.escape(accountId)}"]`)
78
+ || body.querySelector(`.ml-row[data-uid="${uid}"]`);
79
+ if (row)
80
+ row.classList.remove("not-downloaded");
81
+ }
82
+ }
83
+ /** Get all selected message rows */
84
+ export function getSelectedMessages() {
85
+ const body = document.getElementById("ml-body");
86
+ if (!body)
87
+ return [];
88
+ const rows = body.querySelectorAll(".ml-row.selected");
89
+ return Array.from(rows).map(r => ({
90
+ accountId: r.dataset.accountId || "",
91
+ uid: Number(r.dataset.uid),
92
+ folderId: Number(r.dataset.folderId),
93
+ }));
94
+ }
95
+ function clearSelection() {
96
+ const body = document.getElementById("ml-body");
97
+ if (body)
98
+ body.querySelectorAll(".ml-row.selected").forEach(r => r.classList.remove("selected"));
99
+ // S56 seam: the focused-row invariant is "the Row whose .selected is
100
+ // currently mine". clearSelection wipes all .selected, so the invariant
101
+ // would break if we kept a stale pointer.
102
+ currentFocusedRow = null;
103
+ }
104
+ /** Deterministic sender-avatar color from a seed string (typically the
105
+ * email address). Hash → hue at 12 evenly-spaced positions on the wheel.
106
+ * Saturation + lightness fixed so all colors carry the same visual weight
107
+ * regardless of hue, and so light/dark themes don't have to override. */
108
+ function senderColor(seed) {
109
+ let h = 0;
110
+ for (let i = 0; i < seed.length; i++)
111
+ h = (h * 31 + seed.charCodeAt(i)) | 0;
112
+ const hue = ((Math.abs(h) % 12) * 30) + 15; // 15, 45, 75, …, 345
113
+ return `oklch(0.62 0.14 ${hue})`;
114
+ }
115
+ /** Exit multi-select mode (entered via touch long-press). Clears selection
116
+ * and the sticky body flag so subsequent taps open messages again. */
117
+ function exitMultiSelect() {
118
+ const body = document.getElementById("ml-body");
119
+ if (!body?.classList.contains("multi-select-on"))
120
+ return;
121
+ body.classList.remove("multi-select-on");
122
+ clearSelection();
123
+ updateBulkBar();
124
+ }
125
+ /** Bulk-actions bar retired 2026-04-24 — trash + spam live on the main
126
+ * toolbar now and every other bulk op (mark-read, flag, move) is one
127
+ * right-click away. Kept as a no-op stub so existing call sites
128
+ * (avatar tap, row click, long-press) don't need to be touched. */
129
+ function updateBulkBar() { }
130
+ // Escape key + click-outside-list exit multi-select mode. Attached once
131
+ // (idempotent because document only has one listener scope per handler).
132
+ if (!window.__mailxMultiSelectWired) {
133
+ window.__mailxMultiSelectWired = true;
134
+ document.addEventListener("keydown", (e) => {
135
+ if (e.key === "Escape")
136
+ exitMultiSelect();
137
+ });
138
+ document.addEventListener("pointerdown", (e) => {
139
+ const body = document.getElementById("ml-body");
140
+ if (!body?.classList.contains("multi-select-on"))
141
+ return;
142
+ const target = e.target;
143
+ // A tap on a row is handled by the row's own click listener.
144
+ // The toolbar must also be exempt: its trash / spam / etc.
145
+ // buttons operate ON the current multi-selection, so a tap on
146
+ // them should NOT clear selection before the button's click
147
+ // handler runs (otherwise getSelectedMessages returns empty
148
+ // and the action no-ops — Android-reported 2026-04-30: "press
149
+ // multiple circles, press trashcan, checks vanish, nothing
150
+ // deleted"). Same logic for the folder-tree (drop targets,
151
+ // future: bulk move). Exit only on a tap to genuine neutral
152
+ // ground.
153
+ // Exempt: rows (handled by their own listener), toolbar buttons
154
+ // (delete/spam/etc. operate ON the selection — clearing it here
155
+ // empties the selection before the click runs), folder-tree
156
+ // (drop targets / future bulk move), and the context menu
157
+ // (right-click → "mark read" / "move to" / etc. all need the
158
+ // selection intact when the menu item runs).
159
+ if (target.closest(".ml-row, .toolbar, .folder-tree, .ctx-menu, #btn-tb-delete, #btn-tb-spam"))
160
+ return;
161
+ exitMultiSelect();
162
+ }, true);
163
+ }
164
+ function selectRange(from, to) {
165
+ const body = document.getElementById("ml-body");
166
+ if (!body)
167
+ return;
168
+ const rows = Array.from(body.querySelectorAll(".ml-row"));
169
+ const fromIdx = rows.indexOf(from);
170
+ const toIdx = rows.indexOf(to);
171
+ if (fromIdx < 0 || toIdx < 0)
172
+ return;
173
+ const lo = Math.min(fromIdx, toIdx);
174
+ const hi = Math.max(fromIdx, toIdx);
175
+ for (let i = lo; i <= hi; i++)
176
+ rows[i].classList.add("selected");
177
+ }
178
+ /** The row to anchor a shift-click range against. `lastClickedRow` is the
179
+ * primary anchor, but it can become a detached DOM node after a list
180
+ * re-render (folder switch, sort, search reload, paging) — `selectRange`
181
+ * would then no-op. Fall back to whichever live row is `.selected` (the
182
+ * one in the viewer) before giving up. */
183
+ function resolveShiftAnchor() {
184
+ if (lastClickedRow?.isConnected)
185
+ return lastClickedRow;
186
+ const body = document.getElementById("ml-body");
187
+ if (!body)
188
+ return null;
189
+ return body.querySelector(".ml-row.selected");
190
+ }
191
+ const timeFmt = { hour: "2-digit", minute: "2-digit", hour12: false };
192
+ const dateFmt = { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false };
193
+ const dateFmtSameYear = { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false };
194
+ export function initMessageList(handler) {
195
+ onMessageSelect = handler;
196
+ // Infinite scroll
197
+ const body = document.getElementById("ml-body");
198
+ if (body) {
199
+ // Touch scroll vs tap: the WebView occasionally synthesizes a click on
200
+ // touchend even when the user clearly scrolled, which opened a message
201
+ // just from swiping the list. Multi-signal detection so a scroll is
202
+ // reliably classified:
203
+ // 1. touchmove movement ≥ TAP_SLOP — the primary signal
204
+ // 2. actual scrollTop change between touchstart and touchend — always
205
+ // set the flag when the container moved, even if touchmove never
206
+ // fired (some Android builds coalesce events under momentum)
207
+ // 3. longer TAP_SLOP (15px) — fingers are wide; 10px was too twitchy
208
+ let touchStartY = 0;
209
+ let touchStartX = 0;
210
+ let touchStartScrollTop = 0;
211
+ const TAP_SLOP = 15;
212
+ body.addEventListener("touchstart", (e) => {
213
+ const t = e.touches[0];
214
+ touchStartY = t.clientY;
215
+ touchStartX = t.clientX;
216
+ touchStartScrollTop = body.scrollTop;
217
+ touchWasScroll = false;
218
+ }, { passive: true });
219
+ body.addEventListener("touchmove", (e) => {
220
+ const t = e.touches[0];
221
+ if (Math.abs(t.clientY - touchStartY) > TAP_SLOP || Math.abs(t.clientX - touchStartX) > TAP_SLOP) {
222
+ touchWasScroll = true;
223
+ }
224
+ }, { passive: true });
225
+ body.addEventListener("touchend", () => {
226
+ // If the container actually scrolled during this touch, the user
227
+ // was scrolling regardless of how small their finger movement was.
228
+ if (body.scrollTop !== touchStartScrollTop) {
229
+ touchWasScroll = true;
230
+ }
231
+ }, { passive: true });
232
+ body.addEventListener("scroll", () => {
233
+ if (loading)
234
+ return;
235
+ const nearBottom = body.scrollHeight - body.scrollTop - body.clientHeight < 200;
236
+ if (nearBottom && currentPage * 50 < totalMessages) {
237
+ loadMoreMessages();
238
+ }
239
+ });
240
+ }
241
+ // Subscribe to state changes — react to removeMessages (move/delete)
242
+ state.subscribe((change) => {
243
+ if (change === "removed") {
244
+ syncDomToState();
245
+ }
246
+ });
247
+ // Sort column headers — click to cycle. Date defaults desc (newest first);
248
+ // From/Subject default asc on first click so alphabetical order reads
249
+ // naturally. Clicking the currently-active column flips direction.
250
+ const header = document.getElementById("ml-header");
251
+ if (header) {
252
+ header.addEventListener("click", (e) => {
253
+ const col = e.target.closest(".ml-col-sortable");
254
+ if (!col)
255
+ return;
256
+ const key = col.dataset.sort;
257
+ if (!key)
258
+ return;
259
+ if (currentSort === key) {
260
+ currentSortDir = currentSortDir === "asc" ? "desc" : "asc";
261
+ }
262
+ else {
263
+ currentSort = key;
264
+ currentSortDir = key === "date" ? "desc" : "asc";
265
+ }
266
+ // Only per-folder lists support server-side sort today; unified and
267
+ // search paths sort client-side on the fetched page. Reload if we
268
+ // have an active per-folder context.
269
+ if (!searchMode && !unifiedMode && currentAccountId && currentFolderId) {
270
+ loadMessages(currentAccountId, currentFolderId, 1, "", false);
271
+ }
272
+ else {
273
+ applyClientSideSort();
274
+ updateSortIndicators();
275
+ }
276
+ });
277
+ }
278
+ updateSortIndicators();
279
+ }
280
+ /** Reorder currently-loaded state messages in-place by currentSort/currentSortDir.
281
+ * Used for unified-inbox and search results where the server can't re-sort
282
+ * a single page on our behalf. */
283
+ function applyClientSideSort() {
284
+ const items = [...state.getMessages()];
285
+ const sign = currentSortDir === "asc" ? 1 : -1;
286
+ items.sort((a, b) => {
287
+ if (currentSort === "from") {
288
+ const av = (a.from?.name || a.from?.address || "").toLowerCase();
289
+ const bv = (b.from?.name || b.from?.address || "").toLowerCase();
290
+ return av < bv ? -sign : av > bv ? sign : 0;
291
+ }
292
+ if (currentSort === "subject") {
293
+ const av = (a.subject || "").replace(/^(re:|fwd:|fw:)\s*/i, "").toLowerCase();
294
+ const bv = (b.subject || "").replace(/^(re:|fwd:|fw:)\s*/i, "").toLowerCase();
295
+ return av < bv ? -sign : av > bv ? sign : 0;
296
+ }
297
+ // date
298
+ return ((a.date || 0) - (b.date || 0)) * sign;
299
+ });
300
+ state.setMessages(items);
301
+ }
302
+ function updateSortIndicators() {
303
+ const header = document.getElementById("ml-header");
304
+ if (!header)
305
+ return;
306
+ header.querySelectorAll(".ml-col-sortable").forEach(c => {
307
+ c.classList.remove("ml-col-sort-asc", "ml-col-sort-desc");
308
+ if (c.dataset.sort === currentSort) {
309
+ c.classList.add(currentSortDir === "asc" ? "ml-col-sort-asc" : "ml-col-sort-desc");
310
+ }
311
+ });
312
+ }
313
+ /**
314
+ * Sync DOM rows to current state after messages are removed.
315
+ * Removes DOM rows that are no longer in state, updates selection.
316
+ */
317
+ function syncDomToState() {
318
+ const body = document.getElementById("ml-body");
319
+ if (!body)
320
+ return;
321
+ // Build set of UIDs still in state
322
+ const stateUids = new Set(state.getMessages().map(m => `${m.accountId}:${m.uid}`));
323
+ // Remove rows not in state
324
+ for (const row of Array.from(body.querySelectorAll(".ml-row"))) {
325
+ const el = row;
326
+ const key = `${el.dataset.accountId}:${el.dataset.uid}`;
327
+ if (!stateUids.has(key)) {
328
+ el.remove();
329
+ }
330
+ }
331
+ // Update selection to match state
332
+ clearSelection();
333
+ const sel = state.getSelected();
334
+ if (sel) {
335
+ const row = body.querySelector(`.ml-row[data-uid="${sel.uid}"][data-account-id="${sel.accountId}"]`)
336
+ || body.querySelector(`.ml-row[data-uid="${sel.uid}"]`);
337
+ if (row) {
338
+ row.classList.add("selected");
339
+ lastClickedRow = row;
340
+ // Trigger viewer update
341
+ onMessageSelect(sel.accountId, sel.uid, sel.folderId);
342
+ }
343
+ }
344
+ // If no messages left, show empty
345
+ if (state.getMessages().length === 0) {
346
+ body.innerHTML = `<div class="ml-empty">No messages</div>`;
347
+ }
348
+ }
349
+ /** Reload the currently displayed folder (preserves current selection) */
350
+ export function reloadCurrentFolder() {
351
+ if (searchMode) {
352
+ loadSearchResults(currentSearchQuery);
353
+ }
354
+ else if (unifiedMode) {
355
+ loadUnifiedInbox(false);
356
+ }
357
+ else if (currentAccountId && currentFolderId) {
358
+ loadMessages(currentAccountId, currentFolderId, 1, "", false);
359
+ }
360
+ }
361
+ /** Exit search mode without triggering a reload — caller decides what to load
362
+ * next. Used by the search-input "cleared to empty" handler so the next
363
+ * reloadCurrentFolder() call doesn't re-run the stale search query. */
364
+ export function clearSearchMode() {
365
+ searchMode = false;
366
+ currentSearchQuery = "";
367
+ }
368
+ /** Load unified inbox (all accounts) */
369
+ export async function loadUnifiedInbox(autoSelect = true) {
370
+ unifiedMode = true;
371
+ searchMode = false;
372
+ currentSpecialUse = "";
373
+ showToInsteadOfFrom = false; // Unified inbox always shows From, not To
374
+ currentPage = 1;
375
+ totalMessages = 0;
376
+ const body = document.getElementById("ml-body");
377
+ if (!body)
378
+ return;
379
+ const fromHeader = document.querySelector(".ml-col-from");
380
+ if (fromHeader)
381
+ fromHeader.textContent = "From";
382
+ const savedScroll = !autoSelect ? body.scrollTop : 0;
383
+ const savedUid = !autoSelect ? body.querySelector(".ml-row.selected")?.getAttribute("data-uid") : null;
384
+ if (autoSelect) {
385
+ body.innerHTML = `<div class="ml-empty">Loading...</div>`;
386
+ }
387
+ try {
388
+ const result = await apiGetUnifiedInbox(1);
389
+ totalMessages = result.total;
390
+ if (result.items.length === 0) {
391
+ state.setMessages([]);
392
+ body.innerHTML = `<div class="ml-empty">${result.total > 0 ? `${result.total} messages syncing...` : "Syncing — messages will appear shortly"}</div>`;
393
+ return;
394
+ }
395
+ state.setMessages(result.items);
396
+ renderMessages(body, "", result.items);
397
+ if (autoSelect) {
398
+ selectFirst(body);
399
+ }
400
+ else {
401
+ body.scrollTop = savedScroll;
402
+ restoreSelection(body, savedUid);
403
+ }
404
+ }
405
+ catch (e) {
406
+ if (e.name === "AbortError")
407
+ return;
408
+ body.innerHTML = `<div class="ml-empty">Error: ${e.message}</div>`;
409
+ }
410
+ }
411
+ /** Load search results */
412
+ export async function loadSearchResults(query, scope = "all", accountId = "", folderId = 0) {
413
+ searchMode = true;
414
+ unifiedMode = false;
415
+ currentSearchQuery = query;
416
+ currentPage = 1;
417
+ totalMessages = 0;
418
+ const body = document.getElementById("ml-body");
419
+ if (!body)
420
+ return;
421
+ // Clear the preview pane — old preview from the prior selection lingers
422
+ // until the user clicks a search result. Atomic clear-on-list-mutation
423
+ // is what Slice D's row-objects-own-preview will do generally; this is
424
+ // the band-aid for the search-specific case until that lands.
425
+ document.dispatchEvent(new CustomEvent("mailx-clear-viewer"));
426
+ body.innerHTML = `<div class="ml-empty">Searching...</div>`;
427
+ try {
428
+ // Regex search: filter client-side
429
+ if (query.startsWith("/") && query.endsWith("/") && query.length > 2) {
430
+ const pattern = query.slice(1, -1);
431
+ let regex;
432
+ try {
433
+ regex = new RegExp(pattern, "i");
434
+ }
435
+ catch {
436
+ body.innerHTML = `<div class="ml-empty">Invalid regex</div>`;
437
+ return;
438
+ }
439
+ const source = scope === "current" && accountId
440
+ ? await apiGetMessages(accountId, folderId, 1, 10000)
441
+ : await apiGetUnifiedInbox(1, 10000);
442
+ const matches = source.items.filter((m) => regex.test(m.subject || "") || regex.test(m.from?.name || "") || regex.test(m.from?.address || "") || regex.test(m.preview || ""));
443
+ totalMessages = matches.length;
444
+ state.setMessages(matches);
445
+ if (matches.length === 0) {
446
+ body.innerHTML = `<div class="ml-empty">No regex matches</div>`;
447
+ return;
448
+ }
449
+ body.innerHTML = "";
450
+ appendMessages(body, "", matches);
451
+ selectFirst(body);
452
+ return;
453
+ }
454
+ const result = await searchMessages(query, 1, 50, scope, accountId, folderId);
455
+ totalMessages = result.total;
456
+ if (result.items.length === 0) {
457
+ state.setMessages([]);
458
+ body.innerHTML = `<div class="ml-empty">No results for "${query}"</div>`;
459
+ return;
460
+ }
461
+ state.setMessages(result.items);
462
+ body.innerHTML = "";
463
+ appendMessages(body, "", result.items);
464
+ }
465
+ catch (e) {
466
+ body.innerHTML = `<div class="ml-empty">Search error: ${e.message}</div>`;
467
+ }
468
+ }
469
+ export async function loadMessages(accountId, folderId, page = 1, specialUse = "", autoSelect = true) {
470
+ searchMode = false;
471
+ unifiedMode = false;
472
+ // Folder switch clears any in-progress multi-select — carrying a "3
473
+ // selected" state across folders would lie about what rows the bulk
474
+ // buttons would act on.
475
+ exitMultiSelect();
476
+ // specialUse is either the DB tag ("sent"/"drafts"/"outbox") or the
477
+ // folder path lowercased (folder-tree fallback when tag is missing — common
478
+ // on Dovecot which doesn't advertise \Sent). Match both cases.
479
+ // Empty specialUse on reload means "keep what we had" — otherwise a
480
+ // folderCountsChanged event or sort-header click resets Sent/Drafts/Outbox
481
+ // back to showing From (user-reported regression 2026-04-24).
482
+ if (specialUse)
483
+ currentSpecialUse = specialUse;
484
+ const su = currentSpecialUse.toLowerCase();
485
+ showToInsteadOfFrom = su === "sent" || su === "drafts" || su === "outbox"
486
+ || su.endsWith("sent") || su.endsWith("drafts") || su.endsWith("outbox")
487
+ || su === "sent items" || su === "sent mail" || su.endsWith("/sent items") || su.endsWith(".sent items");
488
+ currentAccountId = accountId;
489
+ currentFolderId = folderId;
490
+ currentPage = 1;
491
+ totalMessages = 0;
492
+ const body = document.getElementById("ml-body");
493
+ if (!body)
494
+ return;
495
+ // Update header label
496
+ const fromHeader = document.querySelector(".ml-col-from");
497
+ if (fromHeader)
498
+ fromHeader.textContent = showToInsteadOfFrom ? "To" : "From";
499
+ const savedScroll = !autoSelect ? body.scrollTop : 0;
500
+ const savedUid = !autoSelect ? body.querySelector(".ml-row.selected")?.getAttribute("data-uid") : null;
501
+ if (autoSelect) {
502
+ body.innerHTML = `<div class="ml-empty">Loading...</div>`;
503
+ }
504
+ try {
505
+ const flaggedOnly = document.getElementById("ml-body")?.classList.contains("flagged-only") || false;
506
+ const result = await apiGetMessages(accountId, folderId, 1, 50, flaggedOnly, currentSort, currentSortDir);
507
+ totalMessages = result.total;
508
+ updateSortIndicators();
509
+ if (result.items.length === 0) {
510
+ state.setMessages([]);
511
+ body.innerHTML = `<div class="ml-empty">${flaggedOnly ? "No flagged messages" : "No messages"}</div>`;
512
+ return;
513
+ }
514
+ state.setMessages(result.items);
515
+ renderMessages(body, accountId, result.items);
516
+ if (autoSelect) {
517
+ selectFirst(body);
518
+ }
519
+ else {
520
+ requestAnimationFrame(() => {
521
+ body.scrollTop = savedScroll;
522
+ restoreSelection(body, savedUid);
523
+ });
524
+ }
525
+ }
526
+ catch (e) {
527
+ if (e.name === "AbortError")
528
+ return;
529
+ body.innerHTML = `<div class="ml-empty">Error: ${e.message}</div>`;
530
+ }
531
+ }
532
+ async function loadMoreMessages() {
533
+ const body = document.getElementById("ml-body");
534
+ if (!body)
535
+ return;
536
+ loading = true;
537
+ currentPage++;
538
+ try {
539
+ const flaggedOnly = body.classList.contains("flagged-only");
540
+ const result = searchMode
541
+ ? await searchMessages(currentSearchQuery, currentPage)
542
+ : unifiedMode
543
+ ? await apiGetUnifiedInbox(currentPage)
544
+ : await apiGetMessages(currentAccountId, currentFolderId, currentPage, 50, flaggedOnly);
545
+ // Append to state
546
+ const current = state.getMessages();
547
+ state.setMessages([...current, ...result.items]);
548
+ appendMessages(body, unifiedMode ? "" : currentAccountId, result.items);
549
+ }
550
+ catch (e) {
551
+ console.error(`Load more error: ${e.message}`);
552
+ }
553
+ finally {
554
+ loading = false;
555
+ }
556
+ }
557
+ /** Replace body contents with rendered rows */
558
+ function renderMessages(body, accountId, items) {
559
+ const fragment = document.createDocumentFragment();
560
+ const tempDiv = document.createElement("div");
561
+ appendMessages(tempDiv, accountId, items);
562
+ while (tempDiv.firstChild)
563
+ fragment.appendChild(tempDiv.firstChild);
564
+ body.replaceChildren(fragment);
565
+ }
566
+ function selectFirst(body) {
567
+ // Narrow viewports (Android, phone-sized): don't auto-select. The
568
+ // click handler in app.ts switches the layout to "narrow-active" on
569
+ // any list-row click, which on a phone means the message viewer takes
570
+ // over the screen and hides the list. Auto-selecting at startup
571
+ // therefore lands the user in the LAST letter they read instead of
572
+ // the inbox summary they wanted. Desktop unchanged — auto-select
573
+ // remains useful when the list and viewer are side-by-side.
574
+ if (window.innerWidth <= 768)
575
+ return;
576
+ const firstRow = body.querySelector(".ml-row");
577
+ if (firstRow)
578
+ firstRow.click();
579
+ }
580
+ function restoreSelection(body, savedUid) {
581
+ if (savedUid) {
582
+ const row = body.querySelector(`.ml-row[data-uid="${savedUid}"]`);
583
+ if (row)
584
+ row.classList.add("selected");
585
+ }
586
+ }
587
+ /** Show a floating list of all messages in a thread when the pill is clicked.
588
+ * Each entry in the popup selects that message in the viewer when clicked.
589
+ * This is simpler than inline expansion and avoids duplicating the row builder. */
590
+ export async function showThreadPopup(pillEl, headMsg) {
591
+ // Remove any existing popup
592
+ document.querySelectorAll(".ml-thread-popup").forEach(el => el.remove());
593
+ let thread = [];
594
+ try {
595
+ thread = await getThreadMessages(headMsg.accountId, headMsg.threadId);
596
+ }
597
+ catch { /* ignore */ }
598
+ if (!thread || thread.length === 0)
599
+ return;
600
+ thread.sort((a, b) => (a.date || 0) - (b.date || 0));
601
+ const popup = document.createElement("div");
602
+ popup.className = "ml-thread-popup";
603
+ for (const msg of thread) {
604
+ const item = document.createElement("div");
605
+ item.className = "ml-thread-popup-item";
606
+ if (!msg.flags.includes("\\Seen"))
607
+ item.classList.add("unread");
608
+ const from = document.createElement("span");
609
+ from.className = "ml-thread-popup-from";
610
+ from.textContent = msg.from?.name || msg.from?.address || "?";
611
+ const date = document.createElement("span");
612
+ date.className = "ml-thread-popup-date";
613
+ date.textContent = formatDate(msg.date);
614
+ const subject = document.createElement("span");
615
+ subject.className = "ml-thread-popup-subject";
616
+ subject.textContent = msg.subject || "(no subject)";
617
+ item.appendChild(from);
618
+ item.appendChild(date);
619
+ item.appendChild(subject);
620
+ item.addEventListener("click", async () => {
621
+ focusMessage(msg.accountId, { accountId: msg.accountId, uid: msg.uid, folderId: msg.folderId, subject: msg.subject, from: msg.from, to: msg.to, cc: msg.cc, date: msg.date, flags: msg.flags, size: msg.size, preview: msg.preview, hasAttachments: msg.hasAttachments });
622
+ popup.remove();
623
+ });
624
+ popup.appendChild(item);
625
+ }
626
+ document.body.appendChild(popup);
627
+ const rect = pillEl.getBoundingClientRect();
628
+ popup.style.left = `${rect.left}px`;
629
+ popup.style.top = `${rect.bottom + 4}px`;
630
+ // Dismiss on outside click
631
+ setTimeout(() => {
632
+ const dismiss = (e) => {
633
+ if (!popup.contains(e.target)) {
634
+ popup.remove();
635
+ document.removeEventListener("mousedown", dismiss, true);
636
+ }
637
+ };
638
+ document.addEventListener("mousedown", dismiss, true);
639
+ }, 0);
640
+ }
641
+ function appendMessages(body, accountId, items) {
642
+ // Thread grouping: when the list has the "threaded" class, collapse messages
643
+ // sharing the same threadId to a single row showing the most recent message,
644
+ // with a small pill indicating the thread size. Pre-threading messages have
645
+ // no threadId — those are treated as singletons keyed by their own uid.
646
+ const threaded = body.classList.contains("threaded");
647
+ let rowsToRender = items;
648
+ let threadSize = null;
649
+ if (threaded) {
650
+ const threadMap = new Map(); // threadId → newest msg
651
+ threadSize = new Map();
652
+ for (const msg of items) {
653
+ const key = msg.threadId || `_msg_${msg.accountId || accountId}_${msg.uid}`;
654
+ const existing = threadMap.get(key);
655
+ if (!existing || (msg.date || 0) > (existing.date || 0)) {
656
+ threadMap.set(key, msg);
657
+ }
658
+ }
659
+ // Count messages per thread
660
+ for (const msg of items) {
661
+ const key = msg.threadId || `_msg_${msg.accountId || accountId}_${msg.uid}`;
662
+ const head = threadMap.get(key);
663
+ if (head)
664
+ threadSize.set(head, (threadSize.get(head) || 0) + 1);
665
+ }
666
+ rowsToRender = Array.from(threadMap.values()).sort((a, b) => (b.date || 0) - (a.date || 0));
667
+ }
668
+ for (const msg of rowsToRender) {
669
+ const msgAccountId = msg.accountId || accountId;
670
+ const row = document.createElement("div");
671
+ row.className = "ml-row";
672
+ row.draggable = true;
673
+ if (!msg.flags.includes("\\Seen"))
674
+ row.classList.add("unread");
675
+ if (msg.flags.includes("\\Flagged"))
676
+ row.classList.add("flagged");
677
+ if (!msg.bodyPath)
678
+ row.classList.add("not-downloaded");
679
+ // Pink-row visible reconciliation state (S1 slice C): a queued local
680
+ // action (move/flag/delete) hasn't been ACK'd by the server yet.
681
+ if (msg.pending)
682
+ row.classList.add("pending-reconcile");
683
+ // Reply-row marker: messages with In-Reply-To are replies. Shows a
684
+ // subtle left-border accent so the eye can pick out threaded replies
685
+ // without enabling full thread grouping.
686
+ if (msg.inReplyTo)
687
+ row.classList.add("is-reply");
688
+ row.dataset.uid = String(msg.uid);
689
+ row.dataset.accountId = msgAccountId;
690
+ row.dataset.folderId = String(msg.folderId);
691
+ if (msg.threadId)
692
+ row.dataset.threadId = msg.threadId;
693
+ // Sender avatar \u2014 Thunderbird-style colored circle with the first
694
+ // initial of the sender's display name. Doubles as the multi-select
695
+ // affordance: in `multi-select-on` mode, the avatar swaps to a
696
+ // checkmark via CSS. Color is derived deterministically from the
697
+ // address so the same sender keeps the same color across rows.
698
+ const fromName = (showToInsteadOfFrom && msg.to?.length)
699
+ ? (msg.to[0].name || msg.to[0].address || "?")
700
+ : (msg.from?.name || msg.from?.address || "?");
701
+ const seedAddr = (msg.from?.address || msg.from?.name || "?").toLowerCase();
702
+ const initial = (fromName.replace(/^[\W_]+/, "") || "?").charAt(0).toUpperCase();
703
+ const avatar = document.createElement("span");
704
+ avatar.className = "ml-avatar";
705
+ avatar.textContent = initial;
706
+ avatar.style.background = senderColor(seedAddr);
707
+ avatar.title = msg.from?.address || "";
708
+ // Tapping the avatar enters multi-select mode (or toggles in it,
709
+ // mirroring Thunderbird/Gmail). Click bubbles to the row otherwise,
710
+ // which would open the message — stopPropagation here keeps the
711
+ // avatar a dedicated selection affordance.
712
+ avatar.addEventListener("click", (e) => {
713
+ e.stopPropagation();
714
+ const body = document.getElementById("ml-body");
715
+ if (!body)
716
+ return;
717
+ if (body.classList.contains("multi-select-on")) {
718
+ row.classList.toggle("selected");
719
+ }
720
+ else {
721
+ clearSelection();
722
+ row.classList.add("selected");
723
+ body.classList.add("multi-select-on");
724
+ }
725
+ lastClickedRow = row;
726
+ updateBulkBar();
727
+ });
728
+ // Right-click (or long-press) on the avatar → bulk-selection menu.
729
+ // Putting it on the avatar is contextually right: the avatar is the
730
+ // "select" affordance, so its menu owns operations on the selection
731
+ // set. "Select all visible" is the load-bearing item — there's no
732
+ // Ctrl-A equivalent on touch and the scope-after-search use case
733
+ // demands it.
734
+ avatar.addEventListener("contextmenu", async (e) => {
735
+ e.preventDefault();
736
+ e.stopPropagation();
737
+ const { showContextMenu } = await import("./context-menu.js");
738
+ const body = document.getElementById("ml-body");
739
+ const visibleRows = body
740
+ ? Array.from(body.querySelectorAll(".ml-row:not(.filter-hidden)"))
741
+ : [];
742
+ const selectedCount = body
743
+ ? body.querySelectorAll(".ml-row.selected").length
744
+ : 0;
745
+ showContextMenu(e.clientX, e.clientY, [
746
+ {
747
+ label: `Select all (${visibleRows.length})`,
748
+ action: () => {
749
+ if (!body)
750
+ return;
751
+ body.classList.add("multi-select-on");
752
+ for (const r of visibleRows)
753
+ r.classList.add("selected");
754
+ lastClickedRow = visibleRows[visibleRows.length - 1] || null;
755
+ updateBulkBar();
756
+ },
757
+ disabled: visibleRows.length === 0,
758
+ },
759
+ {
760
+ label: `Clear selection${selectedCount ? ` (${selectedCount})` : ""}`,
761
+ action: () => exitMultiSelect(),
762
+ disabled: selectedCount === 0,
763
+ },
764
+ {
765
+ label: "Invert selection",
766
+ action: () => {
767
+ if (!body)
768
+ return;
769
+ body.classList.add("multi-select-on");
770
+ for (const r of visibleRows)
771
+ r.classList.toggle("selected");
772
+ lastClickedRow = visibleRows[visibleRows.length - 1] || null;
773
+ updateBulkBar();
774
+ },
775
+ disabled: visibleRows.length === 0,
776
+ },
777
+ ]);
778
+ });
779
+ const flag = document.createElement("span");
780
+ flag.className = "ml-flag";
781
+ flag.textContent = msg.flags.includes("\\Flagged") ? "\u2605" : "\u2606";
782
+ flag.title = "Toggle flag";
783
+ const from = document.createElement("span");
784
+ from.className = "ml-from";
785
+ if (showToInsteadOfFrom && msg.to?.length) {
786
+ from.textContent = msg.to.map((a) => a.name || a.address).join(", ");
787
+ }
788
+ else {
789
+ from.textContent = msg.from.name || msg.from.address;
790
+ }
791
+ if (!accountId && msgAccountId) {
792
+ const tag = document.createElement("span");
793
+ tag.className = "ml-account-tag";
794
+ tag.textContent = msgAccountId.charAt(0).toUpperCase();
795
+ tag.title = msgAccountId;
796
+ from.prepend(tag);
797
+ }
798
+ // Search/cross-folder results carry folderName — show a tag so the user
799
+ // can tell which folder each hit lives in.
800
+ if (msg.folderName) {
801
+ const folderTag = document.createElement("span");
802
+ folderTag.className = "ml-folder-tag";
803
+ folderTag.textContent = msg.folderName;
804
+ folderTag.title = `In folder: ${msg.folderName}`;
805
+ from.prepend(folderTag);
806
+ }
807
+ // Unified inbox: same Message-ID exists under >=2 accounts → ⇆ badge.
808
+ // Tooltip names the count so the user knows "this appears on N".
809
+ if (msg.dupeCount >= 2) {
810
+ const dupe = document.createElement("span");
811
+ dupe.className = "ml-dupe-tag";
812
+ dupe.textContent = "⇆";
813
+ dupe.title = `Same message on ${msg.dupeCount} accounts`;
814
+ from.prepend(dupe);
815
+ }
816
+ const subject = document.createElement("span");
817
+ subject.className = "ml-subject";
818
+ subject.innerHTML = escapeHtml(msg.subject);
819
+ // Thread size pill: click to show a popup list of the thread's messages.
820
+ if (threadSize) {
821
+ const n = threadSize.get(msg) || 1;
822
+ if (n > 1 && msg.threadId) {
823
+ row.classList.add("thread-head");
824
+ row.dataset.threadId = msg.threadId;
825
+ const threadPill = document.createElement("span");
826
+ threadPill.className = "ml-thread-pill";
827
+ threadPill.textContent = String(n);
828
+ threadPill.title = `${n} messages in this thread — click to see list`;
829
+ threadPill.addEventListener("click", async (e) => {
830
+ e.stopPropagation();
831
+ await showThreadPopup(threadPill, msg);
832
+ });
833
+ subject.prepend(threadPill);
834
+ }
835
+ }
836
+ if (msg.preview) {
837
+ const preview = document.createElement("span");
838
+ preview.className = "ml-preview";
839
+ preview.textContent = ` \u2014 ${msg.preview}`;
840
+ subject.appendChild(preview);
841
+ }
842
+ const date = document.createElement("span");
843
+ date.className = "ml-date";
844
+ date.textContent = formatDate(msg.date);
845
+ flag.addEventListener("click", async (e) => {
846
+ e.stopPropagation();
847
+ const isFlagged = row.classList.contains("flagged");
848
+ const currentFlags = msg.flags || [];
849
+ const newFlags = isFlagged
850
+ ? currentFlags.filter((f) => f !== "\\Flagged")
851
+ : [...currentFlags, "\\Flagged"];
852
+ try {
853
+ await updateFlags(msgAccountId, msg.uid, newFlags);
854
+ msg.flags = newFlags;
855
+ row.classList.toggle("flagged");
856
+ flag.textContent = row.classList.contains("flagged") ? "\u2605" : "\u2606";
857
+ }
858
+ catch { /* ignore */ }
859
+ });
860
+ row.appendChild(avatar);
861
+ row.appendChild(flag);
862
+ row.appendChild(from);
863
+ row.appendChild(date);
864
+ row.appendChild(subject);
865
+ row.addEventListener("click", (e) => {
866
+ if (touchWasScroll) {
867
+ touchWasScroll = false;
868
+ return;
869
+ }
870
+ // Multi-select mode (entered via long-press on touch): taps toggle
871
+ // rows instead of opening messages. Exit mode when the user taps
872
+ // outside any row or presses Escape (handled at the body level).
873
+ const body = row.parentElement;
874
+ if (body?.classList.contains("multi-select-on")) {
875
+ row.classList.toggle("selected");
876
+ lastClickedRow = row;
877
+ updateBulkBar();
878
+ return;
879
+ }
880
+ if (e.shiftKey) {
881
+ const anchor = resolveShiftAnchor();
882
+ if (anchor) {
883
+ clearSelection();
884
+ selectRange(anchor, row);
885
+ lastClickedRow = row;
886
+ row.classList.remove("unread");
887
+ focusMessage(msgAccountId, msg);
888
+ }
889
+ else {
890
+ // No anchor available (first click of the session, or list
891
+ // was just rebuilt with no selection). Treat as a plain
892
+ // click so the user gets visible feedback rather than a
893
+ // silent no-op.
894
+ clearSelection();
895
+ focusRow(row, msgAccountId, msg);
896
+ lastClickedRow = row;
897
+ row.classList.remove("unread");
898
+ }
899
+ }
900
+ else if (e.ctrlKey || e.metaKey) {
901
+ row.classList.toggle("selected");
902
+ lastClickedRow = row;
903
+ }
904
+ else {
905
+ // Atomic unfocus-previous + focus-this.
906
+ clearSelection();
907
+ focusRow(row, msgAccountId, msg);
908
+ lastClickedRow = row;
909
+ row.classList.remove("unread");
910
+ }
911
+ updateBulkBar();
912
+ });
913
+ // Q64: double-click → pop out the message in a floating overlay so
914
+ // the user can read it without losing the selected list context.
915
+ row.addEventListener("dblclick", (e) => {
916
+ e.preventDefault();
917
+ e.stopPropagation();
918
+ document.dispatchEvent(new CustomEvent("mailx-popout-message", {
919
+ detail: { accountId: msgAccountId, uid: msg.uid, folderId: msg.folderId, subject: msg.subject }
920
+ }));
921
+ });
922
+ row.addEventListener("dragstart", (e) => {
923
+ if (!row.classList.contains("selected")) {
924
+ clearSelection();
925
+ row.classList.add("selected");
926
+ lastClickedRow = row;
927
+ }
928
+ const selected = getSelectedMessages();
929
+ e.dataTransfer.setData("application/x-mailx-messages", JSON.stringify(selected));
930
+ e.dataTransfer.setData("application/x-mailx-message", JSON.stringify({
931
+ accountId: msgAccountId,
932
+ uid: msg.uid,
933
+ folderId: msg.folderId,
934
+ subject: msg.subject,
935
+ }));
936
+ e.dataTransfer.effectAllowed = "copyMove";
937
+ row.classList.add("dragging");
938
+ if (selected.length > 1) {
939
+ const badge = document.createElement("div");
940
+ badge.textContent = `${selected.length} messages`;
941
+ badge.style.cssText = "position:absolute;top:-1000px;background:#333;color:white;padding:4px 8px;border-radius:4px;font-size:12px";
942
+ document.body.appendChild(badge);
943
+ e.dataTransfer.setDragImage(badge, 0, 0);
944
+ setTimeout(() => badge.remove(), 0);
945
+ }
946
+ });
947
+ row.addEventListener("dragend", () => row.classList.remove("dragging"));
948
+ // ── Q66: long-press on touch → context menu ──
949
+ // Mirrors right-click on the phone where right-click isn't a thing.
950
+ // Cancelled by any touchmove or touchend before the threshold.
951
+ let longPressTimer = null;
952
+ const LONG_PRESS_MS = 550;
953
+ row.addEventListener("touchstart", (e) => {
954
+ const t = e.touches[0];
955
+ if (!t)
956
+ return;
957
+ const cx = t.clientX, cy = t.clientY;
958
+ if (longPressTimer)
959
+ clearTimeout(longPressTimer);
960
+ longPressTimer = setTimeout(() => {
961
+ longPressTimer = null;
962
+ // Long-press semantics:
963
+ // - If the list is already in multi-select mode, toggle this
964
+ // row's selected state (so the user can extend a selection
965
+ // without needing a second long-press-and-menu dance).
966
+ // - Otherwise enter multi-select mode: mark THIS row selected
967
+ // and add a sticky class on the body so future taps toggle
968
+ // instead of opening messages. Tap elsewhere or press
969
+ // Escape to exit.
970
+ const body = row.parentElement;
971
+ const alreadyMulti = body?.classList.contains("multi-select-on");
972
+ if (alreadyMulti) {
973
+ row.classList.toggle("selected");
974
+ }
975
+ else {
976
+ clearSelection();
977
+ row.classList.add("selected");
978
+ body?.classList.add("multi-select-on");
979
+ }
980
+ lastClickedRow = row;
981
+ updateBulkBar();
982
+ // Haptic hint if the platform supports it (Android WebView does).
983
+ try {
984
+ navigator.vibrate?.(20);
985
+ }
986
+ catch { /* */ }
987
+ }, LONG_PRESS_MS);
988
+ }, { passive: true });
989
+ const cancelLongPress = () => {
990
+ if (longPressTimer) {
991
+ clearTimeout(longPressTimer);
992
+ longPressTimer = null;
993
+ }
994
+ };
995
+ row.addEventListener("touchmove", cancelLongPress, { passive: true });
996
+ row.addEventListener("touchend", cancelLongPress, { passive: true });
997
+ row.addEventListener("touchcancel", cancelLongPress, { passive: true });
998
+ // ── Right-click context menu ──
999
+ row.addEventListener("contextmenu", (e) => {
1000
+ e.preventDefault();
1001
+ // Selection-on-context-click semantics:
1002
+ // - If the right-clicked row is already selected → keep the
1003
+ // existing selection (single or multi). The menu acts on
1004
+ // whatever is selected.
1005
+ // - If it's NOT selected and we're in multi-select mode →
1006
+ // ADD this row to the selection (don't clear). Long-press
1007
+ // to open a context menu used to wipe the multi-select set,
1008
+ // which was the user-reported "annoying" behavior.
1009
+ // - If it's NOT selected and we're NOT in multi-select →
1010
+ // single-select this row (replace prior selection).
1011
+ const body = row.parentElement;
1012
+ const inMulti = !!body?.classList.contains("multi-select-on");
1013
+ if (!row.classList.contains("selected")) {
1014
+ if (inMulti) {
1015
+ row.classList.add("selected");
1016
+ lastClickedRow = row;
1017
+ }
1018
+ else {
1019
+ clearSelection();
1020
+ row.classList.add("selected");
1021
+ lastClickedRow = row;
1022
+ focusMessage(msgAccountId, msg);
1023
+ }
1024
+ }
1025
+ const isSeen = msg.flags.includes("\\Seen");
1026
+ const isFlagged = msg.flags.includes("\\Flagged");
1027
+ const items = [
1028
+ {
1029
+ label: isSeen ? "Mark unread" : "Mark read",
1030
+ action: async () => {
1031
+ const newFlags = isSeen
1032
+ ? msg.flags.filter((f) => f !== "\\Seen")
1033
+ : [...msg.flags, "\\Seen"];
1034
+ try {
1035
+ await updateFlags(msgAccountId, msg.uid, newFlags);
1036
+ msg.flags = newFlags;
1037
+ state.updateMessageFlags(msgAccountId, msg.uid, newFlags);
1038
+ row.classList.toggle("unread", !newFlags.includes("\\Seen"));
1039
+ }
1040
+ catch { /* ignore */ }
1041
+ },
1042
+ },
1043
+ {
1044
+ label: isFlagged ? "Unflag" : "Flag",
1045
+ action: async () => {
1046
+ const newFlags = isFlagged
1047
+ ? msg.flags.filter((f) => f !== "\\Flagged")
1048
+ : [...msg.flags, "\\Flagged"];
1049
+ try {
1050
+ await updateFlags(msgAccountId, msg.uid, newFlags);
1051
+ msg.flags = newFlags;
1052
+ row.classList.toggle("flagged");
1053
+ flag.textContent = row.classList.contains("flagged") ? "\u2605" : "\u2606";
1054
+ }
1055
+ catch { /* ignore */ }
1056
+ },
1057
+ },
1058
+ { label: "", action: () => { }, separator: true },
1059
+ {
1060
+ label: "Reply",
1061
+ action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "reply" } })),
1062
+ },
1063
+ {
1064
+ label: "Reply All",
1065
+ action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "replyAll" } })),
1066
+ },
1067
+ {
1068
+ label: "Forward",
1069
+ action: () => document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "forward" } })),
1070
+ },
1071
+ { label: "", action: () => { }, separator: true },
1072
+ {
1073
+ label: "Move to folder…",
1074
+ action: async () => {
1075
+ // Move all currently-selected rows (or just this one if it's the only selection)
1076
+ const selectedRows = Array.from(document.querySelectorAll(".ml-row.selected"));
1077
+ const uids = selectedRows.length > 0
1078
+ ? selectedRows.map((r) => Number(r.dataset.uid)).filter(u => !isNaN(u))
1079
+ : [msg.uid];
1080
+ const pick = await pickFolder(msgAccountId, { excludeFolderIds: [msg.folderId] });
1081
+ if (!pick)
1082
+ return;
1083
+ try {
1084
+ await apiMoveMessages(msgAccountId, uids, pick.folderId);
1085
+ // Remove from local state — reconciler handles server sync.
1086
+ state.removeMessages(uids.map(u => ({ accountId: msgAccountId, uid: u })));
1087
+ }
1088
+ catch (err) {
1089
+ alert(`Move failed: ${err.message}`);
1090
+ }
1091
+ },
1092
+ },
1093
+ {
1094
+ label: "Delete",
1095
+ action: () => document.dispatchEvent(new CustomEvent("mailx-delete")),
1096
+ },
1097
+ { label: "", action: () => { }, separator: true },
1098
+ {
1099
+ label: "⚠ Mark as spam",
1100
+ action: () => document.getElementById("btn-spam")?.click(),
1101
+ },
1102
+ { label: "", action: () => { }, separator: true },
1103
+ {
1104
+ label: "Copy Message-ID",
1105
+ action: async () => {
1106
+ // Useful when asking "where did my letter go?" — pair the
1107
+ // Message-ID with the reconcile-delete log line.
1108
+ if (!msg.messageId) {
1109
+ alert("No Message-ID on this row.");
1110
+ return;
1111
+ }
1112
+ try {
1113
+ await navigator.clipboard.writeText(msg.messageId);
1114
+ }
1115
+ catch { /* */ }
1116
+ },
1117
+ },
1118
+ ];
1119
+ showContextMenu(e.clientX, e.clientY, items);
1120
+ });
1121
+ body.appendChild(row);
1122
+ }
1123
+ }
1124
+ function formatDate(epochMs) {
1125
+ const d = new Date(epochMs);
1126
+ const now = new Date();
1127
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
1128
+ const msgDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
1129
+ if (msgDay.getTime() === today.getTime())
1130
+ return d.toLocaleTimeString(undefined, timeFmt);
1131
+ if (d.getFullYear() === now.getFullYear())
1132
+ return d.toLocaleString(undefined, dateFmtSameYear);
1133
+ return d.toLocaleString(undefined, dateFmt);
1134
+ }
1135
+ function escapeHtml(s) {
1136
+ const div = document.createElement("div");
1137
+ div.textContent = s;
1138
+ return div.innerHTML;
1139
+ }
1140
+ //# sourceMappingURL=message-list.js.map