six-degrees 0.4.9 → 0.4.11

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 (242) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +1 -0
  3. package/.next/standalone/.next/build-manifest.json +3 -3
  4. package/.next/standalone/.next/prerender-manifest.json +4 -4
  5. package/.next/standalone/.next/routes-manifest.json +6 -0
  6. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  12. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  13. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  14. package/.next/standalone/.next/server/app/_not-found.rsc +13 -13
  15. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +13 -13
  16. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +12 -12
  17. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  18. package/.next/standalone/.next/server/app/api/admin-delete/route.js.nft.json +1 -1
  19. package/.next/standalone/.next/server/app/api/admin-update/route.js.nft.json +1 -1
  20. package/.next/standalone/.next/server/app/api/bridges/route.js.nft.json +1 -1
  21. package/.next/standalone/.next/server/app/api/bulk-import/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/api/company-scores/legacy/route.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/api/company-scores/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/connections/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/data/export/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/data/import/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/data/restart/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/data/reveal/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/data/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/delete-cluster/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/ingest/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/network/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/notifications/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/outreach/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/queue/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/scraper/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/api/settings/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/api/settings/sector-preview/route.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/api/settings/sector-suggestions/route.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/api/setup-profile/route.js.nft.json +1 -1
  41. package/.next/standalone/.next/server/app/api/social/crm/route/app-paths-manifest.json +3 -0
  42. package/.next/standalone/.next/server/app/api/social/crm/route/build-manifest.json +12 -0
  43. package/.next/standalone/.next/server/app/api/social/crm/route/server-reference-manifest.json +4 -0
  44. package/.next/standalone/.next/server/app/api/social/crm/route.js +10 -0
  45. package/.next/standalone/.next/server/app/api/social/crm/route.js.map +5 -0
  46. package/.next/standalone/.next/server/app/api/social/crm/route.js.nft.json +1 -0
  47. package/.next/standalone/.next/server/app/api/social/crm/route_client-reference-manifest.js +3 -0
  48. package/.next/standalone/.next/server/app/api/social/messages/route.js +4 -3
  49. package/.next/standalone/.next/server/app/api/social/messages/route.js.nft.json +1 -1
  50. package/.next/standalone/.next/server/app/api/social/route.js +4 -3
  51. package/.next/standalone/.next/server/app/api/social/route.js.nft.json +1 -1
  52. package/.next/standalone/.next/server/app/api/unlock/route.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/api/update/route.js.nft.json +1 -1
  54. package/.next/standalone/.next/server/app/api/update-images/route.js.nft.json +1 -1
  55. package/.next/standalone/.next/server/app/api/users/route.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/avatars/[file]/route.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/import/page.js.nft.json +1 -1
  58. package/.next/standalone/.next/server/app/import/page_client-reference-manifest.js +1 -1
  59. package/.next/standalone/.next/server/app/import.html +1 -1
  60. package/.next/standalone/.next/server/app/import.rsc +15 -15
  61. package/.next/standalone/.next/server/app/import.segments/_full.segment.rsc +15 -15
  62. package/.next/standalone/.next/server/app/import.segments/_tree.segment.rsc +2 -2
  63. package/.next/standalone/.next/server/app/import.segments/import/__PAGE__.segment.rsc +14 -14
  64. package/.next/standalone/.next/server/app/index.html +1 -1
  65. package/.next/standalone/.next/server/app/index.rsc +15 -15
  66. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
  67. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  68. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  69. package/.next/standalone/.next/server/app/launch/page.js.nft.json +1 -1
  70. package/.next/standalone/.next/server/app/launch/page_client-reference-manifest.js +1 -1
  71. package/.next/standalone/.next/server/app/launch.html +2 -2
  72. package/.next/standalone/.next/server/app/launch.rsc +15 -15
  73. package/.next/standalone/.next/server/app/launch.segments/_full.segment.rsc +15 -15
  74. package/.next/standalone/.next/server/app/launch.segments/_tree.segment.rsc +2 -2
  75. package/.next/standalone/.next/server/app/launch.segments/launch/__PAGE__.segment.rsc +14 -14
  76. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  77. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  78. package/.next/standalone/.next/server/app/paths/page.js.nft.json +1 -1
  79. package/.next/standalone/.next/server/app/paths/page_client-reference-manifest.js +1 -1
  80. package/.next/standalone/.next/server/app/paths.html +1 -1
  81. package/.next/standalone/.next/server/app/paths.rsc +15 -15
  82. package/.next/standalone/.next/server/app/paths.segments/_full.segment.rsc +15 -15
  83. package/.next/standalone/.next/server/app/paths.segments/_tree.segment.rsc +2 -2
  84. package/.next/standalone/.next/server/app/paths.segments/paths/__PAGE__.segment.rsc +14 -14
  85. package/.next/standalone/.next/server/app/profile/page.js.nft.json +1 -1
  86. package/.next/standalone/.next/server/app/profile/page_client-reference-manifest.js +1 -1
  87. package/.next/standalone/.next/server/app/profile.html +1 -1
  88. package/.next/standalone/.next/server/app/profile.rsc +15 -15
  89. package/.next/standalone/.next/server/app/profile.segments/_full.segment.rsc +15 -15
  90. package/.next/standalone/.next/server/app/profile.segments/_tree.segment.rsc +2 -2
  91. package/.next/standalone/.next/server/app/profile.segments/profile/__PAGE__.segment.rsc +14 -14
  92. package/.next/standalone/.next/server/app/queue/page.js.nft.json +1 -1
  93. package/.next/standalone/.next/server/app/queue/page_client-reference-manifest.js +1 -1
  94. package/.next/standalone/.next/server/app/queue.html +1 -1
  95. package/.next/standalone/.next/server/app/queue.rsc +15 -15
  96. package/.next/standalone/.next/server/app/queue.segments/_full.segment.rsc +15 -15
  97. package/.next/standalone/.next/server/app/queue.segments/_tree.segment.rsc +2 -2
  98. package/.next/standalone/.next/server/app/queue.segments/queue/__PAGE__.segment.rsc +14 -14
  99. package/.next/standalone/.next/server/app/scores/page.js.nft.json +1 -1
  100. package/.next/standalone/.next/server/app/scores/page_client-reference-manifest.js +1 -1
  101. package/.next/standalone/.next/server/app/scores.html +1 -1
  102. package/.next/standalone/.next/server/app/scores.rsc +15 -15
  103. package/.next/standalone/.next/server/app/scores.segments/_full.segment.rsc +15 -15
  104. package/.next/standalone/.next/server/app/scores.segments/_tree.segment.rsc +2 -2
  105. package/.next/standalone/.next/server/app/scores.segments/scores/__PAGE__.segment.rsc +14 -14
  106. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  107. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  108. package/.next/standalone/.next/server/app/settings.html +1 -1
  109. package/.next/standalone/.next/server/app/settings.rsc +15 -15
  110. package/.next/standalone/.next/server/app/settings.segments/_full.segment.rsc +15 -15
  111. package/.next/standalone/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  112. package/.next/standalone/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +14 -14
  113. package/.next/standalone/.next/server/app/setup/page.js.nft.json +1 -1
  114. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  115. package/.next/standalone/.next/server/app/setup.html +1 -1
  116. package/.next/standalone/.next/server/app/setup.rsc +15 -15
  117. package/.next/standalone/.next/server/app/setup.segments/_full.segment.rsc +15 -15
  118. package/.next/standalone/.next/server/app/setup.segments/_tree.segment.rsc +2 -2
  119. package/.next/standalone/.next/server/app/setup.segments/setup/__PAGE__.segment.rsc +14 -14
  120. package/.next/standalone/.next/server/app/social/page.js.nft.json +1 -1
  121. package/.next/standalone/.next/server/app/social/page_client-reference-manifest.js +1 -1
  122. package/.next/standalone/.next/server/app/social.html +1 -1
  123. package/.next/standalone/.next/server/app/social.rsc +15 -15
  124. package/.next/standalone/.next/server/app/social.segments/_full.segment.rsc +15 -15
  125. package/.next/standalone/.next/server/app/social.segments/_tree.segment.rsc +2 -2
  126. package/.next/standalone/.next/server/app/social.segments/social/__PAGE__.segment.rsc +14 -14
  127. package/.next/standalone/.next/server/app-paths-manifest.json +1 -0
  128. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__06mi9k2._.js → [root-of-the-server]__05_3-22._.js} +7 -2
  129. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0t3v1a1._.js +1 -1
  130. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tir0vc._.js +8 -0
  131. package/.next/standalone/.next/server/chunks/_0ea9qwy._.js +1 -1
  132. package/.next/standalone/.next/server/chunks/_1_fynia._.js +4 -4
  133. package/.next/standalone/.next/server/chunks/_1itd1o_._.js +1 -1
  134. package/.next/standalone/.next/server/chunks/_1oqfuh8._.js +3 -0
  135. package/.next/standalone/.next/server/chunks/_1xh07bp._.js +1 -1
  136. package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_social_crm_route_actions_0d1aflv.js +3 -0
  137. package/.next/standalone/.next/server/chunks/lib_db-client_15le7cw.js +7 -7
  138. package/.next/standalone/.next/server/chunks/lib_linkedin-export_12imqxg.js +3 -0
  139. package/.next/standalone/.next/server/chunks/lib_rpc_05gt5ya.js +3 -0
  140. package/.next/standalone/.next/server/chunks/ssr/_0-85h6g._.js +3 -0
  141. package/.next/standalone/.next/server/chunks/ssr/_08cfj2b._.js +3 -0
  142. package/.next/standalone/.next/server/chunks/ssr/_0c_y09g._.js +1 -1
  143. package/.next/standalone/.next/server/chunks/ssr/app_page_1fazqbf.js +1 -1
  144. package/.next/standalone/.next/server/chunks/ssr/app_paths_page_1ol90m7.js +1 -1
  145. package/.next/standalone/.next/server/chunks/ssr/app_profile_page_0u3-l5y.js +1 -1
  146. package/.next/standalone/.next/server/chunks/ssr/app_queue_page_0c6znd6.js +1 -1
  147. package/.next/standalone/.next/server/chunks/ssr/app_scores_page_09wepvv.js +1 -1
  148. package/.next/standalone/.next/server/chunks/ssr/app_settings_page_1f8mjj_.js +1 -1
  149. package/.next/standalone/.next/server/chunks/ssr/app_setup_page_1-282zx.js +1 -1
  150. package/.next/standalone/.next/server/chunks/ssr/app_social_page_0h7zxda.js +2 -1
  151. package/.next/standalone/.next/server/chunks/ssr/lib_galaxy-lab_108_lu-.js +3 -0
  152. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  153. package/.next/standalone/.next/server/middleware-manifest.json +5 -5
  154. package/.next/standalone/.next/server/pages/404.html +1 -1
  155. package/.next/standalone/.next/server/pages/500.html +1 -1
  156. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  157. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  158. package/.next/standalone/.next/static/chunks/0a5iadnmt9u9k.js +1 -0
  159. package/.next/standalone/.next/static/chunks/15sjx07xhpn6p.js +1 -0
  160. package/.next/standalone/.next/static/chunks/{054zdthcnyd2h.js → 160wjb73ddwqd.js} +1 -1
  161. package/.next/standalone/.next/static/chunks/1a74dky22dxd6.js +1 -0
  162. package/.next/standalone/.next/static/chunks/{04bbi55urkrd6.js → 1c_n0973t07l0.js} +1 -1
  163. package/.next/standalone/.next/static/chunks/{0bo1dxn5gwzpk.css → 1x_ysly42-teq.css} +1 -1
  164. package/.next/standalone/.next/static/chunks/{1_mohm2643nxp.js → 236f0jtp68j_h.js} +1 -1
  165. package/.next/standalone/.next/static/chunks/297e1z34yi--z.js +1 -0
  166. package/.next/standalone/.next/static/chunks/2_ak6qjxb6oa5.js +1 -0
  167. package/.next/standalone/.next/static/chunks/{38wwtni5e1ec1.js → 39hr8d-b2ibtw.js} +1 -1
  168. package/.next/standalone/.next/static/chunks/3ui4d-uqkqj5d.js +2 -0
  169. package/.next/{static/chunks/1z_rt4jteewqp.js → standalone/.next/static/chunks/3zw8bv7-mc4n6.js} +1 -1
  170. package/.next/standalone/CHANGELOG.md +57 -0
  171. package/.next/standalone/app/api/data/export/route.js +2 -1
  172. package/.next/standalone/app/api/scraper/route.js +5 -1
  173. package/.next/standalone/app/api/social/crm/route.js +37 -0
  174. package/.next/standalone/app/api/social/messages/route.js +44 -8
  175. package/.next/standalone/app/api/social/route.js +80 -22
  176. package/.next/standalone/app/components/GalaxyLab.js +4 -1
  177. package/.next/standalone/app/components/ScanStatusBar.js +97 -71
  178. package/.next/standalone/app/components/settings/DataSection.js +12 -3
  179. package/.next/standalone/app/globals.css +15 -0
  180. package/.next/standalone/app/paths/page.js +0 -2
  181. package/.next/standalone/app/profile/page.js +0 -2
  182. package/.next/standalone/app/queue/page.js +0 -2
  183. package/.next/standalone/app/scores/page.js +0 -2
  184. package/.next/standalone/app/settings/page.js +0 -2
  185. package/.next/standalone/app/setup/page.js +0 -2
  186. package/.next/standalone/app/social/Crm.js +683 -0
  187. package/.next/standalone/app/social/Thread.js +90 -0
  188. package/.next/standalone/app/social/page.js +71 -62
  189. package/.next/standalone/lib/data-export.js +10 -4
  190. package/.next/standalone/lib/data-folder.js +9 -0
  191. package/.next/standalone/lib/data-import.js +13 -3
  192. package/.next/standalone/lib/island.js +36 -0
  193. package/.next/standalone/lib/linkedin-export.js +267 -46
  194. package/.next/standalone/lib/social-crm.js +415 -0
  195. package/.next/standalone/lib/social-store.js +13 -6
  196. package/.next/standalone/package-lock.json +2 -2
  197. package/.next/standalone/package.json +1 -1
  198. package/.next/standalone/scripts/scrape.py +126 -22
  199. package/.next/static/chunks/0a5iadnmt9u9k.js +1 -0
  200. package/.next/static/chunks/15sjx07xhpn6p.js +1 -0
  201. package/.next/static/chunks/{054zdthcnyd2h.js → 160wjb73ddwqd.js} +1 -1
  202. package/.next/static/chunks/1a74dky22dxd6.js +1 -0
  203. package/.next/static/chunks/{04bbi55urkrd6.js → 1c_n0973t07l0.js} +1 -1
  204. package/.next/static/chunks/{0bo1dxn5gwzpk.css → 1x_ysly42-teq.css} +1 -1
  205. package/.next/static/chunks/{1_mohm2643nxp.js → 236f0jtp68j_h.js} +1 -1
  206. package/.next/static/chunks/297e1z34yi--z.js +1 -0
  207. package/.next/static/chunks/2_ak6qjxb6oa5.js +1 -0
  208. package/.next/static/chunks/{38wwtni5e1ec1.js → 39hr8d-b2ibtw.js} +1 -1
  209. package/.next/static/chunks/3ui4d-uqkqj5d.js +2 -0
  210. package/.next/{standalone/.next/static/chunks/1z_rt4jteewqp.js → static/chunks/3zw8bv7-mc4n6.js} +1 -1
  211. package/CHANGELOG.md +57 -0
  212. package/lib/data-export.js +10 -4
  213. package/lib/data-folder.js +9 -0
  214. package/lib/data-import.js +13 -3
  215. package/lib/island.js +36 -0
  216. package/lib/linkedin-export.js +267 -46
  217. package/lib/social-crm.js +415 -0
  218. package/lib/social-store.js +13 -6
  219. package/package.json +1 -1
  220. package/scripts/scrape.py +126 -22
  221. package/.next/standalone/.next/server/chunks/[root-of-the-server]__08f5awg._.js +0 -8
  222. package/.next/standalone/.next/server/chunks/lib_18cr1gi._.js +0 -8
  223. package/.next/standalone/.next/server/chunks/ssr/_0ezj4e0._.js +0 -3
  224. package/.next/standalone/.next/server/chunks/ssr/_1errbuf._.js +0 -3
  225. package/.next/standalone/.next/server/chunks/ssr/app_components_ui_0pdmz2z.js +0 -3
  226. package/.next/standalone/.next/static/chunks/2bftn9gubwf0w.js +0 -1
  227. package/.next/standalone/.next/static/chunks/2pw6_osz1n_te.js +0 -1
  228. package/.next/standalone/.next/static/chunks/39csdcx7yv227.js +0 -1
  229. package/.next/standalone/.next/static/chunks/3yzlf70j5t0-o.js +0 -1
  230. package/.next/standalone/.next/static/chunks/3z-swu5pet507.js +0 -1
  231. package/.next/standalone/app/social/Conversations.js +0 -246
  232. package/.next/static/chunks/2bftn9gubwf0w.js +0 -1
  233. package/.next/static/chunks/2pw6_osz1n_te.js +0 -1
  234. package/.next/static/chunks/39csdcx7yv227.js +0 -1
  235. package/.next/static/chunks/3yzlf70j5t0-o.js +0 -1
  236. package/.next/static/chunks/3z-swu5pet507.js +0 -1
  237. /package/.next/standalone/.next/static/{yPlsfKhdS8MMtyXF7twSZ → jqE2D3zWqSGz5su948Azf}/_buildManifest.js +0 -0
  238. /package/.next/standalone/.next/static/{yPlsfKhdS8MMtyXF7twSZ → jqE2D3zWqSGz5su948Azf}/_clientMiddlewareManifest.js +0 -0
  239. /package/.next/standalone/.next/static/{yPlsfKhdS8MMtyXF7twSZ → jqE2D3zWqSGz5su948Azf}/_ssgManifest.js +0 -0
  240. /package/.next/static/{yPlsfKhdS8MMtyXF7twSZ → jqE2D3zWqSGz5su948Azf}/_buildManifest.js +0 -0
  241. /package/.next/static/{yPlsfKhdS8MMtyXF7twSZ → jqE2D3zWqSGz5su948Azf}/_clientMiddlewareManifest.js +0 -0
  242. /package/.next/static/{yPlsfKhdS8MMtyXF7twSZ → jqE2D3zWqSGz5su948Azf}/_ssgManifest.js +0 -0
