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
@@ -1,47 +1,81 @@
1
1
  import { rmSync } from 'node:fs';
2
2
  import { db } from '../../../lib/db';
3
3
  import { resolveProfile } from '../../../lib/profile';
4
- import { matchLive } from '../../../lib/linkedin-export';
4
+ import { matchLive, matchLiveGroups, mergeLive } from '../../../lib/linkedin-export';
5
5
  import { socialFiles, readJson, writeJsonAtomic, readMessages, deleteMessages } from '../../../lib/social-store';
6
6
 
7
7
  // The Social tab's saved findings (lib/linkedin-export.js): numbers and dates
8
8
  // from your own LinkedIn export and the live messages sync, one file per
9
- // profile in the data folder. No message text is kept in this file: the words,
10
- // when Keep my messages is on, go to their own file (lib/social-store.js,
11
- // app/api/social/messages). GET reads it, POST replaces it, PUT takes a live
12
- // sync, PATCH flips a switch, DELETE forgets it all.
9
+ // profile in the data folder, and the names of people who aren't your
10
+ // connections (nothing else in the app says who they are). No message text is
11
+ // kept in this file: the words, when Keep my messages is on, go to their own
12
+ // file (lib/social-store.js, app/api/social/messages). GET reads it, POST
13
+ // replaces it, PUT takes a live sync, PATCH flips a switch, DELETE forgets it
14
+ // all. Your CRM notes are a file of their own (app/api/social/crm), which
15
+ // Forget it leaves alone: the page asks about them separately.
13
16
 
14
17
  // A person's key: their profile link, as lib/separation.js keyFor makes it.
15
18
  const KEY_OK = (k) => typeof k === 'string' && k.length > 0 && k.length < 300;
19
+ const num = (n) => (Number.isFinite(Number(n)) && n !== null ? Number(n) : null);
20
+ const name = (s) => (typeof s === 'string' ? s.replace(/[\u0000-\u001f]/g, ' ').trim().slice(0, 120) : '');
16
21
 
17
- /** An import's list of conversations, with nothing but the fields the list shows. */
22
+ /**
23
+ * An import's list of conversations, with nothing but the fields the list
24
+ * shows. A conversation with no one but you in it (the export didn't say to
25
+ * whom) stays, with no people: nothing is dropped.
26
+ */
18
27
  function cleanConversations(list) {
19
28
  if (!Array.isArray(list)) return [];
20
29
  const out = [];
21
30
  for (const c of list) {
22
31
  if (!c || typeof c.id !== 'string' || !c.id || c.id.length > 200 || !Array.isArray(c.people)) continue;
23
32
  const people = c.people.filter(KEY_OK).slice(0, 200);
24
- if (!people.length) continue;
25
33
  out.push({
26
34
  id: c.id,
27
35
  people,
28
36
  group: c.group === true,
37
+ kind: ['sponsored', 'inmail'].includes(c.kind) ? c.kind : null,
38
+ folder: typeof c.folder === 'string' && /^[\w -]{0,30}$/.test(c.folder) ? c.folder.toLowerCase() : '',
29
39
  last: Number(c.last) || null,
30
40
  lastFromThem: typeof c.lastFromThem === 'boolean' ? c.lastFromThem : null,
31
- count: Number.isFinite(Number(c.count)) ? Number(c.count) : null,
41
+ count: num(c.count),
42
+ mine: num(c.mine),
32
43
  });
33
44
  }
34
45
  return out;
35
46
  }
36
47
 
