@crouton-kit/crouter 0.3.28 → 0.3.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-root.d.ts +15 -7
- package/dist/build-root.js +48 -34
- package/dist/builtin-memory/crouter-development/marketplaces.md +7 -6
- package/dist/builtin-memory/crouter-development/personas/base-prompt.md +1 -1
- package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +1 -1
- package/dist/builtin-memory/crouter-development/personas.md +1 -1
- package/dist/builtin-memory/crouter-development/plugins.md +20 -18
- package/dist/builtin-memory/design.md +1 -1
- package/dist/builtin-memory/development.md +1 -1
- package/dist/builtin-memory/internal/INDEX.md +1 -1
- package/dist/builtin-memory/internal/examples/INDEX.md +1 -1
- package/dist/builtin-memory/internal/examples/imessage-assistant.md +2 -2
- package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
- package/dist/builtin-memory/internal/storage-tiers.md +1 -1
- package/dist/builtin-memory/planning.md +1 -1
- package/dist/builtin-memory/spec.md +6 -4
- package/dist/builtin-personas/orchestration-kernel.md +4 -5
- package/dist/builtin-personas/runtime-base.md +5 -4
- package/dist/builtin-personas/spec/PERSONA.md +8 -4
- package/dist/builtin-personas/spec/orchestrator.md +5 -3
- package/dist/builtin-personas/spec/requirements/PERSONA.md +10 -0
- package/dist/builtin-views/canvas/core.mjs +586 -0
- package/dist/builtin-views/canvas/text.mjs +58 -0
- package/dist/builtin-views/canvas/tui.mjs +165 -0
- package/dist/builtin-views/canvas/web.jsx +120 -0
- package/dist/builtin-views/git-pr/core.mjs +673 -0
- package/dist/builtin-views/git-pr/text.mjs +84 -0
- package/dist/builtin-views/git-pr/tui.mjs +302 -0
- package/dist/builtin-views/git-pr/web.jsx +216 -0
- package/dist/builtin-views/inbox/_lib/render.mjs +1 -3
- package/dist/builtin-views/inbox/core.mjs +1273 -0
- package/dist/builtin-views/inbox/text.mjs +73 -0
- package/dist/builtin-views/inbox/tui.mjs +312 -0
- package/dist/builtin-views/inbox/web.jsx +188 -0
- package/dist/builtin-views/linkedin/core.mjs +906 -0
- package/dist/builtin-views/linkedin/text.mjs +69 -0
- package/dist/builtin-views/linkedin/tui.mjs +427 -0
- package/dist/builtin-views/linkedin/web.jsx +206 -0
- package/dist/builtin-views/workspace-sidebar/core.mjs +621 -0
- package/dist/builtin-views/workspace-sidebar/text.mjs +53 -0
- package/dist/builtin-views/workspace-sidebar/tui.mjs +142 -0
- package/dist/builtin-views/workspace-sidebar/web.jsx +109 -0
- package/dist/cli.js +22 -17
- package/dist/clients/attach/__tests__/action-parity.test.js +48 -0
- package/dist/clients/attach/__tests__/clipboard-image.test.js +76 -0
- package/dist/clients/attach/__tests__/editor-newline.test.js +57 -0
- package/dist/clients/attach/__tests__/git-info.test.js +31 -0
- package/dist/clients/attach/__tests__/onrequest-wired.test.js +20 -0
- package/dist/clients/attach/__tests__/slash-quit-copy.test.js +42 -0
- package/dist/clients/attach/__tests__/titled-editor.test.js +39 -0
- package/dist/clients/attach/attach-cmd.js +282 -46
- package/dist/clients/attach/auth-pickers.d.ts +9 -0
- package/dist/clients/attach/auth-pickers.js +194 -0
- package/dist/clients/attach/canvas-panels.d.ts +4 -2
- package/dist/clients/attach/canvas-panels.js +45 -17
- package/dist/clients/attach/chat-view.d.ts +27 -2
- package/dist/clients/attach/chat-view.js +98 -9
- package/dist/clients/attach/clipboard-image.d.ts +47 -8
- package/dist/clients/attach/clipboard-image.js +150 -26
- package/dist/clients/attach/clipboard-text.d.ts +3 -0
- package/dist/clients/attach/clipboard-text.js +39 -0
- package/dist/clients/attach/config-load.d.ts +54 -7
- package/dist/clients/attach/config-load.js +99 -3
- package/dist/clients/attach/context-message.d.ts +12 -0
- package/dist/clients/attach/context-message.js +72 -0
- package/dist/clients/attach/git-info.d.ts +20 -0
- package/dist/clients/attach/git-info.js +44 -0
- package/dist/clients/attach/graph-overlay.d.ts +13 -4
- package/dist/clients/attach/graph-overlay.js +59 -19
- package/dist/clients/attach/input-controller.d.ts +75 -13
- package/dist/clients/attach/input-controller.js +305 -67
- package/dist/clients/attach/pickers.d.ts +58 -0
- package/dist/clients/attach/pickers.js +171 -0
- package/dist/clients/attach/slash-commands.d.ts +22 -0
- package/dist/clients/attach/slash-commands.js +91 -25
- package/dist/clients/attach/titled-editor.d.ts +33 -0
- package/dist/clients/attach/titled-editor.js +84 -0
- package/dist/clients/attach/view-socket.d.ts +18 -1
- package/dist/clients/attach/view-socket.js +70 -1
- package/dist/clients/web/dev-server.d.ts +7 -0
- package/dist/clients/web/dev-server.js +59 -0
- package/dist/clients/web/events.d.ts +14 -0
- package/dist/clients/web/events.js +151 -0
- package/dist/clients/web/server.d.ts +18 -0
- package/dist/clients/web/server.js +450 -0
- package/dist/clients/web/web-cmd.d.ts +2 -0
- package/dist/clients/web/web-cmd.js +120 -0
- package/dist/commands/canvas.js +1 -2
- package/dist/commands/chord.js +4 -25
- package/dist/commands/dashboard.js +5 -1
- package/dist/commands/memory/__tests__/lint-schema.test.js +6 -6
- package/dist/commands/memory/lint.js +14 -2
- package/dist/commands/memory/read.js +2 -2
- package/dist/commands/memory/shared.d.ts +7 -1
- package/dist/commands/memory/shared.js +18 -3
- package/dist/commands/memory/write.js +8 -4
- package/dist/commands/memory.js +4 -4
- package/dist/commands/node.d.ts +1 -1
- package/dist/commands/node.js +64 -65
- package/dist/commands/pkg.js +1 -2
- package/dist/commands/revive.js +11 -3
- package/dist/commands/sys/sync.d.ts +1 -0
- package/dist/commands/sys/sync.js +187 -0
- package/dist/commands/sys.js +3 -2
- package/dist/commands/view-cycle.js +2 -2
- package/dist/commands/view-list.js +8 -8
- package/dist/commands/view-new.js +21 -17
- package/dist/commands/view-pick.js +1 -1
- package/dist/commands/view-run.js +35 -14
- package/dist/commands/view.js +8 -6
- package/dist/commands/workspace.d.ts +2 -0
- package/dist/commands/workspace.js +161 -0
- package/dist/core/__tests__/broker-double-spawn.test.d.ts +1 -0
- package/dist/core/__tests__/broker-double-spawn.test.js +143 -0
- package/dist/core/__tests__/broker-fork-seam.test.d.ts +1 -0
- package/dist/core/__tests__/broker-fork-seam.test.js +102 -0
- package/dist/core/__tests__/broker-sdk-wiring.test.js +2 -0
- package/dist/core/__tests__/child-death-wake.test.js +56 -111
- package/dist/core/__tests__/close.test.js +14 -20
- package/dist/core/__tests__/context-intro.test.js +19 -19
- package/dist/core/__tests__/daemon-boot.test.js +30 -23
- package/dist/core/__tests__/draw-style.test.js +1 -1
- package/dist/core/__tests__/error-stall-recycle.test.d.ts +1 -0
- package/dist/core/__tests__/error-stall-recycle.test.js +141 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +17 -1
- package/dist/core/__tests__/fixtures/fake-engine.js +22 -2
- package/dist/core/__tests__/fixtures/fake-pi-host.js +2 -2
- package/dist/core/__tests__/focuses.test.js +25 -13
- package/dist/core/__tests__/fork.test.js +22 -5
- package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +1 -0
- package/dist/core/__tests__/full/broker-control-preempt.test.js +61 -0
- package/dist/core/__tests__/full/broker-model-changed-broadcast.test.d.ts +1 -0
- package/dist/core/__tests__/full/broker-model-changed-broadcast.test.js +57 -0
- package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.d.ts +1 -0
- package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.js +80 -0
- package/dist/core/__tests__/full/broker-pane-resolution.test.js +90 -0
- package/dist/core/__tests__/full/cascade-close.test.js +22 -8
- package/dist/core/__tests__/full/detach-focus.test.js +66 -169
- package/dist/core/__tests__/helpers/harness.d.ts +6 -9
- package/dist/core/__tests__/helpers/harness.js +19 -36
- package/dist/core/__tests__/human-node-not-supervised.test.d.ts +1 -0
- package/dist/core/__tests__/human-node-not-supervised.test.js +83 -0
- package/dist/core/__tests__/lifecycle.test.js +1 -19
- package/dist/core/__tests__/listing-completeness.test.js +2 -2
- package/dist/core/__tests__/live-mutation-verbs.test.js +5 -5
- package/dist/core/__tests__/memory-resolver.test.js +1 -1
- package/dist/core/__tests__/on-read-dedup-resume.test.js +2 -2
- package/dist/core/__tests__/refresh-stall-recycle.test.d.ts +1 -0
- package/dist/core/__tests__/refresh-stall-recycle.test.js +88 -0
- package/dist/core/__tests__/relaunch-root.test.d.ts +1 -0
- package/dist/core/__tests__/relaunch-root.test.js +114 -0
- package/dist/core/__tests__/reset.test.js +19 -79
- package/dist/core/__tests__/steer-note.test.js +9 -7
- package/dist/core/__tests__/tmux-surface.test.js +13 -7
- package/dist/core/bootstrap.d.ts +0 -3
- package/dist/core/bootstrap.js +1 -143
- package/dist/core/canvas/browse/__tests__/model.test.js +86 -1
- package/dist/core/canvas/browse/__tests__/render.test.js +2 -1
- package/dist/core/canvas/browse/app.js +131 -8
- package/dist/core/canvas/browse/model.d.ts +23 -5
- package/dist/core/canvas/browse/model.js +68 -13
- package/dist/core/canvas/browse/render.d.ts +7 -1
- package/dist/core/canvas/browse/render.js +178 -47
- package/dist/core/canvas/canvas.js +1 -1
- package/dist/core/canvas/focuses.d.ts +4 -4
- package/dist/core/canvas/focuses.js +7 -7
- package/dist/core/canvas/nav-model.js +8 -7
- package/dist/core/canvas/render.d.ts +68 -9
- package/dist/core/canvas/render.js +195 -61
- package/dist/core/canvas/types.d.ts +16 -23
- package/dist/core/config.js +1 -4
- package/dist/core/memory-resolver.js +43 -5
- package/dist/core/runtime/bearings.d.ts +1 -1
- package/dist/core/runtime/bearings.js +8 -8
- package/dist/core/runtime/branded-host.d.ts +16 -0
- package/dist/core/runtime/branded-host.js +127 -0
- package/dist/core/runtime/broker-protocol.d.ts +212 -6
- package/dist/core/runtime/broker.d.ts +4 -1
- package/dist/core/runtime/broker.js +494 -58
- package/dist/core/runtime/close.d.ts +12 -2
- package/dist/core/runtime/close.js +37 -16
- package/dist/core/runtime/front-door.js +6 -14
- package/dist/core/runtime/host.d.ts +14 -34
- package/dist/core/runtime/host.js +14 -50
- package/dist/core/runtime/launch.d.ts +12 -9
- package/dist/core/runtime/launch.js +27 -15
- package/dist/core/runtime/lifecycle.d.ts +1 -1
- package/dist/core/runtime/lifecycle.js +15 -19
- package/dist/core/runtime/nodes.d.ts +0 -55
- package/dist/core/runtime/nodes.js +6 -74
- package/dist/core/runtime/placement.d.ts +91 -335
- package/dist/core/runtime/placement.js +291 -858
- package/dist/core/runtime/promote.d.ts +2 -0
- package/dist/core/runtime/promote.js +24 -6
- package/dist/core/runtime/recap.d.ts +8 -0
- package/dist/core/runtime/recap.js +107 -0
- package/dist/core/runtime/recycle.js +25 -61
- package/dist/core/runtime/reset.d.ts +43 -37
- package/dist/core/runtime/reset.js +131 -218
- package/dist/core/runtime/revive.d.ts +9 -29
- package/dist/core/runtime/revive.js +40 -139
- package/dist/core/runtime/spawn.d.ts +15 -16
- package/dist/core/runtime/spawn.js +165 -194
- package/dist/core/runtime/surface-bg.d.ts +11 -0
- package/dist/core/runtime/surface-bg.js +68 -0
- package/dist/core/runtime/tmux.d.ts +15 -52
- package/dist/core/runtime/tmux.js +35 -99
- package/dist/core/scope.d.ts +1 -2
- package/dist/core/scope.js +1 -5
- package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.d.ts +1 -0
- package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.js +57 -0
- package/dist/core/skill-sync/builtins.d.ts +42 -0
- package/dist/core/skill-sync/builtins.js +112 -0
- package/dist/core/skill-sync/claude-plugins.d.ts +23 -0
- package/dist/core/skill-sync/claude-plugins.js +71 -0
- package/dist/core/skill-sync/engine.d.ts +42 -0
- package/dist/core/skill-sync/engine.js +633 -0
- package/dist/core/skill-sync/export.d.ts +23 -0
- package/dist/core/skill-sync/export.js +86 -0
- package/dist/core/skill-sync/manifest.d.ts +64 -0
- package/dist/core/skill-sync/manifest.js +181 -0
- package/dist/core/skill-sync/profile.d.ts +76 -0
- package/dist/core/skill-sync/profile.js +173 -0
- package/dist/core/skill-sync/snapshot.d.ts +57 -0
- package/dist/core/skill-sync/snapshot.js +120 -0
- package/dist/core/substrate/index.d.ts +1 -1
- package/dist/core/substrate/index.js +1 -1
- package/dist/core/substrate/injected-store.js +3 -3
- package/dist/core/substrate/on-read.js +68 -6
- package/dist/core/substrate/render.d.ts +8 -11
- package/dist/core/substrate/render.js +29 -43
- package/dist/core/substrate/schema.d.ts +10 -3
- package/dist/core/substrate/schema.js +6 -3
- package/dist/core/tui/host.d.ts +12 -4
- package/dist/core/tui/host.js +280 -149
- package/dist/core/view/bridge.d.ts +10 -0
- package/dist/core/view/bridge.js +31 -0
- package/dist/core/view/chrome.d.ts +9 -0
- package/dist/core/view/chrome.js +22 -0
- package/dist/core/view/contract.d.ts +171 -0
- package/dist/core/view/contract.js +23 -0
- package/dist/core/view/loader.d.ts +31 -0
- package/dist/core/view/loader.js +188 -0
- package/dist/core/view/transport-local.d.ts +7 -0
- package/dist/core/view/transport-local.js +70 -0
- package/dist/core/view/transport.d.ts +4 -0
- package/dist/core/view/transport.js +15 -0
- package/dist/daemon/crtrd.d.ts +38 -27
- package/dist/daemon/crtrd.js +303 -376
- package/dist/daemon/manage.js +6 -1
- package/dist/index.js +1 -1
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +28 -20
- package/dist/pi-extensions/canvas-context-intro.js +4 -4
- package/dist/pi-extensions/canvas-doc-substrate.js +16 -16
- package/dist/pi-extensions/canvas-nav.js +5 -0
- package/dist/pi-extensions/canvas-recap.d.ts +37 -0
- package/dist/pi-extensions/canvas-recap.js +236 -0
- package/dist/pi-extensions/canvas-resume.js +2 -1
- package/dist/pi-extensions/canvas-stophook.d.ts +14 -12
- package/dist/pi-extensions/canvas-stophook.js +87 -101
- package/dist/pi-extensions/canvas-view.js +2 -1
- package/dist/pi-extensions/widget-order-bus.d.ts +6 -0
- package/dist/pi-extensions/widget-order-bus.js +34 -0
- package/dist/prompts/view.d.ts +2 -2
- package/dist/prompts/view.js +145 -73
- package/dist/types.d.ts +1 -6
- package/dist/types.js +1 -3
- package/dist/web/ViewChrome.d.ts +7 -0
- package/dist/web/ViewChrome.js +28 -0
- package/dist/web/ViewPane.d.ts +39 -0
- package/dist/web/ViewPane.js +48 -0
- package/dist/web/index.d.ts +6 -0
- package/dist/web/index.js +16 -0
- package/dist/web/runtime.d.ts +39 -0
- package/dist/web/runtime.js +133 -0
- package/dist/web/states.d.ts +24 -0
- package/dist/web/states.js +24 -0
- package/dist/web/transport-http.d.ts +5 -0
- package/dist/web/transport-http.js +28 -0
- package/dist/web-client/assets/index-BUvQb4hR.css +2 -0
- package/dist/web-client/assets/index-ClLQXYAE.js +10 -0
- package/dist/web-client/index.html +13 -0
- package/package.json +20 -6
- package/dist/builtin-views/canvas/client.mjs +0 -303
- package/dist/builtin-views/canvas/view.mjs +0 -576
- package/dist/builtin-views/git-pr/client.mjs +0 -440
- package/dist/builtin-views/git-pr/view.mjs +0 -675
- package/dist/builtin-views/inbox/sources/gmail.mjs +0 -965
- package/dist/builtin-views/inbox/sources/linkedin.mjs +0 -427
- package/dist/builtin-views/inbox/view.mjs +0 -889
- package/dist/builtin-views/linkedin/client.mjs +0 -610
- package/dist/builtin-views/linkedin/view.mjs +0 -1171
- package/dist/commands/pkg/bridge.d.ts +0 -1
- package/dist/commands/pkg/bridge.js +0 -137
- package/dist/commands/skill/author.d.ts +0 -3
- package/dist/commands/skill/author.js +0 -140
- package/dist/commands/skill/shared.d.ts +0 -3
- package/dist/commands/skill/shared.js +0 -19
- package/dist/commands/skill.d.ts +0 -2
- package/dist/commands/skill.js +0 -21
- package/dist/commands/tmux-spread.d.ts +0 -2
- package/dist/commands/tmux-spread.js +0 -144
- package/dist/core/__tests__/full/daemon-liveness-pane.full.test.js +0 -292
- package/dist/core/__tests__/full/placement-focus.test.js +0 -309
- package/dist/core/__tests__/full/placement-reconcile.test.js +0 -212
- package/dist/core/__tests__/full/placement-revive.test.js +0 -238
- package/dist/core/__tests__/full/placement-teardown.test.js +0 -297
- package/dist/core/__tests__/home-session.test.js +0 -186
- package/dist/core/__tests__/relaunch.test.js +0 -335
- package/dist/core/bridge-map.d.ts +0 -19
- package/dist/core/bridge-map.js +0 -73
- package/dist/core/tui/contract.d.ts +0 -83
- package/dist/core/tui/contract.js +0 -8
- package/dist/core/tui/loader.d.ts +0 -16
- package/dist/core/tui/loader.js +0 -94
- package/dist/prompts/skill.d.ts +0 -2
- package/dist/prompts/skill.js +0 -650
- /package/dist/{core/__tests__/full/daemon-liveness-pane.full.test.d.ts → clients/attach/__tests__/action-parity.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/placement-focus.test.d.ts → clients/attach/__tests__/clipboard-image.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/placement-reconcile.test.d.ts → clients/attach/__tests__/editor-newline.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/placement-revive.test.d.ts → clients/attach/__tests__/git-info.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/placement-teardown.test.d.ts → clients/attach/__tests__/onrequest-wired.test.d.ts} +0 -0
- /package/dist/{core/__tests__/home-session.test.d.ts → clients/attach/__tests__/slash-quit-copy.test.d.ts} +0 -0
- /package/dist/{core/__tests__/relaunch.test.d.ts → clients/attach/__tests__/titled-editor.test.d.ts} +0 -0
|
@@ -0,0 +1,1273 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Combined `inbox` view — the PORTABLE CORE (manifest · init · sources ·
|
|
4
|
+
* commands · intents). One core renders in BOTH targets: the tmux TUI
|
|
5
|
+
* (`crtr view run inbox`, via `tui.mjs`) and the React+Tailwind web page
|
|
6
|
+
* (`crtr view serve inbox`, via `web.jsx`).
|
|
7
|
+
*
|
|
8
|
+
* Runs in BOTH Node and the browser, so it imports nothing host-bound — no
|
|
9
|
+
* `node:*`, no crtr. The LinkedIn data layer lives ONCE in the sibling
|
|
10
|
+
* browser-safe `../linkedin/core.mjs` (shared `Source`/`Command` descriptors +
|
|
11
|
+
* the single `classify`/`applyFromMe`), imported here rather than duplicated.
|
|
12
|
+
* Gmail's data logic is inlined as transport-agnostic `Source`/`Command`
|
|
13
|
+
* descriptors (`{id, request, parse}`): the core describes WHAT capture command
|
|
14
|
+
* to run, the host's Transport runs it (local `execFile` for the TUI, the HTTP
|
|
15
|
+
* bridge for web), and the pure `parse()` turns bytes → typed data | typed
|
|
16
|
+
* `SourceError`.
|
|
17
|
+
*
|
|
18
|
+
* NOTHING throws. Sources return a `Result<T>`; each source owns its
|
|
19
|
+
* discover→auth→settle recovery state machine, restated in the `refresh` intent
|
|
20
|
+
* via `ctx.resolve`/`ctx.execute`. Partial readiness is mandatory: a down source
|
|
21
|
+
* contributes zero rows + its own slim banner; every ready source's rows show.
|
|
22
|
+
*
|
|
23
|
+
* @module inbox/core
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
discoverTabSource,
|
|
28
|
+
contextSource,
|
|
29
|
+
conversationsSource,
|
|
30
|
+
openTabCommand,
|
|
31
|
+
navigateCommand,
|
|
32
|
+
viewThreadCommand,
|
|
33
|
+
sendMessageCommand,
|
|
34
|
+
reactCommand,
|
|
35
|
+
applyFromMe,
|
|
36
|
+
} from '../linkedin/core.mjs';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {import('../../core/view/contract.js').SourceError} SourceError
|
|
40
|
+
* @typedef {import('../../core/view/contract.js').IntentCtx<InboxState>} Ctx
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
// ── Merge currency (the shared shapes presenters read) ────────────────────────
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One left-pane row in the merged stream.
|
|
47
|
+
* @typedef {Object} UnifiedRow
|
|
48
|
+
* @property {string} sourceId 'linkedin' | 'gmail'
|
|
49
|
+
* @property {string} key Globally-unique selection id (`<src>:<id>`).
|
|
50
|
+
* @property {string} name
|
|
51
|
+
* @property {string} snippet
|
|
52
|
+
* @property {boolean} unread
|
|
53
|
+
* @property {number} ts Epoch ms (0 if unknown).
|
|
54
|
+
* @property {any} ref Opaque source handle round-tripped to open/reply.
|
|
55
|
+
*/
|
|
56
|
+
/**
|
|
57
|
+
* One message in the right-pane thread.
|
|
58
|
+
* @typedef {Object} UnifiedMessage
|
|
59
|
+
* @property {string} sender
|
|
60
|
+
* @property {boolean} fromMe
|
|
61
|
+
* @property {string} text
|
|
62
|
+
* @property {number} ts
|
|
63
|
+
*/
|
|
64
|
+
/**
|
|
65
|
+
* Right-pane thread payload.
|
|
66
|
+
* @typedef {Object} UnifiedThread
|
|
67
|
+
* @property {string} title
|
|
68
|
+
* @property {string} [subtitle]
|
|
69
|
+
* @property {UnifiedMessage[]} messages
|
|
70
|
+
* @property {boolean} canReply
|
|
71
|
+
* @property {boolean} canReact
|
|
72
|
+
*/
|
|
73
|
+
/**
|
|
74
|
+
* @typedef {Object} Conversation
|
|
75
|
+
* @property {string} urn
|
|
76
|
+
* @property {string} name
|
|
77
|
+
* @property {string} lastMessage
|
|
78
|
+
* @property {boolean} unread
|
|
79
|
+
* @property {number} ts
|
|
80
|
+
* @property {string} recipientId
|
|
81
|
+
*/
|
|
82
|
+
/**
|
|
83
|
+
* The view's immutable state (the core owns it; intents replace it via ctx.set).
|
|
84
|
+
* @typedef {Object} InboxState
|
|
85
|
+
* @property {{linkedin:any, gmail:any}} subs Per-source auth/tab substate.
|
|
86
|
+
* @property {Record<string, UnifiedRow[]>} rowsBySource
|
|
87
|
+
* @property {UnifiedRow[]} rows
|
|
88
|
+
* @property {number} cursor
|
|
89
|
+
* @property {number} scroll
|
|
90
|
+
* @property {string|null} openKey
|
|
91
|
+
* @property {UnifiedRow|null} openRow
|
|
92
|
+
* @property {UnifiedThread|null} thread
|
|
93
|
+
* @property {number} threadScroll
|
|
94
|
+
* @property {'list'|'reply'|'react'} mode
|
|
95
|
+
* @property {string} draft
|
|
96
|
+
* @property {number} reactCursor
|
|
97
|
+
* @property {'all'|'linkedin'|'gmail'} filter
|
|
98
|
+
* @property {Record<string, SourceError|null>} banners
|
|
99
|
+
* @property {Record<string, boolean>} ready
|
|
100
|
+
* @property {number} lastFetch
|
|
101
|
+
*/
|
|
102
|
+
|
|
103
|
+
// ── Result helpers (inlined — the core imports nothing) ───────────────────────
|
|
104
|
+
|
|
105
|
+
/** @template T @param {T} [data] @returns {{ok:true, data:any}} */
|
|
106
|
+
function ok(data) {
|
|
107
|
+
return { ok: true, data };
|
|
108
|
+
}
|
|
109
|
+
/** @param {SourceError} error @returns {{ok:false, error:SourceError}} */
|
|
110
|
+
function fail(error) {
|
|
111
|
+
return { ok: false, error };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Presenter-facing constants ────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
/** Fixed emoji set for the react picker. */
|
|
117
|
+
export const EMOJIS = ['👍', '❤️', '😂', '🔥', '👏', '😮'];
|
|
118
|
+
|
|
119
|
+
/** Severity rank for choosing one banner / one guided panel across sources. */
|
|
120
|
+
const LEVEL_RANK = { error: 3, action: 2, info: 1 };
|
|
121
|
+
|
|
122
|
+
/** Ordered source metadata (LinkedIn first). The presenters read this. */
|
|
123
|
+
export const SOURCES_META = [
|
|
124
|
+
{ id: 'linkedin', label: 'LinkedIn', badge: { glyph: 'in', fg: '36' }, connectKey: 'L' },
|
|
125
|
+
{ id: 'gmail', label: 'Gmail', badge: { glyph: '@', fg: '31' }, connectKey: 'G' },
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
/** id → meta, for badge/label lookup. */
|
|
129
|
+
const SOURCE_BY_ID = {};
|
|
130
|
+
for (const m of SOURCES_META) SOURCE_BY_ID[m.id] = m;
|
|
131
|
+
|
|
132
|
+
/** @param {string} id @returns {{glyph:string, fg:string}} */
|
|
133
|
+
export function badgeFor(id) {
|
|
134
|
+
const m = SOURCE_BY_ID[id];
|
|
135
|
+
return (m && m.badge) || { glyph: '?', fg: '37' };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** @param {string} id @returns {string} */
|
|
139
|
+
function labelFor(id) {
|
|
140
|
+
const m = SOURCE_BY_ID[id];
|
|
141
|
+
return (m && m.label) || id;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Pure presenter helpers (shared by tui + web + text) ───────────────────────
|
|
145
|
+
|
|
146
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
147
|
+
|
|
148
|
+
/** @param {string} s @param {number} n @returns {string} */
|
|
149
|
+
export function truncate(s, n) {
|
|
150
|
+
const str = String(s == null ? '' : s);
|
|
151
|
+
return str.length > n ? str.slice(0, Math.max(0, n - 1)) + '…' : str;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** @param {string} s @param {number} n @returns {string} */
|
|
155
|
+
export function padEnd(s, n) {
|
|
156
|
+
const str = String(s == null ? '' : s);
|
|
157
|
+
return str.length >= n ? str.slice(0, n) : str + ' '.repeat(n - str.length);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Relative-timestamp ladder, max ~5 cols: now / {m}m / {h}h / {d}d /
|
|
162
|
+
* `Mon D` (this year) / `Mon ʼYY` (prior year).
|
|
163
|
+
* @param {number} ts epoch ms (0 ⇒ '') @param {number} [now] @returns {string}
|
|
164
|
+
*/
|
|
165
|
+
export function relTimestamp(ts, now = Date.now()) {
|
|
166
|
+
if (!ts) return '';
|
|
167
|
+
const s = Math.floor((now - ts) / 1000);
|
|
168
|
+
if (s < 60) return 'now';
|
|
169
|
+
const m = Math.floor(s / 60);
|
|
170
|
+
if (m < 60) return `${m}m`;
|
|
171
|
+
const h = Math.floor(m / 60);
|
|
172
|
+
if (h < 24) return `${h}h`;
|
|
173
|
+
const d = Math.floor(h / 24);
|
|
174
|
+
if (d < 7) return `${d}d`;
|
|
175
|
+
const dt = new Date(ts);
|
|
176
|
+
const mon = MONTHS[dt.getMonth()] || '';
|
|
177
|
+
const cur = new Date(now);
|
|
178
|
+
if (dt.getFullYear() === cur.getFullYear()) return `${mon} ${dt.getDate()}`;
|
|
179
|
+
return `${mon} ʼ${String(dt.getFullYear()).slice(-2)}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Calendar-day key for day-divider grouping. @param {number} ts @returns {string} */
|
|
183
|
+
export function dayKey(ts) {
|
|
184
|
+
if (!ts) return '';
|
|
185
|
+
const d = new Date(ts);
|
|
186
|
+
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Human day label for the divider: Today / Yesterday / Mon D / Mon D, YYYY. */
|
|
190
|
+
export function dayLabel(ts, now = Date.now()) {
|
|
191
|
+
const d = new Date(ts);
|
|
192
|
+
const n = new Date(now);
|
|
193
|
+
const same = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
|
194
|
+
if (same(d, n)) return 'Today';
|
|
195
|
+
if (same(d, new Date(now - 86400000))) return 'Yesterday';
|
|
196
|
+
const mon = MONTHS[d.getMonth()] || '';
|
|
197
|
+
if (d.getFullYear() === n.getFullYear()) return `${mon} ${d.getDate()}`;
|
|
198
|
+
return `${mon} ${d.getDate()}, ${d.getFullYear()}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Prefix a source's banner with its label — `Gmail: log in, then press g`. Stays
|
|
203
|
+
* robust if a source baked the label in (no double-prefix).
|
|
204
|
+
* @param {string} label @param {string} banner @returns {string}
|
|
205
|
+
*/
|
|
206
|
+
export function labeled(label, banner) {
|
|
207
|
+
const b = String(banner == null ? '' : banner);
|
|
208
|
+
const lead = `${label}:`.toLowerCase();
|
|
209
|
+
return b.toLowerCase().startsWith(lead) ? b : `${label}: ${b}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Sum unread across READY sources (independent of the visible filter). */
|
|
213
|
+
export function unreadCount(state) {
|
|
214
|
+
let n = 0;
|
|
215
|
+
for (const m of SOURCES_META) {
|
|
216
|
+
if (!state.ready[m.id]) continue;
|
|
217
|
+
for (const r of state.rowsBySource[m.id] || []) if (r.unread) n++;
|
|
218
|
+
}
|
|
219
|
+
return n;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Pick the most-severe down-source display for the no-rows guided panel
|
|
224
|
+
* (blocking states win decisively).
|
|
225
|
+
* @param {InboxState} state @returns {{label:string, d:any}|null}
|
|
226
|
+
*/
|
|
227
|
+
export function pickGuided(state) {
|
|
228
|
+
let best = null;
|
|
229
|
+
let bestScore = -1;
|
|
230
|
+
for (const m of SOURCES_META) {
|
|
231
|
+
const e = state.banners[m.id];
|
|
232
|
+
if (!e || !e.display) continue;
|
|
233
|
+
const score = (LEVEL_RANK[e.display.level] || 0) + (e.display.blocking ? 10 : 0);
|
|
234
|
+
if (score > bestScore) {
|
|
235
|
+
bestScore = score;
|
|
236
|
+
best = { label: m.label, d: e.display };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return best;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── Shared capture plumbing (exec code strings + classifiers) ─────────────────
|
|
243
|
+
|
|
244
|
+
const GMAIL_URL = 'https://mail.google.com';
|
|
245
|
+
const CONVO_COUNT = 25;
|
|
246
|
+
const INBOX_COUNT = 25;
|
|
247
|
+
const SETTLE_MAX = 5;
|
|
248
|
+
const SETTLE_INTERVAL_MS = 1200;
|
|
249
|
+
/** Error kinds that waiting can't fix — stop the settle-poll and guide. */
|
|
250
|
+
const HARD_STOP = new Set(['no-cdp', 'not-logged-in', 'capture-not-dev', 'capture-missing']);
|
|
251
|
+
|
|
252
|
+
/** Pure: setTimeout exists in both Node and the browser. @param {number} ms */
|
|
253
|
+
function sleep(ms) {
|
|
254
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Serialize a JS arg object as a literal safe to splice into the exec code
|
|
259
|
+
* string (escape U+2028/U+2029).
|
|
260
|
+
* @param {Record<string, unknown>} obj @returns {string}
|
|
261
|
+
*/
|
|
262
|
+
function jsLiteral(obj) {
|
|
263
|
+
return JSON.stringify(obj).replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Build the vault-lib exec code string. `libArgs === null` ⇒ a no-arg call.
|
|
268
|
+
* @param {string} lib @param {string} fn @param {Record<string, unknown>|null} libArgs
|
|
269
|
+
* @returns {string}
|
|
270
|
+
*/
|
|
271
|
+
function execCode(lib, fn, libArgs) {
|
|
272
|
+
const call = libArgs === null ? `${fn}()` : `${fn}(${jsLiteral(libArgs)})`;
|
|
273
|
+
return `import {${fn}} from '${lib}'; return await ${call}`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Append --target / --port to an argv (in place). @param {string[]} argv */
|
|
277
|
+
function withTP(argv, target, port) {
|
|
278
|
+
if (target) argv.push('--target', String(target));
|
|
279
|
+
if (port != null && port !== '') argv.push('--port', String(port));
|
|
280
|
+
return argv;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Append only --port. @param {string[]} argv */
|
|
284
|
+
function withPort(argv, port) {
|
|
285
|
+
if (port != null && port !== '') argv.push('--port', String(port));
|
|
286
|
+
return argv;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Parse capture stdout JSON; '' ⇒ null. @param {string} stdout */
|
|
290
|
+
function parseJson(stdout) {
|
|
291
|
+
const out = String(stdout || '').trim();
|
|
292
|
+
if (out === '') return null;
|
|
293
|
+
return JSON.parse(out);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Pull a human message out of capture stderr: prefer the last `ERROR:` line,
|
|
298
|
+
* else the last non-empty line, else a generic fallback.
|
|
299
|
+
* @param {string} stderr @returns {string}
|
|
300
|
+
*/
|
|
301
|
+
function extractErrorMessage(stderr) {
|
|
302
|
+
const lines = String(stderr || '')
|
|
303
|
+
.split(/\r?\n/)
|
|
304
|
+
.map((l) => l.trim())
|
|
305
|
+
.filter(Boolean);
|
|
306
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
307
|
+
if (/^ERROR:/i.test(lines[i])) return lines[i].replace(/^ERROR:\s*/i, '').trim();
|
|
308
|
+
}
|
|
309
|
+
if (lines.length) return lines[lines.length - 1];
|
|
310
|
+
return 'capture exec failed';
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── SourceError helper (shared by gmail descriptors + inbox-local glue) ───────
|
|
314
|
+
|
|
315
|
+
/** @param {string} kind @param {object} display @returns {SourceError} */
|
|
316
|
+
function srcError(kind, display) {
|
|
317
|
+
return { kind, display: /** @type {any} */ (display) };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ── Gmail: classify + display ─────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
const DEV_ONLY_MSG =
|
|
323
|
+
'This view needs a capture dev checkout (vault/ source + esbuild). Not available in the published package.';
|
|
324
|
+
|
|
325
|
+
/** @param {string} stderr @returns {{kind:string, message?:string}} */
|
|
326
|
+
function classifyGmail(stderr) {
|
|
327
|
+
const s = String(stderr || '');
|
|
328
|
+
if (/No browser with CDP found/i.test(s)) return { kind: 'no-cdp' };
|
|
329
|
+
if (/fetch failed/i.test(s) || /failed to fetch/i.test(s) || /ECONNREFUSED/i.test(s)) return { kind: 'no-cdp' };
|
|
330
|
+
if (/No tab found/i.test(s)) return { kind: 'no-tab' };
|
|
331
|
+
if (/DEV_ONLY/i.test(s) || /dev-only feature of capture/i.test(s) || /published package/i.test(s)) {
|
|
332
|
+
return { kind: 'capture-not-dev' };
|
|
333
|
+
}
|
|
334
|
+
if (/RateLimited/i.test(s) || /\b429\b/.test(s)) return { kind: 'rate-limited' };
|
|
335
|
+
if (
|
|
336
|
+
/Unauthenticated/i.test(s) ||
|
|
337
|
+
/XSRF token not found/i.test(s) ||
|
|
338
|
+
/GLOBALS not found/i.test(s) ||
|
|
339
|
+
/may not be logged in/i.test(s) ||
|
|
340
|
+
/not be logged in/i.test(s) ||
|
|
341
|
+
/Not on Gmail domain/i.test(s) ||
|
|
342
|
+
/Account number not found/i.test(s) ||
|
|
343
|
+
/Navigate to mail\.google\.com/i.test(s)
|
|
344
|
+
) {
|
|
345
|
+
return { kind: 'not-logged-in' };
|
|
346
|
+
}
|
|
347
|
+
return { kind: 'error', message: extractErrorMessage(s) };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** @param {{kind:string, message?:string}} error @returns {SourceError} */
|
|
351
|
+
function toGmailSourceError(error) {
|
|
352
|
+
const kind = (error && error.kind) || 'error';
|
|
353
|
+
switch (kind) {
|
|
354
|
+
case 'no-cdp':
|
|
355
|
+
return srcError('no-cdp', {
|
|
356
|
+
headline: 'No debuggable browser',
|
|
357
|
+
explanation: 'crtr drives a browser over CDP and none is running.',
|
|
358
|
+
nextStep: 'Launch Chrome with --remote-debugging-port=9222 (or Arc), then press g',
|
|
359
|
+
banner: 'Gmail: no debuggable browser — launch one, then g',
|
|
360
|
+
level: 'error',
|
|
361
|
+
blocking: true,
|
|
362
|
+
});
|
|
363
|
+
case 'no-tab':
|
|
364
|
+
return srcError('no-tab', {
|
|
365
|
+
headline: 'No Gmail tab',
|
|
366
|
+
explanation: 'Open mail.google.com in the debuggable browser.',
|
|
367
|
+
nextStep: 'Press G to connect',
|
|
368
|
+
banner: 'Gmail: open mail.google.com, then press g',
|
|
369
|
+
level: 'action',
|
|
370
|
+
blocking: true,
|
|
371
|
+
});
|
|
372
|
+
case 'not-logged-in':
|
|
373
|
+
return srcError('not-logged-in', {
|
|
374
|
+
headline: 'Log in to Gmail',
|
|
375
|
+
explanation: 'Gmail needs a sign-in in the browser.',
|
|
376
|
+
nextStep: 'Log in in the opened tab, then press g',
|
|
377
|
+
banner: 'Gmail: log in, then press g',
|
|
378
|
+
level: 'action',
|
|
379
|
+
blocking: true,
|
|
380
|
+
});
|
|
381
|
+
case 'rate-limited':
|
|
382
|
+
return srcError('rate-limited', {
|
|
383
|
+
headline: 'Gmail is throttling',
|
|
384
|
+
explanation: 'Too many requests — wait a moment before retrying.',
|
|
385
|
+
nextStep: 'Press g to retry',
|
|
386
|
+
banner: 'Gmail: throttled — wait, then press g',
|
|
387
|
+
level: 'info',
|
|
388
|
+
blocking: false,
|
|
389
|
+
});
|
|
390
|
+
case 'capture-not-dev':
|
|
391
|
+
return srcError('capture-not-dev', {
|
|
392
|
+
headline: 'Browser bridge unavailable',
|
|
393
|
+
explanation: DEV_ONLY_MSG,
|
|
394
|
+
nextStep: null,
|
|
395
|
+
banner: 'Gmail: browser bridge unavailable — capture dev checkout required',
|
|
396
|
+
level: 'error',
|
|
397
|
+
blocking: true,
|
|
398
|
+
});
|
|
399
|
+
case 'still-loading':
|
|
400
|
+
return srcError('still-loading', {
|
|
401
|
+
headline: 'Still loading Gmail…',
|
|
402
|
+
explanation: 'Gmail is taking a while to load.',
|
|
403
|
+
nextStep: 'Press g to retry',
|
|
404
|
+
banner: 'Gmail: still loading — press g to retry',
|
|
405
|
+
level: 'action',
|
|
406
|
+
blocking: false,
|
|
407
|
+
});
|
|
408
|
+
case 'error':
|
|
409
|
+
default: {
|
|
410
|
+
const msg = (error && error.message) || 'Unknown error.';
|
|
411
|
+
return srcError('error', {
|
|
412
|
+
headline: 'Something went wrong',
|
|
413
|
+
explanation: msg,
|
|
414
|
+
nextStep: 'Press g to retry',
|
|
415
|
+
banner: `Gmail: ${truncate(msg, 80)}`,
|
|
416
|
+
level: 'error',
|
|
417
|
+
blocking: true,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** @returns {SourceError} */
|
|
424
|
+
function gmailCaptureMissing() {
|
|
425
|
+
return toGmailSourceError({ kind: 'error', message: 'capture binary not found — install capture or set CAPTURE_BIN.' });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ── Field mappers ─────────────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
/** @param {unknown} v @returns {number} epoch ms (Gmail dates are already ms) */
|
|
431
|
+
function asMs(v) {
|
|
432
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
|
|
433
|
+
}
|
|
434
|
+
/** @param {any} addr @returns {string} */
|
|
435
|
+
function addrName(addr) {
|
|
436
|
+
const a = addr || {};
|
|
437
|
+
if (typeof a.name === 'string' && a.name.trim()) return a.name;
|
|
438
|
+
if (typeof a.email === 'string' && a.email.trim()) return a.email;
|
|
439
|
+
return 'Unknown';
|
|
440
|
+
}
|
|
441
|
+
/** @param {any} addr @returns {string} */
|
|
442
|
+
function addrEmail(addr) {
|
|
443
|
+
const a = addr || {};
|
|
444
|
+
return typeof a.email === 'string' ? a.email : '';
|
|
445
|
+
}
|
|
446
|
+
/** @param {any} m @returns {UnifiedRow} */
|
|
447
|
+
function toUnifiedRow(m) {
|
|
448
|
+
const o = m || {};
|
|
449
|
+
const threadId = typeof o.threadId === 'string' ? o.threadId : '';
|
|
450
|
+
const from = o.from || {};
|
|
451
|
+
return {
|
|
452
|
+
sourceId: 'gmail',
|
|
453
|
+
key: `gmail:${threadId}`,
|
|
454
|
+
name: addrName(from),
|
|
455
|
+
snippet: typeof o.snippet === 'string' && o.snippet ? o.snippet : typeof o.subject === 'string' ? o.subject : '',
|
|
456
|
+
unread: o.unread === true,
|
|
457
|
+
ts: asMs(o.date),
|
|
458
|
+
ref: {
|
|
459
|
+
threadId,
|
|
460
|
+
messageId: typeof o.messageId === 'string' ? o.messageId : '',
|
|
461
|
+
subject: typeof o.subject === 'string' ? o.subject : '',
|
|
462
|
+
fromEmail: addrEmail(from),
|
|
463
|
+
fromName: addrName(from),
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
/** @param {any} m @param {string} myEmail @returns {UnifiedMessage} */
|
|
468
|
+
function toUnifiedMessage(m, myEmail) {
|
|
469
|
+
const o = m || {};
|
|
470
|
+
const from = o.from || {};
|
|
471
|
+
const fromEmail = addrEmail(from);
|
|
472
|
+
return {
|
|
473
|
+
sender: addrName(from),
|
|
474
|
+
fromMe: !!myEmail && fromEmail.toLowerCase() === myEmail.toLowerCase(),
|
|
475
|
+
text: typeof o.body === 'string' && o.body ? o.body : typeof o.snippet === 'string' ? o.snippet : '',
|
|
476
|
+
ts: asMs(o.date),
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
/** Strip leading `Re:` chains, then prefix a single `Re: `. @param {string} s */
|
|
480
|
+
function reSubject(s) {
|
|
481
|
+
const base = String(s || '').replace(/^\s*(re:\s*)+/i, '').trim();
|
|
482
|
+
return base ? `Re: ${base}` : 'Re:';
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** @param {string} url @returns {boolean} */
|
|
486
|
+
function isGmailUrl(url) {
|
|
487
|
+
try {
|
|
488
|
+
return new URL(url).hostname.toLowerCase() === 'mail.google.com';
|
|
489
|
+
} catch {
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ── Sources (reads) ────────────────────────────────────────────────────────────
|
|
495
|
+
|
|
496
|
+
/** Gmail: discover the mail.google.com tab id via `capture list`. */
|
|
497
|
+
const gmailDiscover = {
|
|
498
|
+
id: 'gmail-discover',
|
|
499
|
+
request: (a) => ({ kind: 'exec', bin: 'capture', args: withPort(['list'], a && a.port) }),
|
|
500
|
+
parse: (raw) => {
|
|
501
|
+
if (!raw.ok) return fail(gmailCaptureMissing());
|
|
502
|
+
if (raw.exitCode !== 0) return fail(toGmailSourceError(classifyGmail(raw.stderr)));
|
|
503
|
+
let tabs;
|
|
504
|
+
try {
|
|
505
|
+
tabs = parseJson(raw.stdout) || [];
|
|
506
|
+
} catch {
|
|
507
|
+
return fail(toGmailSourceError({ kind: 'error', message: 'could not parse capture list output' }));
|
|
508
|
+
}
|
|
509
|
+
if (!Array.isArray(tabs)) return fail(toGmailSourceError({ kind: 'no-tab' }));
|
|
510
|
+
const chosen = tabs.filter((t) => t && typeof t.url === 'string' && isGmailUrl(t.url))[0];
|
|
511
|
+
if (chosen && chosen.id) return ok(String(chosen.id));
|
|
512
|
+
return fail(toGmailSourceError({ kind: 'no-tab' }));
|
|
513
|
+
},
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
/** Gmail: read page auth context `{xsrf, account, email, globals}`. */
|
|
517
|
+
const gmailContext = {
|
|
518
|
+
id: 'gmail-context',
|
|
519
|
+
request: (a) => ({
|
|
520
|
+
kind: 'exec', bin: 'capture',
|
|
521
|
+
args: withTP(['exec', execCode('libs/gmail', 'getContext', null)], a && a.target, a && a.port),
|
|
522
|
+
}),
|
|
523
|
+
parse: (raw) => {
|
|
524
|
+
if (!raw.ok) return fail(gmailCaptureMissing());
|
|
525
|
+
if (raw.exitCode !== 0) return fail(toGmailSourceError(classifyGmail(raw.stderr)));
|
|
526
|
+
let d;
|
|
527
|
+
try {
|
|
528
|
+
d = parseJson(raw.stdout) || {};
|
|
529
|
+
} catch {
|
|
530
|
+
return fail(toGmailSourceError({ kind: 'error', message: 'could not parse getContext output' }));
|
|
531
|
+
}
|
|
532
|
+
if (!d.xsrf || d.account == null || !d.globals) {
|
|
533
|
+
return fail(toGmailSourceError({ kind: 'error', message: 'getContext() returned no xsrf/account/globals' }));
|
|
534
|
+
}
|
|
535
|
+
return ok({ xsrf: String(d.xsrf), account: Number(d.account), email: typeof d.email === 'string' ? d.email : '', globals: d.globals });
|
|
536
|
+
},
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
/** Gmail: list inbox rows (one per thread). */
|
|
540
|
+
const gmailInbox = {
|
|
541
|
+
id: 'gmail-inbox',
|
|
542
|
+
request: (a) => ({
|
|
543
|
+
kind: 'exec', bin: 'capture',
|
|
544
|
+
args: withTP(
|
|
545
|
+
['exec', execCode('libs/gmail', 'listInbox', { xsrf: a.xsrf, account: a.account, globals: a.globals, count: (a && a.count) || INBOX_COUNT })],
|
|
546
|
+
a && a.target, a && a.port
|
|
547
|
+
),
|
|
548
|
+
}),
|
|
549
|
+
parse: (raw) => {
|
|
550
|
+
if (!raw.ok) return fail(gmailCaptureMissing());
|
|
551
|
+
if (raw.exitCode !== 0) return fail(toGmailSourceError(classifyGmail(raw.stderr)));
|
|
552
|
+
let r;
|
|
553
|
+
try {
|
|
554
|
+
r = parseJson(raw.stdout) || {};
|
|
555
|
+
} catch {
|
|
556
|
+
return fail(toGmailSourceError({ kind: 'error', message: 'could not parse listInbox output' }));
|
|
557
|
+
}
|
|
558
|
+
const arr = r && Array.isArray(r.messages) ? r.messages : [];
|
|
559
|
+
return ok(arr.map(toUnifiedRow));
|
|
560
|
+
},
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
/** Gmail: read a thread's raw messages (for buildThread + reply resolution). */
|
|
564
|
+
const gmailThread = {
|
|
565
|
+
id: 'gmail-thread',
|
|
566
|
+
request: (a) => ({
|
|
567
|
+
kind: 'exec', bin: 'capture',
|
|
568
|
+
args: withTP(
|
|
569
|
+
['exec', execCode('libs/gmail', 'readEmail', { xsrf: a.xsrf, account: a.account, globals: a.globals, threadId: a.threadId })],
|
|
570
|
+
a && a.target, a && a.port
|
|
571
|
+
),
|
|
572
|
+
}),
|
|
573
|
+
parse: (raw) => {
|
|
574
|
+
if (!raw.ok) return fail(gmailCaptureMissing());
|
|
575
|
+
if (raw.exitCode !== 0) return fail(toGmailSourceError(classifyGmail(raw.stderr)));
|
|
576
|
+
let r;
|
|
577
|
+
try {
|
|
578
|
+
r = parseJson(raw.stdout) || {};
|
|
579
|
+
} catch {
|
|
580
|
+
return fail(toGmailSourceError({ kind: 'error', message: 'could not parse readEmail output' }));
|
|
581
|
+
}
|
|
582
|
+
return ok(r && Array.isArray(r.messages) ? r.messages : []);
|
|
583
|
+
},
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
// ── Commands (writes) ──────────────────────────────────────────────────────────
|
|
587
|
+
|
|
588
|
+
/** Gmail: open/reuse the mail.google.com tab → its tab id. */
|
|
589
|
+
const gmailOpen = {
|
|
590
|
+
id: 'gmail-open',
|
|
591
|
+
request: (a) => ({ kind: 'exec', bin: 'capture', args: withPort(['open', GMAIL_URL], a && a.port) }),
|
|
592
|
+
parse: (raw) => {
|
|
593
|
+
if (!raw.ok) return fail(gmailCaptureMissing());
|
|
594
|
+
if (raw.exitCode !== 0) return fail(toGmailSourceError(classifyGmail(raw.stderr)));
|
|
595
|
+
let body;
|
|
596
|
+
try {
|
|
597
|
+
body = parseJson(raw.stdout) || {};
|
|
598
|
+
} catch {
|
|
599
|
+
return fail(toGmailSourceError({ kind: 'error', message: 'could not parse capture open output' }));
|
|
600
|
+
}
|
|
601
|
+
if (body && body.id) return ok(String(body.id));
|
|
602
|
+
return fail(toGmailSourceError({ kind: 'error', message: 'capture open returned no tab id' }));
|
|
603
|
+
},
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
/** Gmail: send a reply. */
|
|
607
|
+
const gmailReply = {
|
|
608
|
+
id: 'gmail-reply',
|
|
609
|
+
request: (a) => ({
|
|
610
|
+
kind: 'exec', bin: 'capture',
|
|
611
|
+
args: withTP(
|
|
612
|
+
['exec', execCode('libs/gmail', 'replyEmail', {
|
|
613
|
+
xsrf: a.xsrf, account: a.account, globals: a.globals, threadId: a.threadId,
|
|
614
|
+
originalMsgId: a.originalMsgId, to: a.to, subject: a.subject, body: a.body,
|
|
615
|
+
})],
|
|
616
|
+
a && a.target, a && a.port
|
|
617
|
+
),
|
|
618
|
+
}),
|
|
619
|
+
parse: (raw) => {
|
|
620
|
+
if (!raw.ok) return fail(gmailCaptureMissing());
|
|
621
|
+
if (raw.exitCode !== 0) return fail(toGmailSourceError(classifyGmail(raw.stderr)));
|
|
622
|
+
let out;
|
|
623
|
+
try {
|
|
624
|
+
out = parseJson(raw.stdout) || {};
|
|
625
|
+
} catch {
|
|
626
|
+
return fail(toGmailSourceError({ kind: 'error', message: 'could not parse replyEmail output' }));
|
|
627
|
+
}
|
|
628
|
+
if (out && out.success === false) return fail(toGmailSourceError({ kind: 'error', message: 'Gmail rejected the reply' }));
|
|
629
|
+
return ok(undefined);
|
|
630
|
+
},
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
// ── Orchestration (restated ensureReady state machines) ───────────────────────
|
|
634
|
+
|
|
635
|
+
/** @param {string} headline @param {any} e @returns {SourceError} */
|
|
636
|
+
function defensiveError(headline, e) {
|
|
637
|
+
const msg = e && e.message ? String(e.message) : headline;
|
|
638
|
+
return srcError('error', { headline, explanation: msg, nextStep: 'Press g to retry', banner: msg, level: 'error', blocking: false });
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** Which auto-fix branch a LinkedIn error kind takes. @returns {'open'|'navigate'|null} */
|
|
642
|
+
function autoFixFor(kind) {
|
|
643
|
+
if (kind === 'no-tab') return 'open';
|
|
644
|
+
if (kind === 'not-messaging') return 'navigate';
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** One LinkedIn readiness attempt: discover → context → listConversations. */
|
|
649
|
+
async function attemptLi(ctx, sub) {
|
|
650
|
+
if (!sub.target) {
|
|
651
|
+
const r = await ctx.resolve(discoverTabSource, { port: sub.port });
|
|
652
|
+
if (!r.ok) return r;
|
|
653
|
+
sub.target = r.data;
|
|
654
|
+
}
|
|
655
|
+
if (!sub.ctx) {
|
|
656
|
+
const r = await ctx.resolve(contextSource, { target: sub.target, port: sub.port });
|
|
657
|
+
if (!r.ok) return r;
|
|
658
|
+
sub.ctx = r.data;
|
|
659
|
+
}
|
|
660
|
+
const lc = await ctx.resolve(conversationsSource, { target: sub.target, port: sub.port, csrf: sub.ctx.csrf, memberId: sub.ctx.memberId, count: CONVO_COUNT });
|
|
661
|
+
if (!lc.ok) return lc;
|
|
662
|
+
sub.convos = lc.data;
|
|
663
|
+
return { ok: true };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** Bounded settle-poll after a LinkedIn auto open/navigate. */
|
|
667
|
+
async function settleLi(ctx, sub) {
|
|
668
|
+
for (let i = 1; i <= SETTLE_MAX; i++) {
|
|
669
|
+
await sleep(SETTLE_INTERVAL_MS);
|
|
670
|
+
const r = await attemptLi(ctx, sub);
|
|
671
|
+
if (r.ok) {
|
|
672
|
+
sub.loginTabOpened = false;
|
|
673
|
+
return { ok: true };
|
|
674
|
+
}
|
|
675
|
+
if (HARD_STOP.has(r.error.kind)) return r;
|
|
676
|
+
}
|
|
677
|
+
// Settle-exhaustion is inbox glue (a "still loading" nudge), not a classify dup.
|
|
678
|
+
return fail(srcError('settling', {
|
|
679
|
+
headline: 'Still loading',
|
|
680
|
+
explanation: 'LinkedIn is taking a moment to load.',
|
|
681
|
+
nextStep: 'Press g to retry',
|
|
682
|
+
banner: 'still loading — press g to retry',
|
|
683
|
+
level: 'action',
|
|
684
|
+
blocking: false,
|
|
685
|
+
}));
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** LinkedIn ensureReady: discover→auth→settle with auto-fix branches. */
|
|
689
|
+
async function ensureLinkedin(ctx, sub) {
|
|
690
|
+
const probe = await attemptLi(ctx, sub);
|
|
691
|
+
if (probe.ok) {
|
|
692
|
+
sub.loginTabOpened = false;
|
|
693
|
+
return { ok: true };
|
|
694
|
+
}
|
|
695
|
+
const error = probe.error;
|
|
696
|
+
const auto = autoFixFor(error.kind);
|
|
697
|
+
if (auto) {
|
|
698
|
+
const fix = auto === 'open'
|
|
699
|
+
? await ctx.execute(openTabCommand, { port: sub.port })
|
|
700
|
+
: await ctx.execute(navigateCommand, { target: sub.target, port: sub.port });
|
|
701
|
+
if (!fix.ok) return fix;
|
|
702
|
+
if (auto === 'open' && fix.data) sub.target = fix.data;
|
|
703
|
+
sub.ctx = null;
|
|
704
|
+
return settleLi(ctx, sub);
|
|
705
|
+
}
|
|
706
|
+
// not-logged-in: open the messaging tab ONCE per episode, then guide.
|
|
707
|
+
if (error.kind === 'not-logged-in' && !sub.loginTabOpened) {
|
|
708
|
+
const o = await ctx.execute(openTabCommand, { port: sub.port });
|
|
709
|
+
if (o.ok && o.data) sub.target = o.data;
|
|
710
|
+
sub.loginTabOpened = true;
|
|
711
|
+
}
|
|
712
|
+
return probe;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** One Gmail readiness attempt: discover → context. */
|
|
716
|
+
async function attemptGmail(ctx, sub) {
|
|
717
|
+
if (!sub.target) {
|
|
718
|
+
const r = await ctx.resolve(gmailDiscover, { port: sub.port });
|
|
719
|
+
if (!r.ok) return r;
|
|
720
|
+
sub.target = r.data;
|
|
721
|
+
}
|
|
722
|
+
if (!sub.ctx) {
|
|
723
|
+
const r = await ctx.resolve(gmailContext, { target: sub.target, port: sub.port });
|
|
724
|
+
if (!r.ok) return r;
|
|
725
|
+
sub.ctx = r.data;
|
|
726
|
+
}
|
|
727
|
+
return { ok: true };
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** Bounded settle-poll after a Gmail auto open. */
|
|
731
|
+
async function settleGmail(ctx, sub) {
|
|
732
|
+
for (let i = 1; i <= SETTLE_MAX; i++) {
|
|
733
|
+
await sleep(SETTLE_INTERVAL_MS);
|
|
734
|
+
ctx.signal.setStatus(`Loading Gmail… (${i})`);
|
|
735
|
+
const r = await attemptGmail(ctx, sub);
|
|
736
|
+
if (r.ok) {
|
|
737
|
+
sub.loginTabOpened = false;
|
|
738
|
+
return { ok: true };
|
|
739
|
+
}
|
|
740
|
+
if (HARD_STOP.has(r.error.kind)) return r;
|
|
741
|
+
}
|
|
742
|
+
return fail(toGmailSourceError({ kind: 'still-loading' }));
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** Gmail ensureReady: discover→auth→settle. */
|
|
746
|
+
async function ensureGmail(ctx, sub) {
|
|
747
|
+
const r = await attemptGmail(ctx, sub);
|
|
748
|
+
if (r.ok) {
|
|
749
|
+
sub.loginTabOpened = false;
|
|
750
|
+
return { ok: true };
|
|
751
|
+
}
|
|
752
|
+
const kind = r.error.kind;
|
|
753
|
+
if (kind === 'no-tab') {
|
|
754
|
+
ctx.signal.setStatus('Opening Gmail…');
|
|
755
|
+
const o = await ctx.execute(gmailOpen, { port: sub.port });
|
|
756
|
+
if (!o.ok) return o;
|
|
757
|
+
sub.target = o.data;
|
|
758
|
+
sub.ctx = null;
|
|
759
|
+
return settleGmail(ctx, sub);
|
|
760
|
+
}
|
|
761
|
+
if (kind === 'not-logged-in') {
|
|
762
|
+
if (!sub.loginTabOpened) {
|
|
763
|
+
const o = await ctx.execute(gmailOpen, { port: sub.port });
|
|
764
|
+
if (o.ok && o.data) sub.target = o.data;
|
|
765
|
+
sub.loginTabOpened = true;
|
|
766
|
+
}
|
|
767
|
+
return r;
|
|
768
|
+
}
|
|
769
|
+
return r;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** Map cached LinkedIn conversations → UnifiedRows (pure). */
|
|
773
|
+
function liRowsFromConvos(sub) {
|
|
774
|
+
const csrf = sub.ctx ? sub.ctx.csrf : '';
|
|
775
|
+
const memberId = sub.ctx ? sub.ctx.memberId : '';
|
|
776
|
+
return (sub.convos || []).map((c) => ({
|
|
777
|
+
sourceId: 'linkedin',
|
|
778
|
+
key: `linkedin:${c.urn}`,
|
|
779
|
+
name: c.name || 'Unknown',
|
|
780
|
+
snippet: c.lastMessage || '',
|
|
781
|
+
unread: !!c.unread,
|
|
782
|
+
ts: c.ts || 0,
|
|
783
|
+
ref: { urn: c.urn, recipientId: c.recipientId, csrf, memberId },
|
|
784
|
+
}));
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/** Load a LinkedIn thread; caches raw msgs (urns) for react; clears unread. */
|
|
788
|
+
async function loadLiThread(ctx, sub, ref) {
|
|
789
|
+
const csrf = (sub.ctx && sub.ctx.csrf) || ref.csrf;
|
|
790
|
+
const memberId = (sub.ctx && sub.ctx.memberId) || ref.memberId;
|
|
791
|
+
if (!csrf) {
|
|
792
|
+
return fail(srcError('error', {
|
|
793
|
+
headline: 'Something went wrong', explanation: 'not authenticated yet',
|
|
794
|
+
nextStep: 'Press g to retry', banner: 'not authenticated yet', level: 'error', blocking: false,
|
|
795
|
+
}));
|
|
796
|
+
}
|
|
797
|
+
const vc = await ctx.execute(viewThreadCommand, { target: sub.target, port: sub.port, csrf, conversationUrn: ref.urn });
|
|
798
|
+
if (!vc.ok) return vc;
|
|
799
|
+
// applyFromMe resolves fromMe and keeps each message's urn (needed by react).
|
|
800
|
+
const raw = applyFromMe(vc.data, memberId);
|
|
801
|
+
sub.threads = { ...sub.threads, [ref.urn]: raw };
|
|
802
|
+
const c = (sub.convos || []).find((x) => x.urn === ref.urn);
|
|
803
|
+
if (c) c.unread = false;
|
|
804
|
+
const name = c ? c.name : 'Conversation';
|
|
805
|
+
const messages = raw.map((m) => ({ sender: m.sender, fromMe: m.fromMe, text: m.text, ts: m.ts }));
|
|
806
|
+
return ok({ title: name || 'Conversation', messages, canReply: true, canReact: true });
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** Load a Gmail thread → UnifiedThread (with from/to subtitle). */
|
|
810
|
+
async function loadGmailThread(ctx, sub, ref) {
|
|
811
|
+
const threadId = ref && ref.threadId ? ref.threadId : '';
|
|
812
|
+
if (!threadId) return fail(toGmailSourceError({ kind: 'error', message: 'loadThread: missing threadId' }));
|
|
813
|
+
const r = await ctx.resolve(gmailThread, { xsrf: sub.ctx.xsrf, account: sub.ctx.account, globals: sub.ctx.globals, threadId, target: sub.target, port: sub.port });
|
|
814
|
+
if (!r.ok) return r;
|
|
815
|
+
const msgs = r.data;
|
|
816
|
+
const myEmail = sub.ctx.email || '';
|
|
817
|
+
const first = msgs[0] || {};
|
|
818
|
+
const last = msgs[msgs.length - 1] || {};
|
|
819
|
+
const title = (typeof first.subject === 'string' && first.subject) || (ref && ref.subject) || '(no subject)';
|
|
820
|
+
const fromAddr = addrEmail(last.from) || addrEmail(first.from);
|
|
821
|
+
const toAddr = (last.to && last.to[0] && addrEmail(last.to[0])) || (first.to && first.to[0] && addrEmail(first.to[0])) || '';
|
|
822
|
+
const subtitle = fromAddr || toAddr ? `from ${fromAddr || '—'} · to ${toAddr || '—'}` : undefined;
|
|
823
|
+
return ok({
|
|
824
|
+
title,
|
|
825
|
+
...(subtitle ? { subtitle } : {}),
|
|
826
|
+
messages: msgs.map((m) => toUnifiedMessage(m, myEmail)),
|
|
827
|
+
canReply: true,
|
|
828
|
+
canReact: false,
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/** Resolve recipient + originalMsgId from a Gmail thread, then send the reply. */
|
|
833
|
+
async function sendGmail(ctx, sub, ref, text) {
|
|
834
|
+
const threadId = ref && ref.threadId ? ref.threadId : '';
|
|
835
|
+
if (!threadId) return fail(toGmailSourceError({ kind: 'error', message: 'reply: missing threadId' }));
|
|
836
|
+
const body = String(text == null ? '' : text);
|
|
837
|
+
if (!body.trim()) return fail(toGmailSourceError({ kind: 'error', message: 'reply: empty body' }));
|
|
838
|
+
const read = await ctx.resolve(gmailThread, { xsrf: sub.ctx.xsrf, account: sub.ctx.account, globals: sub.ctx.globals, threadId, target: sub.target, port: sub.port });
|
|
839
|
+
if (!read.ok) return read;
|
|
840
|
+
const msgs = read.data;
|
|
841
|
+
if (msgs.length === 0) return fail(toGmailSourceError({ kind: 'error', message: 'reply: thread has no messages' }));
|
|
842
|
+
const myEmail = (sub.ctx.email || '').toLowerCase();
|
|
843
|
+
const latest = msgs[msgs.length - 1] || {};
|
|
844
|
+
const originalMsgId = typeof latest.messageId === 'string' ? latest.messageId : (ref && ref.messageId) || '';
|
|
845
|
+
if (!originalMsgId) return fail(toGmailSourceError({ kind: 'error', message: 'reply: could not resolve message id' }));
|
|
846
|
+
let tgt = null;
|
|
847
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
848
|
+
const fe = addrEmail((msgs[i] || {}).from).toLowerCase();
|
|
849
|
+
if (fe && fe !== myEmail) {
|
|
850
|
+
tgt = msgs[i];
|
|
851
|
+
break;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
let toEmail = tgt ? addrEmail(tgt.from) : '';
|
|
855
|
+
if (!toEmail) toEmail = (latest.to && latest.to[0] && addrEmail(latest.to[0])) || '';
|
|
856
|
+
if (!toEmail && ref && ref.fromEmail) toEmail = ref.fromEmail;
|
|
857
|
+
if (!toEmail) return fail(toGmailSourceError({ kind: 'error', message: 'reply: could not determine recipient' }));
|
|
858
|
+
const subject = reSubject((tgt && tgt.subject) || latest.subject || (ref && ref.subject) || '');
|
|
859
|
+
return ctx.execute(gmailReply, {
|
|
860
|
+
xsrf: sub.ctx.xsrf, account: sub.ctx.account, globals: sub.ctx.globals, threadId,
|
|
861
|
+
originalMsgId, to: toEmail, subject, body, target: sub.target, port: sub.port,
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// ── Merge / chrome helpers ─────────────────────────────────────────────────────
|
|
866
|
+
|
|
867
|
+
/** Sort the merged set unread-first then ts desc. Mutates `arr`. */
|
|
868
|
+
function sortRows(arr) {
|
|
869
|
+
arr.sort((a, b) => {
|
|
870
|
+
if (!!a.unread !== !!b.unread) return a.unread ? -1 : 1;
|
|
871
|
+
return (b.ts || 0) - (a.ts || 0);
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** Re-merge rowsBySource into the visible, sorted list — keeping the cursor by key. */
|
|
876
|
+
function mergeRows(rowsBySource, filter, prevRows, prevCursor) {
|
|
877
|
+
const prev = prevRows[prevCursor];
|
|
878
|
+
const prevKey = prev && prev.key;
|
|
879
|
+
let all = [];
|
|
880
|
+
for (const m of SOURCES_META) {
|
|
881
|
+
if (filter !== 'all' && filter !== m.id) continue;
|
|
882
|
+
all = all.concat(rowsBySource[m.id] || []);
|
|
883
|
+
}
|
|
884
|
+
sortRows(all);
|
|
885
|
+
let cursor = prevCursor;
|
|
886
|
+
if (prevKey) {
|
|
887
|
+
const i = all.findIndex((r) => r.key === prevKey);
|
|
888
|
+
if (i >= 0) cursor = i;
|
|
889
|
+
}
|
|
890
|
+
if (cursor >= all.length) cursor = Math.max(0, all.length - 1);
|
|
891
|
+
if (cursor < 0) cursor = 0;
|
|
892
|
+
return { rows: all, cursor };
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/** Collapse per-source down-states into ONE host banner (label-prefixed). */
|
|
896
|
+
function applyBanners(ctx, banners) {
|
|
897
|
+
const downs = [];
|
|
898
|
+
for (const m of SOURCES_META) {
|
|
899
|
+
const e = banners && banners[m.id];
|
|
900
|
+
if (e && e.display) downs.push({ label: m.label, d: e.display });
|
|
901
|
+
}
|
|
902
|
+
if (downs.length === 0) {
|
|
903
|
+
ctx.signal.clearBanner();
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
let level = 'info';
|
|
907
|
+
for (const x of downs) if ((LEVEL_RANK[x.d.level] || 0) > (LEVEL_RANK[level] || 0)) level = x.d.level;
|
|
908
|
+
// LinkedIn's shared SourceError displays carry no `.banner`; fall back to
|
|
909
|
+
// explanation/headline so the merged chrome banner is never blank.
|
|
910
|
+
const msg = downs.map((x) => labeled(x.label, x.d.banner || x.d.explanation || x.d.headline)).join(' · ');
|
|
911
|
+
ctx.signal.setBanner(msg, /** @type {any} */ (level));
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** Set a one-off banner for a per-action SourceError (open/reply/react). */
|
|
915
|
+
function bannerFromSource(ctx, id, error) {
|
|
916
|
+
const label = labelFor(id);
|
|
917
|
+
const d = error && error.display;
|
|
918
|
+
const text = d && (d.banner || d.explanation || d.headline);
|
|
919
|
+
if (text) ctx.signal.setBanner(labeled(label, text), (d && d.level) || 'error');
|
|
920
|
+
else ctx.signal.setBanner(`${label}: error`, 'error');
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/** Drive the live "N unread · <filter>" subtitle from current state. */
|
|
924
|
+
function updateSubtitle(ctx) {
|
|
925
|
+
const s = ctx.state;
|
|
926
|
+
const n = unreadCount(s);
|
|
927
|
+
const parts = [];
|
|
928
|
+
if (n > 0) parts.push(`${n} unread`);
|
|
929
|
+
if (s.filter !== 'all') {
|
|
930
|
+
const m = SOURCE_BY_ID[s.filter];
|
|
931
|
+
parts.push(`${m ? m.label : s.filter} only`);
|
|
932
|
+
}
|
|
933
|
+
ctx.signal.setSubtitle(parts.length ? parts.join(' · ') : null);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// ── Sub cloning (immutable update discipline) ─────────────────────────────────
|
|
937
|
+
|
|
938
|
+
function cloneLi(sub) {
|
|
939
|
+
return { ...sub, threads: { ...(sub.threads || {}) }, convos: (sub.convos || []).map((c) => ({ ...c })) };
|
|
940
|
+
}
|
|
941
|
+
function cloneGmail(sub) {
|
|
942
|
+
return { ...sub };
|
|
943
|
+
}
|
|
944
|
+
function cloneSubs(subs) {
|
|
945
|
+
return { linkedin: cloneLi(subs.linkedin), gmail: cloneGmail(subs.gmail) };
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// ── The portable core ──────────────────────────────────────────────────────────
|
|
949
|
+
|
|
950
|
+
/** @type {import('../../core/view/contract.js').ViewCore<InboxState>} */
|
|
951
|
+
const core = {
|
|
952
|
+
manifest: {
|
|
953
|
+
id: 'inbox',
|
|
954
|
+
title: 'Inbox',
|
|
955
|
+
description: 'Combined inbox — triage LinkedIn + Gmail in one ranked list',
|
|
956
|
+
refreshMs: 30000,
|
|
957
|
+
},
|
|
958
|
+
|
|
959
|
+
/** Cheap + synchronous initial state. NO fetch — the host paints a loading
|
|
960
|
+
* frame, then dispatches the first 'refresh'. @returns {InboxState} */
|
|
961
|
+
init(opts) {
|
|
962
|
+
return {
|
|
963
|
+
subs: {
|
|
964
|
+
linkedin: { ctx: null, target: (opts && opts.target) || null, port: (opts && opts.port) || undefined, convos: [], threads: {}, loginTabOpened: false },
|
|
965
|
+
gmail: { target: null, port: undefined, ctx: null, loginTabOpened: false },
|
|
966
|
+
},
|
|
967
|
+
rowsBySource: {},
|
|
968
|
+
rows: [],
|
|
969
|
+
cursor: 0,
|
|
970
|
+
scroll: 0,
|
|
971
|
+
openKey: null,
|
|
972
|
+
openRow: null,
|
|
973
|
+
thread: null,
|
|
974
|
+
threadScroll: 0,
|
|
975
|
+
mode: 'list',
|
|
976
|
+
draft: '',
|
|
977
|
+
reactCursor: 0,
|
|
978
|
+
filter: 'all',
|
|
979
|
+
banners: {},
|
|
980
|
+
ready: {},
|
|
981
|
+
lastFetch: 0,
|
|
982
|
+
};
|
|
983
|
+
},
|
|
984
|
+
|
|
985
|
+
sources: { gmailDiscover, gmailContext, gmailInbox, gmailThread },
|
|
986
|
+
commands: { gmailOpen, gmailReply },
|
|
987
|
+
|
|
988
|
+
intents: {
|
|
989
|
+
/**
|
|
990
|
+
* For each source (LinkedIn first): ensureReady → list rows; merge + sort;
|
|
991
|
+
* set per-source banners. Partial readiness: one down source never blanks the
|
|
992
|
+
* view. Skips while composing/reacting so a poll can't disrupt input.
|
|
993
|
+
* @param {Ctx} ctx
|
|
994
|
+
*/
|
|
995
|
+
async refresh(ctx) {
|
|
996
|
+
if (ctx.state.mode !== 'list') return;
|
|
997
|
+
ctx.signal.setStatus('Refreshing…');
|
|
998
|
+
const s0 = ctx.state;
|
|
999
|
+
const subs = cloneSubs(s0.subs);
|
|
1000
|
+
/** @type {Record<string, UnifiedRow[]>} */
|
|
1001
|
+
const rowsBySource = {};
|
|
1002
|
+
/** @type {Record<string, SourceError|null>} */
|
|
1003
|
+
const banners = {};
|
|
1004
|
+
/** @type {Record<string, boolean>} */
|
|
1005
|
+
const ready = {};
|
|
1006
|
+
|
|
1007
|
+
for (const meta of SOURCES_META) {
|
|
1008
|
+
const id = meta.id;
|
|
1009
|
+
const sub = subs[id];
|
|
1010
|
+
let er;
|
|
1011
|
+
try {
|
|
1012
|
+
er = id === 'linkedin' ? await ensureLinkedin(ctx, sub) : await ensureGmail(ctx, sub);
|
|
1013
|
+
} catch (e) {
|
|
1014
|
+
er = { ok: false, error: defensiveError('source error', e) };
|
|
1015
|
+
}
|
|
1016
|
+
if (er.ok) {
|
|
1017
|
+
ready[id] = true;
|
|
1018
|
+
banners[id] = null;
|
|
1019
|
+
if (id === 'linkedin') {
|
|
1020
|
+
rowsBySource[id] = liRowsFromConvos(sub);
|
|
1021
|
+
} else {
|
|
1022
|
+
let lr;
|
|
1023
|
+
try {
|
|
1024
|
+
lr = await ctx.resolve(gmailInbox, { xsrf: sub.ctx.xsrf, account: sub.ctx.account, globals: sub.ctx.globals, count: INBOX_COUNT, target: sub.target, port: sub.port });
|
|
1025
|
+
} catch (e) {
|
|
1026
|
+
lr = { ok: false, error: defensiveError('list failed', e) };
|
|
1027
|
+
}
|
|
1028
|
+
if (lr.ok) {
|
|
1029
|
+
rowsBySource[id] = lr.data;
|
|
1030
|
+
} else {
|
|
1031
|
+
rowsBySource[id] = [];
|
|
1032
|
+
banners[id] = lr.error;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
} else {
|
|
1036
|
+
ready[id] = false;
|
|
1037
|
+
rowsBySource[id] = [];
|
|
1038
|
+
banners[id] = er.error;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
ctx.set((s) => {
|
|
1043
|
+
const { rows, cursor } = mergeRows(rowsBySource, s.filter, s.rows, s.cursor);
|
|
1044
|
+
return { ...s, subs, rowsBySource, banners, ready, rows, cursor, lastFetch: Date.now() };
|
|
1045
|
+
});
|
|
1046
|
+
ctx.signal.setStatus(null);
|
|
1047
|
+
applyBanners(ctx, banners);
|
|
1048
|
+
updateSubtitle(ctx);
|
|
1049
|
+
},
|
|
1050
|
+
|
|
1051
|
+
/** @param {Ctx} ctx */
|
|
1052
|
+
cursorDown: (ctx) => ctx.set((s) => ({ ...s, cursor: s.rows.length ? Math.min(s.rows.length - 1, s.cursor + 1) : 0 })),
|
|
1053
|
+
/** @param {Ctx} ctx */
|
|
1054
|
+
cursorUp: (ctx) => ctx.set((s) => ({ ...s, cursor: Math.max(0, s.cursor - 1) })),
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Open the row at `idx` (or the cursor): dispatch to its source's loadThread.
|
|
1058
|
+
* @param {Ctx} ctx @param {number} [idx]
|
|
1059
|
+
*/
|
|
1060
|
+
async open(ctx, idx) {
|
|
1061
|
+
const s0 = ctx.state;
|
|
1062
|
+
const i = typeof idx === 'number' ? idx : s0.cursor;
|
|
1063
|
+
const row = s0.rows[i];
|
|
1064
|
+
if (!row) return;
|
|
1065
|
+
ctx.signal.setStatus('Loading thread…');
|
|
1066
|
+
const subs = cloneSubs(s0.subs);
|
|
1067
|
+
const id = row.sourceId;
|
|
1068
|
+
let result;
|
|
1069
|
+
try {
|
|
1070
|
+
result = id === 'linkedin' ? await loadLiThread(ctx, subs.linkedin, row.ref) : await loadGmailThread(ctx, subs.gmail, row.ref);
|
|
1071
|
+
} catch (e) {
|
|
1072
|
+
result = { ok: false, error: defensiveError('could not load thread', e) };
|
|
1073
|
+
}
|
|
1074
|
+
ctx.signal.setStatus(null);
|
|
1075
|
+
if (!result.ok) {
|
|
1076
|
+
ctx.set((s) => ({ ...s, subs, openKey: row.key, openRow: row, thread: null, threadScroll: 0 }));
|
|
1077
|
+
bannerFromSource(ctx, id, result.error);
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
ctx.set((s) => {
|
|
1081
|
+
// Mark the open row read (optimistic) across both views of it.
|
|
1082
|
+
const mark = (r) => (r.key === row.key ? { ...r, unread: false } : r);
|
|
1083
|
+
const rows = s.rows.map(mark);
|
|
1084
|
+
const rowsBySource = { ...s.rowsBySource };
|
|
1085
|
+
rowsBySource[id] = (rowsBySource[id] || []).map(mark);
|
|
1086
|
+
return { ...s, subs, rows, rowsBySource, openKey: row.key, openRow: row, thread: result.data, threadScroll: 0 };
|
|
1087
|
+
});
|
|
1088
|
+
updateSubtitle(ctx);
|
|
1089
|
+
},
|
|
1090
|
+
|
|
1091
|
+
/** @param {Ctx} ctx */
|
|
1092
|
+
cycleFilter(ctx) {
|
|
1093
|
+
ctx.set((s) => {
|
|
1094
|
+
const cyc = ['all', 'linkedin', 'gmail'];
|
|
1095
|
+
const i = cyc.indexOf(s.filter);
|
|
1096
|
+
const filter = cyc[(i + 1) % cyc.length] || 'all';
|
|
1097
|
+
const { rows, cursor } = mergeRows(s.rowsBySource, filter, s.rows, s.cursor);
|
|
1098
|
+
return { ...s, filter, rows, cursor };
|
|
1099
|
+
});
|
|
1100
|
+
updateSubtitle(ctx);
|
|
1101
|
+
},
|
|
1102
|
+
|
|
1103
|
+
/** @param {Ctx} ctx */
|
|
1104
|
+
startReply(ctx) {
|
|
1105
|
+
const s = ctx.state;
|
|
1106
|
+
if (!s.openKey || !s.thread) {
|
|
1107
|
+
ctx.signal.setBanner('Open a conversation first', 'action');
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
if (!s.thread.canReply) return;
|
|
1111
|
+
ctx.set((st) => ({ ...st, mode: 'reply', draft: '' }));
|
|
1112
|
+
ctx.signal.setMode('compose');
|
|
1113
|
+
},
|
|
1114
|
+
|
|
1115
|
+
/** @param {Ctx} ctx */
|
|
1116
|
+
startReact(ctx) {
|
|
1117
|
+
const s = ctx.state;
|
|
1118
|
+
if (!s.openKey || !s.thread) {
|
|
1119
|
+
ctx.signal.setBanner('Open a conversation first', 'action');
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
if (!s.thread.canReact || (s.thread.messages || []).length === 0) return;
|
|
1123
|
+
ctx.set((st) => ({ ...st, mode: 'react', reactCursor: 0 }));
|
|
1124
|
+
ctx.signal.setMode('react');
|
|
1125
|
+
},
|
|
1126
|
+
|
|
1127
|
+
/** @param {Ctx} ctx @param {string} payload */
|
|
1128
|
+
setDraft: (ctx, payload) => ctx.set((s) => ({ ...s, draft: typeof payload === 'string' ? payload : '' })),
|
|
1129
|
+
|
|
1130
|
+
/** @param {Ctx} ctx */
|
|
1131
|
+
async submitReply(ctx) {
|
|
1132
|
+
const s0 = ctx.state;
|
|
1133
|
+
const text = s0.draft.trim();
|
|
1134
|
+
ctx.set((s) => ({ ...s, mode: 'list' }));
|
|
1135
|
+
ctx.signal.setMode(null);
|
|
1136
|
+
if (!text) return;
|
|
1137
|
+
const row = s0.openRow;
|
|
1138
|
+
const id = row ? row.sourceId : null;
|
|
1139
|
+
if (!row || !id) {
|
|
1140
|
+
ctx.signal.setBanner('No open conversation to reply to.', 'error');
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
ctx.signal.setStatus('Sending…');
|
|
1144
|
+
const subs = cloneSubs(s0.subs);
|
|
1145
|
+
let result;
|
|
1146
|
+
try {
|
|
1147
|
+
result = id === 'linkedin'
|
|
1148
|
+
? await ctx.execute(sendMessageCommand, { target: subs.linkedin.target, port: subs.linkedin.port, csrf: (subs.linkedin.ctx && subs.linkedin.ctx.csrf) || row.ref.csrf, myMemberId: (subs.linkedin.ctx && subs.linkedin.ctx.memberId) || row.ref.memberId, recipient: row.ref.recipientId, text, conversationUrn: row.ref.urn })
|
|
1149
|
+
: await sendGmail(ctx, subs.gmail, row.ref, text);
|
|
1150
|
+
} catch (e) {
|
|
1151
|
+
result = { ok: false, error: defensiveError('send failed', e) };
|
|
1152
|
+
}
|
|
1153
|
+
if (!result.ok) {
|
|
1154
|
+
ctx.signal.setStatus(null);
|
|
1155
|
+
bannerFromSource(ctx, id, result.error);
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
// Reconcile by reloading the thread (best-effort).
|
|
1159
|
+
let thread = s0.thread;
|
|
1160
|
+
try {
|
|
1161
|
+
const reload = id === 'linkedin' ? await loadLiThread(ctx, subs.linkedin, row.ref) : await loadGmailThread(ctx, subs.gmail, row.ref);
|
|
1162
|
+
if (reload.ok) thread = reload.data;
|
|
1163
|
+
} catch { /* non-fatal */ }
|
|
1164
|
+
ctx.set((s) => ({ ...s, subs, draft: '', thread }));
|
|
1165
|
+
ctx.signal.setStatus('Sent');
|
|
1166
|
+
applyBanners(ctx, ctx.state.banners);
|
|
1167
|
+
},
|
|
1168
|
+
|
|
1169
|
+
/** @param {Ctx} ctx */
|
|
1170
|
+
reactPrev: (ctx) => ctx.set((s) => ({ ...s, reactCursor: Math.max(0, s.reactCursor - 1) })),
|
|
1171
|
+
/** @param {Ctx} ctx */
|
|
1172
|
+
reactNext: (ctx) => ctx.set((s) => ({ ...s, reactCursor: Math.min(EMOJIS.length - 1, s.reactCursor + 1) })),
|
|
1173
|
+
|
|
1174
|
+
/** @param {Ctx} ctx @param {number} [idx] */
|
|
1175
|
+
async submitReact(ctx, idx) {
|
|
1176
|
+
const s0 = ctx.state;
|
|
1177
|
+
const row = s0.openRow;
|
|
1178
|
+
const id = row ? row.sourceId : null;
|
|
1179
|
+
ctx.set((s) => ({ ...s, mode: 'list' }));
|
|
1180
|
+
ctx.signal.setMode(null);
|
|
1181
|
+
if (id !== 'linkedin' || !row) {
|
|
1182
|
+
ctx.signal.setBanner('Cannot react here.', 'error');
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
const emoji = EMOJIS[typeof idx === 'number' ? idx : s0.reactCursor] || EMOJIS[0];
|
|
1186
|
+
const subs = cloneSubs(s0.subs);
|
|
1187
|
+
const sub = subs.linkedin;
|
|
1188
|
+
const msgs = sub.threads[row.ref.urn] || [];
|
|
1189
|
+
const tgt = msgs.length ? msgs[msgs.length - 1] : null;
|
|
1190
|
+
if (!tgt || !tgt.urn) {
|
|
1191
|
+
ctx.signal.setBanner('LinkedIn: no message to react to', 'error');
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
const csrf = (sub.ctx && sub.ctx.csrf) || row.ref.csrf;
|
|
1195
|
+
if (!csrf) {
|
|
1196
|
+
ctx.signal.setBanner('LinkedIn: not authenticated yet', 'error');
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
ctx.signal.setStatus('Reacting…');
|
|
1200
|
+
let r;
|
|
1201
|
+
try {
|
|
1202
|
+
r = await ctx.execute(reactCommand, { target: sub.target, port: sub.port, csrf, messageUrn: tgt.urn, emoji });
|
|
1203
|
+
} catch (e) {
|
|
1204
|
+
r = { ok: false, error: defensiveError('react failed', e) };
|
|
1205
|
+
}
|
|
1206
|
+
if (!r.ok) {
|
|
1207
|
+
ctx.signal.setStatus(null);
|
|
1208
|
+
bannerFromSource(ctx, 'linkedin', r.error);
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
ctx.signal.setStatus('Reacted ' + emoji);
|
|
1212
|
+
applyBanners(ctx, ctx.state.banners);
|
|
1213
|
+
},
|
|
1214
|
+
|
|
1215
|
+
/** @param {Ctx} ctx */
|
|
1216
|
+
cancelCompose(ctx) {
|
|
1217
|
+
ctx.set((s) => ({ ...s, mode: 'list', draft: '' }));
|
|
1218
|
+
ctx.signal.setMode(null);
|
|
1219
|
+
},
|
|
1220
|
+
|
|
1221
|
+
/** @param {Ctx} ctx */
|
|
1222
|
+
async connectLinkedin(ctx) {
|
|
1223
|
+
const subs = cloneSubs(ctx.state.subs);
|
|
1224
|
+
const sub = subs.linkedin;
|
|
1225
|
+
ctx.signal.setStatus('Connecting LinkedIn…');
|
|
1226
|
+
let o;
|
|
1227
|
+
try {
|
|
1228
|
+
o = await ctx.execute(openTabCommand, { port: sub.port });
|
|
1229
|
+
} catch (e) {
|
|
1230
|
+
o = { ok: false, error: defensiveError('connect failed', e) };
|
|
1231
|
+
}
|
|
1232
|
+
ctx.signal.setStatus(null);
|
|
1233
|
+
if (!o.ok) {
|
|
1234
|
+
ctx.set((s) => ({ ...s, banners: { ...s.banners, linkedin: o.error } }));
|
|
1235
|
+
applyBanners(ctx, ctx.state.banners);
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
if (o.data) sub.target = o.data;
|
|
1239
|
+
sub.ctx = null;
|
|
1240
|
+
ctx.set((s) => ({ ...s, subs }));
|
|
1241
|
+
await ctx.dispatch('refresh');
|
|
1242
|
+
},
|
|
1243
|
+
|
|
1244
|
+
/** @param {Ctx} ctx */
|
|
1245
|
+
async connectGmail(ctx) {
|
|
1246
|
+
const subs = cloneSubs(ctx.state.subs);
|
|
1247
|
+
const sub = subs.gmail;
|
|
1248
|
+
ctx.signal.setStatus('Connecting Gmail…');
|
|
1249
|
+
let o;
|
|
1250
|
+
try {
|
|
1251
|
+
o = await ctx.execute(gmailOpen, { port: sub.port });
|
|
1252
|
+
} catch (e) {
|
|
1253
|
+
o = { ok: false, error: defensiveError('connect failed', e) };
|
|
1254
|
+
}
|
|
1255
|
+
ctx.signal.setStatus(null);
|
|
1256
|
+
if (!o.ok) {
|
|
1257
|
+
ctx.set((s) => ({ ...s, banners: { ...s.banners, gmail: o.error } }));
|
|
1258
|
+
applyBanners(ctx, ctx.state.banners);
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
if (o.data) sub.target = o.data;
|
|
1262
|
+
sub.ctx = null;
|
|
1263
|
+
sub.loginTabOpened = true; // we just opened it; don't double-open on ensureReady
|
|
1264
|
+
ctx.set((s) => ({ ...s, subs }));
|
|
1265
|
+
await ctx.dispatch('refresh');
|
|
1266
|
+
},
|
|
1267
|
+
|
|
1268
|
+
/** @param {Ctx} ctx */
|
|
1269
|
+
quit: (ctx) => ctx.signal.quit(),
|
|
1270
|
+
},
|
|
1271
|
+
};
|
|
1272
|
+
|
|
1273
|
+
export default core;
|