@@ -0,0 +1,90 @@
1
+ 'use client';
2
+
3
+ // One conversation's messages in the Social tab's CRM (Crm.js), newest at the
4
+ // bottom. Only while Keep my messages is on: the words live only in the app's
5
+ // data folder (lib/social-store.js), and GET /api/social/messages serves them
6
+ // a page at a time.
7
+
8
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
9
+ import { LINE } from '../components/ui';
10
+
11
+ const THREAD_PAGE = 100;
12
+ const EARLIER_PAGE = 200;
13
+ const when = (t) => (t ? new Date(t).toLocaleString(undefined, { day: 'numeric', month: 'short', year: 'numeric', hour: 'numeric', minute: '2-digit' }) : '');
14
+
15
+ // One conversation's messages, newest at the bottom, a page at a time: the
16
+ // newest 100 first and 200 more each time you ask, so a thread of thousands
17
+ // opens as fast as a short one.
18
+ export default function Thread({ id, keep, group, token }) {
19
+ const [t, setT] = useState(null);
20
+ const [err, setErr] = useState(null);
21
+ const scroller = useRef(null);
22
+ const keepAt = useRef(null);
23
+
24
+ useEffect(() => {
25
+ if (!keep) return undefined;
26
+ let live = true;
27
+ fetch(`/api/social/messages?id=${encodeURIComponent(id)}&limit=${THREAD_PAGE}`).then((r) => r.json())
28
+ .then((d) => { if (live) { if (d.error) setErr(d.error); else { keepAt.current = 'bottom'; setT(d); } } })
29
+ .catch(() => { if (live) setErr('It couldn’t be read.'); });
30
+ return () => { live = false; };
31
+ }, [id, keep, token]);
32
+
33
+ // Opened at the newest; after "Show earlier", where you were stays put.
34
+ useLayoutEffect(() => {
35
+ const el = scroller.current;
36
+ if (!el || !keepAt.current) return;
37
+ if (keepAt.current === 'bottom') el.scrollTop = el.scrollHeight;
38
+ else el.scrollTop = el.scrollHeight - keepAt.current;
39
+ keepAt.current = null;
40
+ }, [t]);
41
+
42
+ async function earlier() {
43
+ const el = scroller.current;
44
+ const d = await fetch(`/api/social/messages?id=${encodeURIComponent(id)}&before=${t.start}&limit=${EARLIER_PAGE}`).then((r) => r.json()).catch(() => null);
45
+ if (!d || d.error) return;
46
+ keepAt.current = el ? el.scrollHeight - el.scrollTop : null;
47
+ setT({ ...t, start: d.start, messages: [...d.messages, ...t.messages] });
48
+ }
49
+
50
+ const pad = { padding: '10px 14px 14px', fontSize: 12.5, color: '#8b9a9a' };
51
+ if (!keep) return <div style={pad}>Turn on <b>Keep my messages</b> to read them here.</div>;
52
+ if (err) return <div style={{ ...pad, color: '#ff9b9b' }}>{err}</div>;
53
+ if (!t) return <div style={pad}>Opening…</div>;
54
+ if (!t.total) {
55
+ return (
56
+ <div style={pad}>
57
+ No messages are kept for this conversation yet. Choose your export folder again, or sync your messages, to
58
+ bring them in.
59
+ </div>
60
+ );
61
+ }
62
+ return (
63
+ <div ref={scroller} style={{ maxHeight: 440, overflowY: 'auto', padding: '8px 14px 12px', background: 'rgba(0,0,0,0.2)' }}>
64
+ {t.start > 0 && (
65
+ <div style={{ textAlign: 'center', margin: '4px 0 10px' }}>
66
+ <button onClick={earlier} style={{ background: 'rgba(255,255,255,0.06)', border: LINE, borderRadius: 6, color: '#cfe6f7', cursor: 'pointer', fontSize: 12, padding: '4px 12px' }}>
67
+ Show earlier ({t.start.toLocaleString()} more)
68
+ </button>
69
+ </div>
70
+ )}
71
+ {t.messages.map((m, i) => (
72
+ <div key={t.start + i} style={{ display: 'flex', justifyContent: m.fromMe ? 'flex-end' : 'flex-start', margin: '6px 0' }}>
73
+ <div style={{ maxWidth: '72%' }}>
74
+ <div style={{
75
+ padding: '7px 11px', borderRadius: 12, fontSize: 13, lineHeight: 1.45, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere',
76
+ background: m.fromMe ? 'rgba(52,152,219,0.35)' : 'rgba(255,255,255,0.08)', color: '#eef',
77
+ borderBottomRightRadius: m.fromMe ? 4 : 12, borderBottomLeftRadius: m.fromMe ? 12 : 4,
78
+ }}
79
+ >
80
+ {m.text || <i style={{ color: '#8b9a9a' }}>(no words: an attachment or a reaction)</i>}
81
+ </div>
82
+ <div style={{ fontSize: 10.5, color: '#778', marginTop: 2, textAlign: m.fromMe ? 'right' : 'left' }}>
83
+ {m.fromMe ? 'You' : group ? 'Someone in the group' : 'Them'} · {when(m.t)}
84
+ </div>
85
+ </div>
86
+ </div>
87
+ ))}
88
+ </div>
89
+ );
90
+ }
@@ -6,9 +6,11 @@
6
6
  // your data), read here in the browser: full history, no traffic to LinkedIn;