48
+ /** An import's requests (buildInvitations), without their notes: those go with the messages, and only while they're kept. */
49
+ function cleanInvitations(list) {
50
+ if (!Array.isArray(list)) return [];
51
+ const out = [];
52
+ for (const i of list.slice(0, 50000)) {
53
+ if (!i || !['in', 'out'].includes(i.dir) || !KEY_OK(i.key) || typeof i.id !== 'string' || i.id.length > 400) continue;
54
+ out.push({ id: i.id, dir: i.dir, key: i.key, name: name(i.name), t: Number(i.t) || null });
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /** Names by key, as the export spells them. */
60
+ function cleanNames(names) {
61
+ const out = {};
62
+ if (!names || typeof names !== 'object') return out;
63
+ for (const [k, v] of Object.entries(names).slice(0, 200000)) {
64
+ const n = name(v);
65
+ if (KEY_OK(k) && n) out[k] = n;
66
+ }
67
+ return out;
68
+ }
69
+
37
70
  export async function GET() {
38
71
  const files = socialFiles();
39
72
  if (!files) return Response.json({ social: null });
40
73
  const social = readJson(files.social, null);
41
74
  if (!social) return Response.json({ social: null });
42
- // The list of conversations can run to thousands; the Galaxy and the tab's
43
- // counts don't need it, and GET /api/social/messages serves it.
44
- const { conversations, ...rest } = social;
75
+ // The lists (conversations, requests, names, what the sync found beyond your
76
+ // connections) can run to thousands; the Galaxy and the tab's counts don't
77
+ // need them, and GET /api/social/messages serves them.
78
+ const { conversations, invitations, names, liveOthers, liveGroups, ...rest } = social;
45
79
  return Response.json({ social: { ...rest, conversationCount: Array.isArray(conversations) ? conversations.length : 0 } });
46
80
  }
47
81
 
@@ -55,6 +89,8 @@ export async function POST(request) {
55
89
  const clean = {
56
90
  live: before.live || {},
57
91
  liveAt: before.liveAt || null,
92
+ liveOthers: before.liveOthers || {},
93
+ liveGroups: before.liveGroups || [],
58
94
  autoSync: before.autoSync === true,
59
95
  keepMessages: before.keepMessages === true,
60
96
  importedAt: new Date().toISOString(),
@@ -64,6 +100,8 @@ export async function POST(request) {
64
100
  chapters: Array.isArray(body?.chapters) ? body.chapters : [],
65
101
  posts: body?.posts && typeof body.posts === 'object' ? body.posts : null,
66
102
  invites: body?.invites && typeof body.invites === 'object' ? body.invites : null,
103
+ invitations: cleanInvitations(body?.invitations),
104
+ names: cleanNames(body?.names),
67
105
  emails: body?.emails === true,
68
106
  };
69
107
  for (const p of Object.values(clean.people)) {
@@ -73,14 +111,18 @@ export async function POST(request) {
73
111
  }
74
112
  }
75
113
  writeJsonAtomic(files.social, clean);
76
- return Response.json({ ok: true, people: Object.keys(clean.people).length, conversations: clean.conversations.length });
114
+ return Response.json({ ok: true, people: Object.keys(clean.people).length, conversations: clean.conversations.length, invitations: clean.invitations.length });
77
115
  }
78
116
 
79
117
  // From the scanner's experimental messages sync (scripts/scrape.py sync_messages):
80
118
  // per person, when your 1:1 conversation was last active, unread count, the
81
- // thread's link, who wrote last and the newest message. Merged in beside what
82
- // the export gave, kept under `live`. The newest message's words go to the
83
- // messages file, and only while Keep my messages is on.
119
+ // thread's link, who wrote last and the newest message; and the group
120
+ // conversations, without any words. Kept beside what the export gave: your
121
+ // connections under `live`, everyone else (with their name) under
122
+ // `liveOthers`, groups under `liveGroups`, each merged with what earlier syncs
123
+ // found, so a quick sync doesn't forget what a full-history one read further
124
+ // down. The newest message's words go to the messages file, and only while
125
+ // Keep my messages is on.
84
126
  export async function PUT(request) {
85
127
  const files = socialFiles();
86
128
  if (!files) return Response.json({ error: 'Pick a profile first.' }, { status: 400 });
@@ -95,19 +137,34 @@ export async function PUT(request) {
95
137
  if (me?.id) q.eq('user_id', me.id);
96
138
  conns = (await q).data || [];
97
139
  } catch { /* no connections yet */ }
98
- const { live: matched, unmatched } = matchLive(body?.live || {}, conns);
140
+ const { live: matched, others: found, unmatched } = matchLive(body?.live || {}, conns);
99
141
  const live = {};
142
+ const others = {};
100
143
  const previews = {};
101
- for (const [key, { preview, ...rest }] of Object.entries(matched)) {
102
- live[key] = rest;
103
- if (saved.keepMessages === true && preview?.text) previews[key] = preview;
144
+ for (const [into, from] of [[live, matched], [others, found]]) {
145
+ for (const [key, { preview, ...rest }] of Object.entries(from)) {
146
+ into[key] = rest;
147
+ if (saved.keepMessages === true && preview?.text) previews[key] = preview;
148
+ }
149
+ }
150
+ const groups = {};
151
+ for (const g of saved.liveGroups || []) groups[g.threadUrl || g.people.join(',')] = g;
152
+ for (const g of matchLiveGroups(body?.groups || [], conns)) {
153
+ const k = g.threadUrl || g.people.join(',');
154
+ if (!groups[k] || (g.last || 0) >= (groups[k].last || 0)) groups[k] = g;
104
155
  }
105
- writeJsonAtomic(files.social, { ...saved, live, liveAt: new Date().toISOString() });
156
+ writeJsonAtomic(files.social, {
157
+ ...saved,
158
+ live: mergeLive(saved.live, live),
159
+ liveOthers: mergeLive(saved.liveOthers, others),
160
+ liveGroups: Object.values(groups),
161
+ liveAt: new Date().toISOString(),
162
+ });
106
163
  if (Object.keys(previews).length) {
107
164
  const kept = readJson(files.messages, {}) || {};
108
165
  writeJsonAtomic(files.messages, { ...kept, previews: { ...(kept.previews || {}), ...previews } });
109
166
  }
110
- return Response.json({ ok: true, people: Object.keys(live).length, unmatched, previews: Object.keys(previews).length });
167
+ return Response.json({ ok: true, people: Object.keys(live).length, unmatched, groups: Object.keys(groups).length, previews: Object.keys(previews).length });
111
168
  }
112
169
 
113
170
  // The Social tab's switches: "once a day" for the live messages sync, and
@@ -127,7 +184,8 @@ export async function PATCH(request) {
127
184
  return Response.json({ ok: true, autoSync: next.autoSync === true, keepMessages: next.keepMessages === true });
128
185
  }
129
186
 
130
- // Forget it: both files, the numbers and the words.
187
+ // Forget it: both files, the numbers and the words. Not your CRM notes: the
188
+ // page asks about those on their own (DELETE /api/social/crm).
131
189
  export async function DELETE() {
132
190
  const files = socialFiles();
133
191
  if (files) {
@@ -12,6 +12,7 @@ import {
12
12
  layoutsNow, watchLayouts, saveLayout, applyLayout, forgetLayout, milestones,
13
13
  } from '../../lib/galaxy-lab';
14
14
  import { savePicture, recordReplay, canRecord } from '../../lib/galaxy-export';
15
+ import { showActivity } from '../../lib/island';
15
16
 
16
17
  const noLayouts = [];
17
18
 
@@ -89,7 +90,9 @@ export default function GalaxyLab() {
89
90
  const run = async (what, job) => {
90
91
  setBusy(what);
91
92
  setNote(null);
92
- try { await job(); } catch (e) { setNote(e.message || 'That didn’t work.'); } finally { setBusy(null); }
93
+ // A recording runs for the whole replay: the notch under the header shows it.
94
+ const done = what === 'video' ? showActivity({ id: 'replay-video', label: 'Recording the replay', detail: 'The video saves when it ends' }) : () => {};
95
+ try { await job(); } catch (e) { setNote(e.message || 'That didn’t work.'); } finally { setBusy(null); done(); }
93
96
  };
94
97
  const canReplay = clock.min != null && clock.max != null && clock.max > clock.min;
95
98
  const preset = FORCES.every((f) => lab[f.key] === LAB_DEFAULTS[f.key]) ? 'rings'
@@ -1,18 +1,21 @@
1
1
  'use client';
2
2
 
3
- // The scan status bar: while a scan runs, every page shows what it's doing,
4
- // for whom, how far it's got, and today's LinkedIn budget, with Stop. Stop
5
- // saves what was read, and the Scan page's Resume carries on from that page.
6
- // It sits just under the header's tabs (Network Circle, Degrees…), on every
7
- // page, the Scan page too (Blake, 2026-09-29). It only reports: the
8
- // pacing and the caps live in the scanner (scripts/scrape.py, lib/linkedin-limits.js).
3
+ // The notch: a small pill hanging from the bottom of the header, centred, on
4
+ // every page (Blake, 2026-09-29: "a notch ui of the thing scanning"). It stays
5
+ // out of the way: nothing at all while nothing runs; a tiny dimmed "Auto scan"
6
+ // when the all-day mode is on but idle; while a scan runs, what it's doing and
7
+ // how far it's got. Hover or click it to open it: who, today's LinkedIn budget,
8
+ // the latest line, Details and Stop. Stop saves what was read, and the Scan
9
+ // page's Resume carries on from that page. Other long jobs can show here too
10
+ // (lib/island.js). It only reports: the pacing and the caps live in the scanner
11
+ // (scripts/scrape.py, lib/linkedin-limits.js).
9
12
 
10
13
  import { useEffect, useState, useSyncExternalStore } from 'react';
11
- import { createPortal } from 'react-dom';
12
14
  import { usePathname } from 'next/navigation';
13
15
  import Link from 'next/link';
14
16
  import { watchScanner, scannerNow, stopScrape } from '../../lib/scraper-client';
15
17
  import { watchAllDay, allDayNow } from '../../lib/experimental-client';
18
+ import { watchActivities, activitiesNow, noActivities } from '../../lib/island';
16
19
 
17
20
  const WHAT = {
18
21
  full: 'Scanning your network',
@@ -28,6 +31,7 @@ const WHAT = {
28
31
  company: 'Scanning a company',
29
32
  photos: 'Saving photos',
30
33
  messages: 'Reading your messages list',
34
+ 'messages-full': 'Reading your whole messages history',
31
35
  };
32
36
 
33
37
  // Green while there's plenty left, amber past 60%, red past 90%.
@@ -36,33 +40,35 @@ const tone = (used, cap) => (!cap ? '#8b9a9a' : used / cap >= 0.9 ? '#ff6b6b' :
36
40
  export default function ScanStatusBar() {
37
41
  const [job, setJob] = useState(() => scannerNow());
38
42
  const [stopping, setStopping] = useState(false);
39
- // The Scan page's experimental all-day Auto-Bridge switch: with it on, the bar
40
- // stays under the tabs even while nothing runs, so you can see the mode is on.
43
+ const [open, setOpen] = useState(false);
44
+ const [pinned, setPinned] = useState(false);
41
45
  const allDay = useSyncExternalStore(watchAllDay, allDayNow, () => false);
42
- // Where it sits: in the page's own slot under its header when the page has one
43
- // (it then pushes the page down rather than covering it), otherwise fixed just
44
- // under the header's buttons, measured, since a header can wrap to two rows.
46
+ const others = useSyncExternalStore(watchActivities, activitiesNow, noActivities);
47
+ useEffect(() => watchScanner(() => {
48
+ const now = scannerNow();
49
+ setJob(now);
50
+ if (!now.running) setStopping(false);
51
+ }), []);
52
+
53
+ // Hangs from the header's bottom edge, measured: the header can load late,
54
+ // be swapped for a new one, or wrap to two rows.
45
55
  const pathname = usePathname();
46
- const [spot, setSpot] = useState({ slot: null, top: null });
56
+ const [top, setTop] = useState(null);
47
57
  useEffect(() => {
48
- // The header can load late or be swapped for a new one (a page that shows
49
- // "Loading…" first), so it's found again whenever the page's elements change,
50
- // and its size is watched for a wrap to two rows.
51
58
  let header = null;
52
59
  let ro = null;
53
60
  let queued = 0;
54
61
  const measure = () => {
55
62
  queued = 0;
56
- const slot = document.getElementById('scan-bar-slot');
57
- const now = slot ? null : document.querySelector('header');
63
+ const now = document.querySelector('header');
58
64
  if (now !== header) {
59
65
  ro?.disconnect();
60
66
  ro = null;
61
67
  header = now;
62
68
  if (header) { ro = new ResizeObserver(later); ro.observe(header); }
63
69
  }
64
- const top = header ? Math.round(header.getBoundingClientRect().bottom) + 6 : null;
65
- setSpot((was) => (was.slot === slot && was.top === top ? was : { slot, top }));
70
+ const at = header ? Math.round(header.getBoundingClientRect().bottom) : null;
71
+ setTop((was) => (was === at ? was : at));
66
72
  };
67
73
  const later = () => { if (!queued) queued = requestAnimationFrame(measure); };
68
74
  later();
@@ -71,63 +77,83 @@ export default function ScanStatusBar() {
71
77
  window.addEventListener('resize', later);
72
78
  return () => { cancelAnimationFrame(queued); ro?.disconnect(); mo.disconnect(); window.removeEventListener('resize', later); };
73
79
  }, [pathname]);
74
- useEffect(() => watchScanner(() => {
75
- const now = scannerNow();
76
- setJob(now);
77
- if (!now.running) setStopping(false);
78
- }), []);
79
- const place = {
80
- ...(spot.slot
81
- ? { position: 'relative', margin: '8px auto 12px', width: 'fit-content' }
82
- : { position: 'fixed', left: '50%', top: spot.top != null ? spot.top : 'var(--scan-bar-top, 94px)', transform: 'translateX(-50%)', zIndex: 60 }),
83
- maxWidth: 'calc(100vw - 32px)', display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'nowrap', whiteSpace: 'nowrap',
84
- padding: '8px 12px', borderRadius: 12, fontSize: 12, color: '#cfd8d8', background: 'rgba(14,16,32,0.94)',
85
- };
86
- const put = (bar) => (spot.slot && document.body.contains(spot.slot) ? createPortal(bar, spot.slot) : bar);
87
- if (!job?.running) {
88
- if (!allDay) return null;
89
- return put(
90
- <div role="status" style={{ ...place, opacity: 0.55, border: '1px solid rgba(255,255,255,0.1)' }}>
91
- <span style={{ width: 8, height: 8, borderRadius: '50%', background: '#556', flexShrink: 0 }} />
92
- <b style={{ color: '#b8c4c4' }}>Auto scan</b>
93
- <span style={{ color: '#8b9a9a' }}>ready · press Auto scan beside Scan to start</span>
94
- </div>
95
- );
96
- }
97
80
 
98
- const p = job.progress;
81
+ const running = !!job?.running;
82
+ const other = others[others.length - 1] || null;
83
+ if (!running && !other && !allDay) return null;
84
+
85
+ const p = job?.progress;
99
86
  const step = p?.kind === 'batch' && p.total ? `${p.current || p.done || 0} of ${p.total}`
100
- : p?.kind === 'walk' && p.total ? `${(p.done || 0).toLocaleString()} of ${p.total.toLocaleString()} read`
87
+ : p?.kind === 'walk' && p.total ? `${(p.done || 0).toLocaleString()} of ${p.total.toLocaleString()}`
101
88
  : p?.kind === 'saving' ? 'saving' : null;
102
- const b = job.budget;
103
- const last = [...(job.log || [])].reverse().find((l) => l && l.trim())?.trim();
89
+ const fraction = p?.total ? Math.min(1, (p.current || p.done || 0) / p.total) : other?.progress ?? null;
90
+ const b = job?.budget;
91
+ const last = running ? [...(job.log || [])].reverse().find((l) => l && l.trim())?.trim() : null;
92
+ const expanded = open || pinned;
104
93
 
105
- return put(
106
- <div role="status" aria-live="polite" style={{ ...place, border: '1px solid rgba(0,255,136,0.25)' }}>
107
- <span style={{ width: 8, height: 8, borderRadius: '50%', background: '#00ff88', flexShrink: 0 }} />
108
- <b style={{ color: '#fff' }}>{WHAT[job.action] || 'Scanning'}</b>
109
- {job.target?.name && <span>{job.target.name}</span>}
110
- {step && <span style={{ color: '#8b9a9a' }}>{step}</span>}
111
- {b && (
112
- <span style={{ color: tone(b.searches, b.cap) }} title="Searches on your LinkedIn account in the last 24 hours, and your daily budget">
113
- {b.searches}{b.cap ? ` of ${b.cap}` : ''} searches today
114
- </span>
94
+ // What the pill says, smallest first.
95
+ const dot = running ? '#00ff88' : other ? (other.tone === 'warn' ? '#FFD700' : '#3498DB') : '#556';
96
+ const label = running ? (WHAT[job.action] || 'Scanning') : other ? other.label : 'Auto scan';
97
+ const short = running ? step : other ? other.detail : null;
98
+
99
+ return (
100
+ <div
101
+ role="status"
102
+ aria-live="polite"
103
+ onMouseEnter={() => setOpen(true)}
104
+ onMouseLeave={() => setOpen(false)}
105
+ style={{
106
+ position: 'fixed', left: '50%', top: top ?? 'var(--scan-bar-top, 94px)', transform: 'translateX(-50%)', zIndex: 60,
107
+ maxWidth: 'calc(100vw - 32px)', minWidth: running || other ? 180 : 0,
108
+ padding: expanded ? '8px 14px 10px' : '4px 12px 6px',
109
+ borderRadius: '0 0 16px 16px', borderTop: 'none',
110
+ border: `1px solid ${running ? 'rgba(0,255,136,0.25)' : 'rgba(255,255,255,0.1)'}`,
111
+ background: 'rgba(8,10,22,0.96)', color: '#cfd8d8', fontSize: 12,
112
+ opacity: running || other || expanded ? 1 : 0.55,
113
+ boxShadow: running ? '0 6px 20px rgba(0,0,0,0.35)' : 'none',
114
+ transition: 'padding 0.18s ease, opacity 0.18s ease',
115
+ }}
116
+ >
117
+ <button
118
+ onClick={() => setPinned((v) => !v)}
119
+ aria-expanded={expanded}
120
+ title={expanded ? 'Close' : 'Open'}
121
+ style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', background: 'none', border: 'none', color: 'inherit', font: 'inherit', cursor: 'pointer', padding: 0, whiteSpace: 'nowrap' }}
122
+ >
123
+ <span className={running ? 'notch-pulse' : undefined} style={{ width: 7, height: 7, borderRadius: '50%', background: dot, flexShrink: 0 }} />
124
+ <b style={{ color: running || other ? '#fff' : '#b8c4c4', fontWeight: 600 }}>{label}</b>
125
+ {short && <span style={{ color: '#8b9a9a' }}>{short}</span>}
126
+ {!running && !other && expanded && <span style={{ color: '#8b9a9a' }}>ready · press Auto scan beside Scan to start</span>}
127
+ </button>
128
+ {fraction != null && (
129
+ <div style={{ height: 2, marginTop: 5, borderRadius: 2, background: 'rgba(255,255,255,0.08)', overflow: 'hidden' }}>
130
+ <div style={{ width: `${Math.round(fraction * 100)}%`, height: '100%', background: running ? '#00ff88' : '#3498DB', transition: 'width 0.4s ease' }} />
131
+ </div>
115
132
  )}
116
- {last && (
117
- <span style={{ color: '#667', flex: '0 1 240px', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={last}>
118
- {last}
119
- </span>
133
+ {expanded && running && (
134
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 8, flexWrap: 'wrap' }}>
135
+ {job.target?.name && <span>{job.target.name}</span>}
136
+ {b && (
137
+ <span style={{ color: tone(b.searches, b.cap) }} title="Searches on your LinkedIn account in the last 24 hours, and your daily budget">
138
+ {b.searches}{b.cap ? ` of ${b.cap}` : ''} searches today
139
+ </span>
140
+ )}
141
+ {last && (
142
+ <span style={{ color: '#667', maxWidth: 280, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={last}>{last}</span>
143
+ )}
144
+ <Link href="/setup" style={{ color: '#3498DB', textDecoration: 'none' }}>Details</Link>
145
+ <button
146
+ onClick={async () => { setStopping(true); try { await stopScrape(); } catch { setStopping(false); } }}
147
+ disabled={stopping}
148
+ title="Stops after saving what's been read; Resume on the Scan page carries on from the same page"
149
+ style={{
150
+ padding: '3px 10px', borderRadius: 8, fontSize: 12, cursor: stopping ? 'default' : 'pointer',
151
+ background: 'rgba(255,107,107,0.12)', color: '#ff9b9b', border: '1px solid rgba(255,107,107,0.35)',
152
+ }}
153
+ >{stopping ? 'Stopping…' : 'Stop'}</button>
154
+ </div>
120
155
  )}
121
- <Link href="/setup" style={{ color: '#3498DB', textDecoration: 'none' }}>Details</Link>
122
- <button
123
- onClick={async () => { setStopping(true); try { await stopScrape(); } catch { setStopping(false); } }}
124
- disabled={stopping}
125
- title="Stops after saving what's been read; Resume on the Scan page carries on from the same page"
126
- style={{
127
- padding: '4px 10px', borderRadius: 8, fontSize: 12, cursor: stopping ? 'default' : 'pointer',
128
- background: 'rgba(255,107,107,0.12)', color: '#ff9b9b', border: '1px solid rgba(255,107,107,0.35)',
129
- }}
130
- >{stopping ? 'Stopping…' : 'Stop'}</button>
156
+ {expanded && !running && other?.detail && <div style={{ marginTop: 6, color: '#8b9a9a' }}>{other.detail}</div>}
131
157
  </div>
132
158
  );
133
159
  }
@@ -79,6 +79,7 @@ export default function DataSection() {
79
79
  const [exportNote, setExportNote] = useState(null);
80
80
  const [importNote, setImportNote] = useState(null);
81
81
  const [photos, setPhotos] = useState(true);
82
+ const [social, setSocial] = useState(true);
82
83
  const [file, setFile] = useState(null);
83
84
  const [confirmed, setConfirmed] = useState(false);
84
85
  const [restarting, setRestarting] = useState(false);
@@ -137,7 +138,7 @@ export default function DataSection() {
137
138
  const res = await fetch('/api/data/export', {
138
139
  method: 'POST',
139
140
  headers: { 'Content-Type': 'application/json' },
140
- body: JSON.stringify({ photos }),
141
+ body: JSON.stringify({ photos, social }),
141
142
  });
142
143
  if (!res.ok) await answer(res, 'The copy could not be made.');
143
144
  const blob = await res.blob();
@@ -324,6 +325,13 @@ export default function DataSection() {
324
325
  They can’t be downloaded again without a new scan.
325
326
  </span>
326
327
  </label>
328
+ <label style={check}>
329
+ <input type="checkbox" checked={social} onChange={(e) => setSocial(e.target.checked)} style={{ marginTop: 3 }} />
330
+ <span>
331
+ Include the Social tab: its findings, your CRM notes, stages and follow-ups, and any messages you keep
332
+ there.
333
+ </span>
334
+ </label>
327
335
  <div style={row}>
328
336
  <Btn primary onClick={exportNow} disabled={!!busy || restarting || info.people === 0}>
329
337
  {busy === 'export' ? 'Saving…' : 'Save a copy of my network'}
@@ -334,8 +342,9 @@ export default function DataSection() {
334
342
  <Body style={small}>
335
343
  The copy holds the names, headlines and photos of the people in your network. Keep it private: don’t
336
344
  post it or share it, and delete it once it has been imported. A network opened from a LinkedIn CSV lives
337
- only in its browser tab, so it isn’t in the copy; import the CSV again on the new computer. The Social
338
- tab’s findings, and any messages you keep there, stay on this computer and aren’t in the copy either.
345
+ only in its browser tab, so it isn’t in the copy; import the CSV again on the new computer. With the
346
+ Social tab included, the copy also holds your CRM notes and any messages you keep, so it&rsquo;s as private
347
+ as your inbox.
339
348
  </Body>
340
349
 
341
350
  <h3 style={subhead}>Bring in a network from another computer</h3>
@@ -42,3 +42,18 @@ html:has([data-field-question]) {
42
42
  /* Where the scan status bar sits: just under the header (app/components/ScanStatusBar.js). */
43
43
  :root { --scan-bar-top: 94px; }
44
44
  @media (max-width: 767px) { :root { --scan-bar-top: 66px; } }
45
+
46
+ /* The notch's running dot (app/components/ScanStatusBar.js). */
47
+ @keyframes notch-pulse { 50% { opacity: 0.35; } }
48
+ @media (prefers-reduced-motion: no-preference) { .notch-pulse { animation: notch-pulse 1.6s ease-in-out infinite; } }
49
+
50
+ /* The Social tab's CRM (app/social/Crm.js): the list and the person side by
51
+ side, stacked on a phone. In Pipeline the board takes the whole width and
52
+ the person sits under it. */
53
+ .crm-grid { display: grid; grid-template-columns: minmax(280px, 400px) minmax(0, 1fr); gap: 10px; align-items: start; }
54
+ .crm-grid > .crm-wide { grid-column: 1 / -1; }
55
+ .crm-board { display: grid; grid-template-columns: repeat(6, minmax(150px, 1fr)); gap: 10px; padding: 10px; overflow-x: auto; }
56
+ @media (max-width: 760px) {
57
+ .crm-grid { grid-template-columns: minmax(0, 1fr); }
58
+ .crm-board { grid-template-columns: minmax(0, 1fr); }
59
+ }
@@ -302,8 +302,6 @@ function PathsInner() {
302
302
  )}
303
303
  </div>
304
304
  </header>
305
- {/* The scan status bar sits here, under the header's buttons (ScanStatusBar.js). */}
306
- <div id="scan-bar-slot" />
307
305
 
308
306
  {!selectedCompany && tab !== 'companies' ? (
309
307
  <PathsAnalyzer d1={d1Data} d2={d2Data} d3={d3Data} tab={tab}
@@ -164,8 +164,6 @@ function ProfileInner() {
164
164
  WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent',
165
165
  }}>Profile</h1>
166
166
  </header>
167
- {/* The scan status bar sits here, under the header's buttons (ScanStatusBar.js). */}
168
- <div id="scan-bar-slot" />
169
167
 
170
168
  <div style={{ flex: 1, overflow: 'auto', padding: '30px 20px' }}>
171
169
  <div style={{ maxWidth: 700, margin: '0 auto' }}>
@@ -301,8 +301,6 @@ function QueueInner() {
301
301
  ))}
302
302
  </div>
303
303
  </header>
304
- {/* The scan status bar sits here, under the header's buttons (ScanStatusBar.js). */}
305
- <div id="scan-bar-slot" />
306
304
 
307
305
  {/* Batch action bar — recs view only */}
308
306
  {view === 'recs' && selected.size > 0 && (
@@ -56,8 +56,6 @@ function ScoresInner() {
56
56
  WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent',
57
57
  }}>Scores</h1>
58
58
  </header>
59
- {/* The scan status bar sits here, under the header's buttons (ScanStatusBar.js). */}
60
- <div id="scan-bar-slot" />
61
59
  <main style={{ maxWidth: 980, margin: '0 auto', padding: '8px 24px 64px' }}>
62
60
  <Body style={{ marginTop: 16 }}>
63
61
  How someone&rsquo;s power score is worked out, and the three things you can change about it:{' '}
@@ -73,8 +73,6 @@ function SettingsInner() {
73
73
  WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent',
74
74
  }}>Settings</h1>
75
75
  </header>
76
- {/* The scan status bar sits here, under the header's buttons (ScanStatusBar.js). */}
77
- <div id="scan-bar-slot" />
78
76
 
79
77
  <main style={{ maxWidth: 720, margin: '0 auto', padding: '8px 24px 64px' }}>
80
78
  {error && <Body style={{ color: '#ff7676', marginTop: 16 }}>{error}</Body>}
@@ -203,8 +203,6 @@ function SetupInner() {
203
203
  WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent',
204
204
  }}>Scan your network</h1>
205
205
  </header>
206
- {/* The scan status bar sits here, under the header's buttons (ScanStatusBar.js). */}
207
- <div id="scan-bar-slot" />
208
206
 
209
207
  <div style={{ maxWidth: 720, margin: '0 auto', padding: '32px 24px 64px' }}>
210
208