7
7
  // - an experimental live sync (the scanner's --messages): your messages list,
8
8
  // read once in your own Chrome, by hand or once a day.
9
- // Kept on this computer: dates, who wrote last and counts, and the messages
10
- // themselves only if you turn on Keep my messages (Conversations.js), in a file
11
- // of their own that switching it off deletes.
9
+ // Kept on this computer: dates, who wrote last and counts, the names of people
10
+ // who aren't your connections, and the messages themselves (and the notes sent
11
+ // with requests) only if you turn on Keep my messages, in a file of their own
12
+ // that switching it off deletes. The CRM (Crm.js) is the main section: everyone
13
+ // you've been in touch with, and your own stage, tags, notes and follow-ups.
12
14
 
13
15
  import { useEffect, useMemo, useRef, useState } from 'react';
14
16
  import Link from 'next/link';
@@ -16,20 +18,18 @@ import OnboardingGate from '../components/OnboardingGate';
16
18
  import { useUser } from '../components/UserProvider';
17
19
  import { loadNetwork } from '../../lib/network';
18
20
  import {
19
- readTable, messageStats, careerChapters, postingEffect, invitationSplit, warmthOf, repliesWaiting, buildConversations, conversationSummary,
21
+ readTable, messageStats, careerChapters, postingEffect, invitationSplit, warmthOf, buildConversations, conversationSummary, buildInvitations,
20
22
  } from '../../lib/linkedin-export';
21
23
  import { mergeSocial } from '../../lib/galaxy-lab';
22
24
  import { keyFor } from '../../lib/separation';
23
25
  import { runScrape, watchScanner, scannerNow } from '../../lib/scraper-client';
24
26
  import { Body, LINE, FONT } from '../components/ui';
25
27
  import { IS_DEMO } from '../../lib/demo';
26
- import Conversations from './Conversations';
28
+ import Crm from './Crm';
27
29
 
28
- const TIER = { S: '#FFD700', A: '#9B59B6', B: '#3498DB', C: '#95A5A6', D: '#BDC3C7' };
29
30
  const WARMTH = { warm: ['Warm', '#ff9f43'], cool: ['Cool', '#3498DB'], dormant: ['Dormant', '#8899aa'], never: ['Never messaged', '#556'] };
30
31
  const FILES = { 'connections.csv': 'first name', 'messages.csv': 'conversation id', 'invitations.csv': 'direction', 'positions.csv': 'company name', 'shares.csv': 'date' };
31
32
  const day = (t) => (t ? new Date(t).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) : '');
32
- const slug = (u) => String(u || '').replace(/\/+$/, '').split('/').pop();
33
33
  // Messages go to the server in parts of about this many characters: a request
34
34
  // here can carry 10 MB, and a character can take up to 3 bytes.
35
35
  const PART_CHARS = 2_500_000;
@@ -55,6 +55,14 @@ async function sendMessages(conversations) {
55
55
  }
56
56
  }
57
57
 
58
+ /** The notes sent with requests, when the messages are kept: they ride in the same file and go with it. */
59
+ async function sendInviteNotes(invitations) {
60
+ const invites = {};
61
+ for (const i of invitations) if (i.note) invites[i.id] = i.note;
62
+ const r = await fetch('/api/social/messages', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ invites }) });
63
+ if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'The requests’ notes couldn’t be saved.');
64
+ }
65
+
58
66
  export default function SocialPage() {
59
67
  return <OnboardingGate><SocialInner /></OnboardingGate>;
60
68
  }
@@ -69,9 +77,11 @@ function SocialInner() {
69
77
  const [job, setJob] = useState(() => scannerNow());
70
78
  const [switching, setSwitching] = useState(false);
71
79
  const [listToken, setListToken] = useState(0);
72
- // The last export's messages.csv, in this window only, so turning Keep my
73
- // messages on right after an import needn't ask for the folder again.
80
+ const [crmToken, setCrmToken] = useState(0);
81
+ // The last export's messages.csv and Invitations.csv, in this window only, so
82
+ // turning Keep my messages on right after an import needn't ask for the folder again.
74
83
  const lastMessages = useRef(null);
84
+ const lastInvites = useRef(null);
75
85
  const keep = social?.keepMessages === true;
76
86
 
77
87
  // The Conversations list reads again whenever this does (listToken).
@@ -86,14 +96,6 @@ function SocialInner() {
86
96
  return () => { live = false; };
87
97
  }, [userId]);
88
98
 
89
- // Everyone you know, by profile: name and tier for the lists below.
90
- const byKey = useMemo(() => {
91
- const m = new Map();
92
- for (const r of [...(net?.degree1 || []), ...(net?.degree2 || [])]) if (!m.has(keyFor(r))) m.set(keyFor(r), r);
93
- return m;
94
- }, [net]);
95
- const connected = useMemo(() => new Set((net?.degree1 || []).map(keyFor)), [net]);
96
-
97
99
  async function importFolder(list) {
98
100
  setBusy(true);
99
101
  setNote(null);
@@ -108,7 +110,9 @@ function SocialInner() {
108
110
  const stats = found['messages.csv'] ? messageStats(found['messages.csv']) : { asOf: null, people: new Map() };
109
111
  // The words are read out of messages.csv only when Keep my messages is on.
110
112
  const built = found['messages.csv'] ? buildConversations(found['messages.csv'], { keepText: keep }) : null;
113
+ const invitations = found['invitations.csv'] ? buildInvitations(found['invitations.csv'], { keepText: keep }) : null;
111
114
  lastMessages.current = found['messages.csv'] || null;
115
+ lastInvites.current = found['invitations.csv'] || null;
112
116
  const people = {};
113
117
  for (const [url, v] of stats.people) people[url] = { last: v.last, lastFromThem: v.lastFromThem, total: v.total, recent: v.recent };
114
118
  if (keepEmails) {
@@ -127,15 +131,26 @@ function SocialInner() {
127
131
  chapters: careerChapters(found['positions.csv'] || [], conns, asOf),
128
132
  posts: found['shares.csv'] ? postingEffect(found['shares.csv'], conns) : null,
129
133
  invites: found['invitations.csv'] ? invitationSplit(found['invitations.csv']) : null,
134
+ // One record per request, without the notes: those go with the messages, below.
135
+ invitations: (invitations || []).map(({ note, ...rest }) => rest),
136
+ // Names as the export spells them, so people who aren't connections can be listed.
137
+ names: built ? built.names : {},
130
138
  emails: keepEmails,
131
139
  };
132
140
  const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
133
141
  if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || 'It couldn’t be saved.');
134
142
  if (built && keep) await sendMessages(built.conversations);
143
+ if (invitations && keep) await sendInviteNotes(invitations);
144
+ const odd = built ? [
145
+ built.drafts ? `${built.drafts.toLocaleString()} unsent ${built.drafts === 1 ? 'draft' : 'drafts'} left out` : '',
146
+ built.skipped ? `${built.skipped.toLocaleString()} ${built.skipped === 1 ? 'row' : 'rows'} with no conversation or date left out` : '',
147
+ ].filter(Boolean) : [];
135
148
  setNote({
136
149
  good: true,
137
150
  text: `Read ${Object.keys(found).length} of LinkedIn’s files: ${stats.people.size.toLocaleString()} people you’ve messaged one to one`
138
- + (built ? `, ${built.conversations.length.toLocaleString()} conversations${keep ? ', their messages kept on this computer' : ''}.` : '.'),
151
+ + (built ? `, ${built.conversations.length.toLocaleString()} conversations${keep ? ', their messages kept on this computer' : ''}` : '')
152
+ + (invitations ? `, ${invitations.length.toLocaleString()} connection requests` : '')
153
+ + `.${odd.length ? ` (${odd.join('; ')}.)` : ''}`,
139
154
  });
140
155
  await reload();
141
156
  } catch (e) {
@@ -145,11 +160,15 @@ function SocialInner() {
145
160
  }
146
161
  }
147
162
 
148
- async function syncNow() {
163
+ // The daily sync reads the top of the list; `full` scrolls until the whole
164
+ // history has loaded (scripts/scrape.py --full-history), at the same pace.
165
+ async function syncNow(full = false) {
149
166
  setNote(null);
150
167
  try {
151
- const end = await runScrape('messages');
152
- setNote(end?.exitCode === 0 ? { good: true, text: 'Your messages list is read.' } : { good: false, text: 'The sync stopped before it finished. The Scan page has the log.' });
168
+ const end = await runScrape(full ? 'messages-full' : 'messages');
169
+ setNote(end?.exitCode === 0
170
+ ? { good: true, text: full ? 'Your whole messages history is read.' : 'Your messages list is read.' }
171
+ : { good: false, text: 'The sync stopped before it finished. The Scan page has the log.' });
153
172
  await reload();
154
173
  } catch (e) {
155
174
  setNote({ good: false, text: e.message });
@@ -172,8 +191,9 @@ function SocialInner() {
172
191
  if (!on) await fetch('/api/social/messages', { method: 'DELETE' });
173
192
  const r = await fetch('/api/social', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keepMessages: on }) });
174
193
  if (!r.ok) throw new Error('The switch couldn’t be saved.');
175
- if (on && lastMessages.current) {
176
- await sendMessages(buildConversations(lastMessages.current, { keepText: true }).conversations);
194
+ if (on && (lastMessages.current || lastInvites.current)) {
195
+ if (lastMessages.current) await sendMessages(buildConversations(lastMessages.current, { keepText: true }).conversations);
196
+ if (lastInvites.current) await sendInviteNotes(buildInvitations(lastInvites.current, { keepText: true }));
177
197
  setNote({ good: true, text: 'Your messages are kept on this computer now.' });
178
198
  } else if (on) {
179
199
  setNote({ good: true, text: 'On. Choose your export folder again, or sync your messages, to bring the messages in.' });
@@ -188,10 +208,21 @@ function SocialInner() {
188
208
  }
189
209
  }
190
210
 
211
+ // Forget it: the export's and the sync's findings, and the messages. Your CRM
212
+ // notes are your own work, so they're a question of their own, asked after.
191
213
  async function forget() {
192
214
  await fetch('/api/social', { method: 'DELETE' });
193
215
  lastMessages.current = null;
194
- setNote({ good: true, text: 'Forgotten. Nothing from your export or messages is kept here now, the messages themselves included.' });
216
+ lastInvites.current = null;
217
+ let text = 'Forgotten. Nothing from your export or messages is kept here now, the messages themselves included.';
218
+ if (window.confirm('Also delete your CRM: the stages, tags, notes and follow-up dates you added? This can’t be undone. Cancel keeps them.')) {
219
+ await fetch('/api/social/crm', { method: 'DELETE' });
220
+ setCrmToken((n) => n + 1);
221
+ text += ' Your CRM notes are deleted too.';
222
+ } else {
223
+ text += ' Your CRM notes are kept.';
224
+ }
225
+ setNote({ good: true, text });
195
226
  reload();
196
227
  }
197
228
 
@@ -205,16 +236,6 @@ function SocialInner() {
205
236
  return counts;
206
237
  }, [net, merged, asOf]);
207
238
 
208
- const waiting = useMemo(() => {
209
- const stats = { asOf, people: new Map([...merged].filter(([, v]) => v.lastFromThem != null)) };
210
- const list = repliesWaiting(stats).map((w) => ({ ...w, person: byKey.get(w.url) }));
211
- for (const [k, v] of merged) {
212
- if (v.unread > 0 && !list.some((w) => w.url === k)) list.push({ url: k, unread: v.unread, person: byKey.get(k) });
213
- }
214
- const rank = (w) => ({ S: 0, A: 1 }[w.person?.tier] ?? 2);
215
- return list.sort((a, b) => rank(a) - rank(b) || (a.days ?? 0) - (b.days ?? 0)).slice(0, 15);
216
- }, [merged, asOf, byKey]);
217
-
218
239
  if (IS_DEMO) return <div style={{ minHeight: '100vh', background: '#0a0a1a', color: '#aaa', padding: 40, fontFamily: FONT }}>Not part of the demo.</div>;
219
240
  const running = job?.running;
220
241
  const box = { padding: '12px 14px', borderRadius: 8, border: LINE, background: 'rgba(255,255,255,0.03)', marginTop: 10 };
@@ -226,13 +247,12 @@ function SocialInner() {
226
247
  <Link href="/" style={{ color: '#888', textDecoration: 'none', fontSize: 13, fontWeight: 600, padding: '6px 14px', borderRadius: 6, background: 'rgba(255,255,255,0.06)', border: LINE }}>← Back to Map</Link>
227
248
  <h1 style={{ fontSize: 22, fontWeight: 700, margin: 0, color: '#FFD700' }}>Social <span style={{ fontSize: 12, color: '#8b9a9a', fontWeight: 600 }}>experimental</span></h1>
228
249
  </header>
229
- {/* The scan status bar sits here, under the header's buttons (ScanStatusBar.js). */}
230
- <div id="scan-bar-slot" />
231
- <main style={{ maxWidth: 980, margin: '0 auto', padding: '8px 24px 64px' }}>
250
+ <main style={{ maxWidth: 1180, margin: '0 auto', padding: '8px 16px 64px' }}>
232
251
  <Body style={{ marginTop: 16 }}>
233
- Who you&rsquo;re warm with, who&rsquo;s waiting on a reply, and how each chapter of your career built your network,
234
- from your own LinkedIn data. Only dates, who wrote last and counts are kept on this computer, and what
235
- messages say only if you turn on <i>Keep my messages</i> under Conversations.
252
+ Everyone you&rsquo;ve been in touch with on LinkedIn, connections or not: who&rsquo;s waiting on you, who
253
+ hasn&rsquo;t answered, what you&rsquo;ve sent and received, and your own notes and follow-ups on each, from
254
+ your own LinkedIn data. Only dates, who wrote last, counts and names are kept on this computer, and what
255
+ messages say only if you turn on <i>Keep my messages</i> below.
236
256
  </Body>
237
257
 
238
258
  <h2 style={h2}>Your LinkedIn export</h2>
@@ -258,14 +278,19 @@ function SocialInner() {
258
278
  <h2 style={h2}>Live messages sync <span style={{ fontSize: 11, color: '#FFD700' }}>experimental</span></h2>
259
279
  <div style={box}>
260
280
  <Body style={{ margin: 0 }}>
261
- Reads your messages list once, in your own Chrome: who each one-to-one conversation is with, when it was last
262
- active, whether it&rsquo;s unread and who wrote last. It uses no search budget and opens no conversation. The
263
- newest message&rsquo;s words are kept only if <i>Keep my messages</i> is on.
281
+ Reads your messages list once, in your own Chrome: who each conversation is with, connections or not, when it
282
+ was last active, whether it&rsquo;s unread and who wrote last. It uses no search budget and opens no
283
+ conversation. The newest message&rsquo;s words are kept only if <i>Keep my messages</i> is on. The daily sync
284
+ reads the top of the list; <i>Read my whole history</i> keeps scrolling until no more conversations load
285
+ (at most 60 scrolls, about 1,000 conversations), at the same slow, fixed pace, so it takes a few minutes.
264
286
  </Body>
265
287
  <div style={{ marginTop: 10, display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' }}>
266
- <button onClick={syncNow} disabled={running} style={{ padding: '7px 14px', borderRadius: 8, border: 'none', background: running ? '#333' : 'rgba(0,255,136,0.15)', color: running ? '#777' : '#00ff88', cursor: running ? 'default' : 'pointer', fontSize: 13 }}>
288
+ <button onClick={() => syncNow(false)} disabled={running} style={{ padding: '7px 14px', borderRadius: 8, border: 'none', background: running ? '#333' : 'rgba(0,255,136,0.15)', color: running ? '#777' : '#00ff88', cursor: running ? 'default' : 'pointer', fontSize: 13 }}>
267
289
  {running ? 'The scanner is busy' : 'Sync messages now'}
268
290
  </button>
291
+ <button onClick={() => syncNow(true)} disabled={running} style={{ padding: '7px 14px', borderRadius: 8, border: LINE, background: 'none', color: running ? '#777' : '#cfe6f7', cursor: running ? 'default' : 'pointer', fontSize: 13 }}>
292
+ Read my whole history
293
+ </button>
269
294
  <label style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: 12.5, color: '#b8c4c4' }}>
270
295
  <input type="checkbox" checked={social?.autoSync === true} onChange={(e) => setAuto(e.target.checked)} />
271
296
  Once a day while the app is open (daytime only)
@@ -274,8 +299,8 @@ function SocialInner() {
274
299
  </div>
275
300
  {note && <div style={{ marginTop: 10, fontSize: 13, color: note.good ? '#00ff88' : '#ff9b9b' }}>{note.text}</div>}
276
301
 
277
- <h2 style={h2}>Conversations</h2>
278
- <Conversations byKey={byKey} connected={connected} ready={!!net} keep={keep} onKeep={setKeep} switching={switching || busy} token={listToken} />
302
+ <h2 style={h2}>People</h2>
303
+ <Crm net={net} asOf={social ? asOf : null} keep={keep} onKeep={setKeep} switching={switching || busy} token={listToken} crmToken={crmToken} />
279
304
 
280
305
  {social && (
281
306
  <>
@@ -292,22 +317,6 @@ function SocialInner() {
292
317
  Of your connections. Warm: messaged in the last 30 days, or 3+ messages in 90. Cool: within a year. Dormant: longer ago.
293
318
  </div>
294
319
 
295
- <h2 style={h2}>Waiting on you</h2>
296
- <div style={{ ...box, padding: 0 }}>
297
- {waiting.length === 0 && <div style={{ padding: 12, fontSize: 13, color: '#8b9a9a' }}>No one is waiting on a reply.</div>}
298
- {waiting.map((w) => (
299
- <div key={w.url} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 14px', borderBottom: '1px solid rgba(255,255,255,0.04)', fontSize: 13 }}>
300
- <span style={{ width: 8, height: 8, borderRadius: '50%', background: TIER[w.person?.tier] || '#556', flexShrink: 0 }} />
301
- <span style={{ flex: 1, minWidth: 0 }}>
302
- <b>{w.person?.name || slug(w.url)}</b>
303
- {w.person?.company ? <span style={{ color: '#8b9a9a' }}> · {w.person.company}</span> : null}
304
- </span>
305
- <span style={{ color: '#b8c4c4' }}>{w.unread ? `${w.unread} unread` : `wrote ${w.days} days before your export`}</span>
306
- <a href={w.url} target="_blank" rel="noreferrer" style={{ color: '#3498DB', textDecoration: 'none' }}>Open ↗</a>
307
- </div>
308
- ))}
309
- </div>
310
-
311
320
  {social.chapters?.length > 0 && (
312
321
  <>
313
322
  <h2 style={h2}>Career chapters</h2>
@@ -14,7 +14,7 @@ import { DatabaseSync } from 'node:sqlite';
14
14
  import { readFileSync, lstatSync, statSync } from 'node:fs';
15
15
  import { createHash } from 'node:crypto';
16
16
  import path from 'node:path';
17
- import { AVATAR_FILE, TRAVELLING_FILES, namesInFolder } from './data-folder.js';
17
+ import { AVATAR_FILE, TRAVELLING_FILES, SOCIAL_FILE, namesInFolder } from './data-folder.js';
18
18
 
19
19
  /** Bump when the file's layout changes in a way an older import can't read. */
20
20
  export const EXPORT_FORMAT = 1;
@@ -72,7 +72,7 @@ export function referencedPhotos(db) {
72
72
  * plain files are taken, never a link, and avatars/ is read only if it is a
73
73
  * real folder, so nothing outside the data folder can be pulled in through one.
74
74
  */
75
- export function travellingFiles(dir, { includePhotos = true, photos = null } = {}) {
75
+ export function travellingFiles(dir, { includePhotos = true, photos = null, includeSocial = true } = {}) {
76
76
  const plain = (p) => {
77
77
  try { return lstatSync(p).isFile(); } catch { return false; }
78
78
  };
@@ -81,6 +81,12 @@ export function travellingFiles(dir, { includePhotos = true, photos = null } = {
81
81
  const abs = path.join(dir, name);
82
82
  if (plain(abs)) out.push({ rel: name, abs });
83
83
  }
84
+ if (includeSocial) {
85
+ for (const name of namesInFolder(dir).sort()) {
86
+ const abs = path.join(dir, name);
87
+ if (SOCIAL_FILE.test(name) && plain(abs)) out.push({ rel: name, abs });
88
+ }
89
+ }
84
90
  if (includePhotos) {
85
91
  for (const name of namesInFolder(path.join(dir, 'avatars')).sort()) {
86
92
  if (!AVATAR_FILE.test(name) || (photos && !photos.has(name))) continue;
@@ -96,7 +102,7 @@ export function travellingFiles(dir, { includePhotos = true, photos = null } = {
96
102
  * `outFile`, which must not exist yet.
97
103
  * @returns {{ bytes, people, photos, files, counts }}
98
104
  */
99
- export function buildExport(db, { dir, outFile, includePhotos = true, appVersion, schemaSql, now = new Date() }) {
105
+ export function buildExport(db, { dir, outFile, includePhotos = true, includeSocial = true, appVersion, schemaSql, now = new Date() }) {
100
106
  db.exec(`VACUUM INTO ${sqlString(outFile)}`);
101
107
 
102
108
  const out = new DatabaseSync(outFile);
@@ -127,7 +133,7 @@ export function buildExport(db, { dir, outFile, includePhotos = true, appVersion
127
133
  // photo of someone whose circle was deleted since; the copy has no row
128
134
  // for them, so their face has no reason to travel.
129
135
  const inNetwork = includePhotos ? referencedPhotos(out) : null;
130
- for (const f of travellingFiles(dir, { includePhotos, photos: inNetwork })) {
136
+ for (const f of travellingFiles(dir, { includePhotos, photos: inNetwork, includeSocial })) {
131
137
  let bytes;
132
138
  try { bytes = readFileSync(f.abs); } catch { continue; } // replaced since the listing
133
139
  put.run(f.rel, bytes, sha256(bytes));
@@ -44,6 +44,15 @@ export const BUDGET_FILES = [
44
44
  'linkedin-cooldown.json',
45
45
  ];
46
46
 
47
+ /**
48
+ * The Social tab's files, one set per profile: its findings, the messages kept
49
+ * with "Keep my messages on this computer", and the CRM's stages, tags and
50
+ * notes (lib/social-store.js). A copy carries them when asked (Blake,
51
+ * 2026-09-29: everything goes with the network), and an import puts them back
52
+ * in place of this computer's, which are kept beside the old network.
53
+ */
54
+ export const SOCIAL_FILE = /^(?:social|social-messages|crm)-[\w-]+\.json$/;
55
+
47
56
  /** Everything of the scanner's that an export carries. */
48
57
  export const TRAVELLING_FILES = [...NETWORK_FILES, ...BUDGET_FILES];
49
58
 
@@ -31,7 +31,7 @@ import { runningNow } from './scan-state.js';
31
31
  import { durable } from './durable.js';
32
32
  import { budgetFileProblem, mergeBudgetFiles } from './linkedin-limits.js';
33
33
  import {
34
- AVATAR_FILE, TRAVELLING_FILES, NETWORK_FILES, BUDGET_FILES, PENDING_DIR, IMPORT_BACKUP_PREFIX,
34
+ AVATAR_FILE, TRAVELLING_FILES, NETWORK_FILES, BUDGET_FILES, SOCIAL_FILE, PENDING_DIR, IMPORT_BACKUP_PREFIX,
35
35
  sweepLeftovers, namesInFolder,
36
36
  } from './data-folder.js';
37
37
  import {
@@ -158,10 +158,11 @@ export async function receiveUpload(body, file, { declared }) {
158
158
  return received;
159
159
  }
160
160
 
161
- /** 'photo', 'file' (one of the scanner's travelling files), or null for anything an export never carries. */
161
+ /** 'photo', 'file' (one of the scanner's travelling files), 'social', or null for anything an export never carries. */
162
162
  export function exportedFileKind(rel) {
163
163
  if (typeof rel !== 'string') return null;
164
164
  if (TRAVELLING_FILES.includes(rel)) return 'file';
165
+ if (SOCIAL_FILE.test(rel)) return 'social';
165
166
  // The same pattern the /avatars route serves: no folders, no dots but the
166
167
  // extension, so no path can climb out of avatars/.
167
168
  if (rel.startsWith('avatars/') && AVATAR_FILE.test(rel.slice('avatars/'.length))) return 'photo';
@@ -421,7 +422,7 @@ export function stageImport(upload, {
421
422
  throw new ImportError(`“${shortName(rel)}” inside this file is damaged, so nothing was imported.`);
422
423
  }
423
424
  writeFileSync(path.join(filesDir, rel), row.bytes, { flag: 'wx' });
424
- if (kind === 'file') scannerFiles.push(path.join(filesDir, rel));
425
+ if (kind === 'file' || kind === 'social') scannerFiles.push(path.join(filesDir, rel));
425
426
  }
426
427
  } finally {
427
428
  db.close();
@@ -731,6 +732,14 @@ export function applyPendingImport({ dir, dbFile, now = new Date(), copyDatabase
731
732
  keptFolder();
732
733
  move(from, path.join(keptFiles, name));
733
734
  }
735
+ // This computer's Social files step aside only when the copy brings its
736
+ // own; a copy made without them leaves them where they are.
737
+ if (namesInFolder(path.join(pending, 'files')).some((n) => SOCIAL_FILE.test(n))) {
738
+ for (const name of namesInFolder(dir).filter((n) => SOCIAL_FILE.test(n))) {
739
+ keptFolder();
740
+ move(path.join(dir, name), path.join(keptFiles, name));
741
+ }
742
+ }
734
743
  for (const name of BUDGET_FILES) {
735
744
  const from = path.join(dir, name);
736
745
  if (!exists(from) || !lstatSync(from).isFile()) continue;
@@ -786,6 +795,7 @@ export function applyPendingImport({ dir, dbFile, now = new Date(), copyDatabase
786
795
  const from = path.join(files, name);
787
796
  if (exists(from)) move(from, path.join(dir, name));
788
797
  }
798
+ for (const name of namesInFolder(files).filter((n) => SOCIAL_FILE.test(n))) move(path.join(files, name), path.join(dir, name));
789
799
  mergeBudgetFiles({ dir, from: files });
790
800
  durable.syncFolder(dir);
791
801
  step('done');
@@ -0,0 +1,36 @@
1
+ // The notch (app/components/ScanStatusBar.js): one small pill hanging under the
2
+ // header that shows what the app is doing. The scanner shows there on its own;
3
+ // anything else that runs for a while (a replay being recorded, an import) can
4
+ // show there too, through this list.
5
+ //
6
+ // const done = showActivity({ id: 'replay', label: 'Recording the replay', detail: '12 s' });
7
+ // … later: done();
8
+ //
9
+ // An activity is { id, label, detail?, progress? (0–1), tone? ('run' | 'idle' | 'warn') }.
10
+ // Showing the same id again replaces it. The list only changes when something
11
+ // in it does, as useSyncExternalStore needs.
12
+
13
+ const listeners = new Set();
14
+ let activities = [];
15
+
16
+ export const activitiesNow = () => activities;
17
+ const EMPTY = [];
18
+ export const noActivities = () => EMPTY;
19
+
20
+ export function watchActivities(f) {
21
+ listeners.add(f);
22
+ return () => listeners.delete(f);
23
+ }
24
+
25
+ export function showActivity(a) {
26
+ if (!a?.id) return () => {};
27
+ activities = [...activities.filter((x) => x.id !== a.id), a];
28
+ listeners.forEach((f) => f());
29
+ return () => clearActivity(a.id);
30
+ }
31
+
32
+ export function clearActivity(id) {
33
+ if (!activities.some((x) => x.id === id)) return;
34
+ activities = activities.filter((x) => x.id !== id);
35
+ listeners.forEach((f) => f());
36
+ }