diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 974ce48..a184f59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,7 +59,7 @@ importers: dependencies: roamjs-components: specifier: 'catalog:' - version: 0.89.0(dd2f1f144015bc13ec70826008c78270) + version: 0.89.0(5222ab9d8d0f15037684080314cfeeb5) use-sync-external-store: specifier: 'catalog:' version: 1.6.0(react@17.0.2) @@ -72,13 +72,13 @@ importers: version: 30.0.1 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.2.0)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.2.0)(typescript@7.0.2))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.17.14)(jiti@1.21.7)(sass@1.103.1)(yaml@2.9.0)) + version: 4.1.10(@types/node@26.2.0)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.2.0)(typescript@7.0.2))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.25.12)(jiti@1.21.7)(sass@1.103.1)(yaml@2.9.0)) prototypes/properties-panel: dependencies: roamjs-components: specifier: 'catalog:' - version: 0.89.0(dd2f1f144015bc13ec70826008c78270) + version: 0.89.0(5222ab9d8d0f15037684080314cfeeb5) use-sync-external-store: specifier: 'catalog:' version: 1.6.0(react@17.0.2) @@ -97,7 +97,32 @@ importers: version: 30.0.1 vitest: specifier: 'catalog:' - version: 4.1.10(@types/node@26.2.0)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.2.0)(typescript@7.0.2))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.17.14)(jiti@1.21.7)(sass@1.103.1)(yaml@2.9.0)) + version: 4.1.10(@types/node@26.2.0)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.2.0)(typescript@7.0.2))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.25.12)(jiti@1.21.7)(sass@1.103.1)(yaml@2.9.0)) + + prototypes/roam-inbox: + dependencies: + roamjs-components: + specifier: 'catalog:' + version: 0.89.0(7b650c56182331394426bf3631cd9dff) + use-sync-external-store: + specifier: 'catalog:' + version: 1.6.0(react@17.0.2) + devDependencies: + '@discoursegraphs/extension-base': + specifier: workspace:* + version: link:../../packages/extension-base + '@types/node': + specifier: ^22.0.0 + version: 22.20.2 + jsdom: + specifier: 'catalog:' + version: 30.0.1 + typescript: + specifier: ^5.9.0 + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@22.20.2)(jsdom@30.0.1)(msw@2.15.0(@types/node@22.20.2)(typescript@5.9.3))(vite@8.2.1(@types/node@22.20.2)(esbuild@0.25.12)(jiti@1.21.7)(sass@1.103.1)(yaml@2.9.0)) prototypes/zotero-roam-mcp: dependencies: @@ -1568,6 +1593,9 @@ packages: '@types/nanoid@2.0.0': resolution: {integrity: sha512-NtwPHfAyU3IDXdKAB2OMPpAauHBg9gUjpOYr3FAzI84D70nWdS8k5mryteLvT/s1ACeAFAkGg132/XJVN4qx/w==} + '@types/node@22.20.2': + resolution: {integrity: sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==} + '@types/node@26.2.0': resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} @@ -3870,6 +3898,11 @@ packages: engines: {node: '>=4.2.0'} hasBin: true + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} @@ -3883,6 +3916,9 @@ packages: resolution: {integrity: sha512-6FTtyGr8ldU0pfbvW/eOZrEtEkczHRUtduBnA90Jh9kMPCiFNnXIon3vF41N0S4tV1HHQt4Hk1j4srpESziCaA==} engines: {node: '>=0.1.14'} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} @@ -4815,6 +4851,14 @@ snapshots: '@inquirer/ansi@2.0.7': {} + '@inquirer/confirm@6.3.0(@types/node@22.20.2)': + dependencies: + '@inquirer/core': 12.0.1(@types/node@22.20.2) + '@inquirer/type': 4.1.0(@types/node@22.20.2) + optionalDependencies: + '@types/node': 22.20.2 + optional: true + '@inquirer/confirm@6.3.0(@types/node@26.2.0)': dependencies: '@inquirer/core': 12.0.1(@types/node@26.2.0) @@ -4822,6 +4866,19 @@ snapshots: optionalDependencies: '@types/node': 26.2.0 + '@inquirer/core@12.0.1(@types/node@22.20.2)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/figures': 2.0.8 + '@inquirer/type': 4.1.0(@types/node@22.20.2) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 22.20.2 + optional: true + '@inquirer/core@12.0.1(@types/node@26.2.0)': dependencies: '@inquirer/ansi': 2.0.7 @@ -4836,6 +4893,11 @@ snapshots: '@inquirer/figures@2.0.8': {} + '@inquirer/type@4.1.0(@types/node@22.20.2)': + optionalDependencies: + '@types/node': 22.20.2 + optional: true + '@inquirer/type@4.1.0(@types/node@26.2.0)': optionalDependencies: '@types/node': 26.2.0 @@ -4961,7 +5023,7 @@ snapshots: '@playwright/test@1.29.0': dependencies: - '@types/node': 26.2.0 + '@types/node': 22.20.2 playwright-core: 1.29.0 '@popperjs/core@2.11.8': {} @@ -5087,6 +5149,21 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.63.1': optional: true + '@samepage/scripts@0.74.5(@aws-sdk/client-lambda@3.1111.0)(@aws-sdk/client-s3@3.1111.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@17.0.2(react@17.0.2))(react@17.0.2))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3)))(archiver@5.3.2)(axios@1.20.0(debug@4.4.3))(debug@4.4.3)(dotenv@16.6.1)(esbuild@0.25.12)(patch-package@6.5.1)(tailwindcss@3.3.2(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3)))(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3))(zod@4.1.11)': + dependencies: + '@aws-sdk/client-lambda': 3.1111.0 + '@aws-sdk/client-s3': 3.1111.0 + '@samepage/testing': 0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@17.0.2(react@17.0.2))(react@17.0.2))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3)) + archiver: 5.3.2 + axios: 1.20.0(debug@4.4.3) + debug: 4.4.3 + dotenv: 16.6.1 + esbuild: 0.25.12 + patch-package: 6.5.1 + tailwindcss: 3.3.2(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3)) + ts-node: 10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3) + zod: 4.1.11 + '@samepage/scripts@0.74.5(@aws-sdk/client-lambda@3.1111.0)(@aws-sdk/client-s3@3.1111.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@17.0.2(react@17.0.2))(react@17.0.2))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)))(archiver@5.3.2)(axios@1.20.0(debug@4.4.3))(debug@4.4.3)(dotenv@16.6.1)(esbuild@0.17.14)(patch-package@6.5.1)(tailwindcss@3.3.2(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)))(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2))(zod@4.1.11)': dependencies: '@aws-sdk/client-lambda': 3.1111.0 @@ -5102,6 +5179,33 @@ snapshots: ts-node: 10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2) zod: 4.1.11 + '@samepage/scripts@0.74.5(@aws-sdk/client-lambda@3.1111.0)(@aws-sdk/client-s3@3.1111.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@17.0.2(react@17.0.2))(react@17.0.2))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)))(archiver@5.3.2)(axios@1.20.0(debug@4.4.3))(debug@4.4.3)(dotenv@16.6.1)(esbuild@0.25.12)(patch-package@6.5.1)(tailwindcss@3.3.2(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)))(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2))(zod@4.1.11)': + dependencies: + '@aws-sdk/client-lambda': 3.1111.0 + '@aws-sdk/client-s3': 3.1111.0 + '@samepage/testing': 0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@17.0.2(react@17.0.2))(react@17.0.2))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)) + archiver: 5.3.2 + axios: 1.20.0(debug@4.4.3) + debug: 4.4.3 + dotenv: 16.6.1 + esbuild: 0.25.12 + patch-package: 6.5.1 + tailwindcss: 3.3.2(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)) + ts-node: 10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2) + zod: 4.1.11 + + '@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@17.0.2(react@17.0.2))(react@17.0.2))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3))': + dependencies: + '@playwright/test': 1.29.0 + '@testing-library/react': 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + '@testing-library/user-event': 14.6.4(@testing-library/dom@10.4.1) + '@types/jsdom': 20.0.1 + c8: 7.14.0 + debug: 4.4.3 + dotenv: 16.6.1 + jsdom: 30.0.1 + ts-node: 10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3) + '@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@17.0.2(react@17.0.2))(react@17.0.2))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2))': dependencies: '@playwright/test': 1.29.0 @@ -5389,7 +5493,7 @@ snapshots: '@types/jsdom@20.0.1': dependencies: - '@types/node': 26.2.0 + '@types/node': 22.20.2 '@types/tough-cookie': 4.0.5 parse5: 7.3.0 @@ -5397,7 +5501,11 @@ snapshots: '@types/nanoid@2.0.0': dependencies: - '@types/node': 26.2.0 + '@types/node': 22.20.2 + + '@types/node@22.20.2': + dependencies: + undici-types: 6.21.0 '@types/node@26.2.0': dependencies: @@ -5419,7 +5527,7 @@ snapshots: '@types/set-cookie-parser@2.4.10': dependencies: - '@types/node': 26.2.0 + '@types/node': 22.20.2 '@types/sizzle@2.3.10': {} @@ -5555,6 +5663,15 @@ snapshots: msw: 2.15.0(@types/node@26.2.0)(typescript@4.9.5) vite: 5.4.21(@types/node@26.2.0)(lightningcss@1.33.0)(sass@1.103.1) + '@vitest/mocker@4.1.10(msw@2.15.0(@types/node@22.20.2)(typescript@5.9.3))(vite@8.2.1(@types/node@22.20.2)(esbuild@0.25.12)(jiti@1.21.7)(sass@1.103.1)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.15.0(@types/node@22.20.2)(typescript@5.9.3) + vite: 8.2.1(@types/node@22.20.2)(esbuild@0.25.12)(jiti@1.21.7)(sass@1.103.1)(yaml@2.9.0) + '@vitest/mocker@4.1.10(msw@2.15.0(@types/node@26.2.0)(typescript@7.0.2))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.17.14)(jiti@1.21.7)(sass@1.103.1)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 @@ -5564,6 +5681,15 @@ snapshots: msw: 2.15.0(@types/node@26.2.0)(typescript@7.0.2) vite: 8.2.1(@types/node@26.2.0)(esbuild@0.17.14)(jiti@1.21.7)(sass@1.103.1)(yaml@2.9.0) + '@vitest/mocker@4.1.10(msw@2.15.0(@types/node@26.2.0)(typescript@7.0.2))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.25.12)(jiti@1.21.7)(sass@1.103.1)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.15.0(@types/node@26.2.0)(typescript@7.0.2) + vite: 8.2.1(@types/node@26.2.0)(esbuild@0.25.12)(jiti@1.21.7)(sass@1.103.1)(yaml@2.9.0) + '@vitest/pretty-format@2.0.5': dependencies: tinyrainbow: 1.2.0 @@ -6963,6 +7089,32 @@ snapshots: ms@2.1.3: {} + msw@2.15.0(@types/node@22.20.2)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 6.3.0(@types/node@22.20.2) + '@mswjs/interceptors': 0.41.9 + '@open-draft/deferred-promise': 3.0.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.14.2 + headers-polyfill: 5.0.1 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.11.11 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.2 + type-fest: 5.9.0 + until-async: 3.0.2 + yargs: 17.7.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + optional: true + msw@2.15.0(@types/node@26.2.0)(typescript@4.9.5): dependencies: '@inquirer/confirm': 6.3.0(@types/node@26.2.0) @@ -7196,6 +7348,14 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.26 + postcss-load-config@4.0.2(postcss@8.5.26)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3)): + dependencies: + lilconfig: 3.1.3 + yaml: 2.9.0 + optionalDependencies: + postcss: 8.5.26 + ts-node: 10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3) + postcss-load-config@4.0.2(postcss@8.5.26)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)): dependencies: lilconfig: 3.1.3 @@ -7413,6 +7573,108 @@ snapshots: dependencies: glob: 7.2.3 + roamjs-components@0.89.0(5222ab9d8d0f15037684080314cfeeb5): + dependencies: + '@blueprintjs/core': 3.54.0(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + '@blueprintjs/datetime': 3.24.1(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + '@blueprintjs/select': 3.19.1(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + '@samepage/scripts': 0.74.5(@aws-sdk/client-lambda@3.1111.0)(@aws-sdk/client-s3@3.1111.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@17.0.2(react@17.0.2))(react@17.0.2))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)))(archiver@5.3.2)(axios@1.20.0(debug@4.4.3))(debug@4.4.3)(dotenv@16.6.1)(esbuild@0.25.12)(patch-package@6.5.1)(tailwindcss@3.3.2(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)))(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2))(zod@4.1.11) + '@types/crypto-js': 4.1.1 + '@types/cytoscape': 3.31.0 + '@types/file-saver': 2.0.5 + '@types/jsdom': 20.0.1 + '@types/marked': 4.3.2 + '@types/nanoid': 2.0.0 + '@types/react': 17.0.39 + '@types/react-dom': 17.0.13 + '@types/use-sync-external-store': 0.0.3 + chrono-node: 2.3.0 + color: 4.2.3 + crypto-js: 3.1.9-1 + cytoscape: 3.34.1 + date-fns: 2.30.0 + edn-data: 1.2.2 + file-saver: 2.0.2 + fuzzy: 0.1.3 + hast-util-to-html: 7.1.3 + idb: 6.1.5 + insect: 5.9.1 + jsdom: 30.0.1 + jszip: 3.10.0 + marked: 4.0.16 + marked-react: 1.1.2(react@17.0.2) + nanoid: 3.3.18 + react: 17.0.2 + react-dom: 17.0.2(react@17.0.2) + refractor: 3.6.0 + tslib: 2.8.1 + use-sync-external-store: 1.6.0(react@17.0.2) + xregexp: 5.1.2 + transitivePeerDependencies: + - '@aws-sdk/client-lambda' + - '@aws-sdk/client-s3' + - '@samepage/testing' + - archiver + - axios + - debug + - dotenv + - esbuild + - patch-package + - tailwindcss + - ts-node + - zod + + roamjs-components@0.89.0(7b650c56182331394426bf3631cd9dff): + dependencies: + '@blueprintjs/core': 3.54.0(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + '@blueprintjs/datetime': 3.24.1(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + '@blueprintjs/select': 3.19.1(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + '@samepage/scripts': 0.74.5(@aws-sdk/client-lambda@3.1111.0)(@aws-sdk/client-s3@3.1111.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@17.0.2(react@17.0.2))(react@17.0.2))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3)))(archiver@5.3.2)(axios@1.20.0(debug@4.4.3))(debug@4.4.3)(dotenv@16.6.1)(esbuild@0.25.12)(patch-package@6.5.1)(tailwindcss@3.3.2(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3)))(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3))(zod@4.1.11) + '@types/crypto-js': 4.1.1 + '@types/cytoscape': 3.31.0 + '@types/file-saver': 2.0.5 + '@types/jsdom': 20.0.1 + '@types/marked': 4.3.2 + '@types/nanoid': 2.0.0 + '@types/react': 17.0.39 + '@types/react-dom': 17.0.13 + '@types/use-sync-external-store': 0.0.3 + chrono-node: 2.3.0 + color: 4.2.3 + crypto-js: 3.1.9-1 + cytoscape: 3.34.1 + date-fns: 2.30.0 + edn-data: 1.2.2 + file-saver: 2.0.2 + fuzzy: 0.1.3 + hast-util-to-html: 7.1.3 + idb: 6.1.5 + insect: 5.9.1 + jsdom: 30.0.1 + jszip: 3.10.0 + marked: 4.0.16 + marked-react: 1.1.2(react@17.0.2) + nanoid: 3.3.18 + react: 17.0.2 + react-dom: 17.0.2(react@17.0.2) + refractor: 3.6.0 + tslib: 2.8.1 + use-sync-external-store: 1.6.0(react@17.0.2) + xregexp: 5.1.2 + transitivePeerDependencies: + - '@aws-sdk/client-lambda' + - '@aws-sdk/client-s3' + - '@samepage/testing' + - archiver + - axios + - debug + - dotenv + - esbuild + - patch-package + - tailwindcss + - ts-node + - zod + roamjs-components@0.89.0(dd2f1f144015bc13ec70826008c78270): dependencies: '@blueprintjs/core': 3.54.0(react-dom@17.0.2(react@17.0.2))(react@17.0.2) @@ -7736,6 +7998,34 @@ snapshots: tagged-tag@1.0.0: {} + tailwindcss@3.3.2(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3)): + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 2.1.0 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.26 + postcss-import: 15.1.0(postcss@8.5.26) + postcss-js: 4.1.0(postcss@8.5.26) + postcss-load-config: 4.0.2(postcss@8.5.26)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3)) + postcss-nested: 6.2.0(postcss@8.5.26) + postcss-selector-parser: 6.1.4 + postcss-value-parser: 4.2.0 + resolve: 1.22.12 + sucrase: 3.35.1 + transitivePeerDependencies: + - ts-node + tailwindcss@3.3.2(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)): dependencies: '@alloc/quick-lru': 5.2.0 @@ -7859,6 +8149,26 @@ snapshots: ts-interface-checker@0.1.13: {} + ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.13 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 22.20.2 + acorn: 8.18.0 + acorn-walk: 8.3.5 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.4 + make-error: 1.3.6 + typescript: 5.9.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + optionalDependencies: + '@swc/core': 1.16.1 + ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -7893,6 +8203,8 @@ snapshots: typescript@4.9.5: {} + typescript@5.9.3: {} + typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 @@ -7924,6 +8236,8 @@ snapshots: typeson@6.1.0: {} + undici-types@6.21.0: {} + undici-types@8.3.0: {} undici@6.28.0: {} @@ -7995,6 +8309,21 @@ snapshots: lightningcss: 1.33.0 sass: 1.103.1 + vite@8.2.1(@types/node@22.20.2)(esbuild@0.25.12)(jiti@1.21.7)(sass@1.103.1)(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.26 + rolldown: 1.2.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.2 + esbuild: 0.25.12 + fsevents: 2.3.3 + jiti: 1.21.7 + sass: 1.103.1 + yaml: 2.9.0 + vite@8.2.1(@types/node@26.2.0)(esbuild@0.17.14)(jiti@1.21.7)(sass@1.103.1)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 @@ -8010,6 +8339,21 @@ snapshots: sass: 1.103.1 yaml: 2.9.0 + vite@8.2.1(@types/node@26.2.0)(esbuild@0.25.12)(jiti@1.21.7)(sass@1.103.1)(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.26 + rolldown: 1.2.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.2.0 + esbuild: 0.25.12 + fsevents: 2.3.3 + jiti: 1.21.7 + sass: 1.103.1 + yaml: 2.9.0 + vitest-mock-extended@2.0.2(typescript@4.9.5)(vitest@2.1.9(@types/node@26.2.0)(jsdom@26.0.0)(lightningcss@1.33.0)(msw@2.15.0(@types/node@26.2.0)(typescript@4.9.5))(sass@1.103.1)): dependencies: ts-essentials: 10.2.1(typescript@4.9.5) @@ -8052,6 +8396,34 @@ snapshots: - supports-color - terser + vitest@4.1.10(@types/node@22.20.2)(jsdom@30.0.1)(msw@2.15.0(@types/node@22.20.2)(typescript@5.9.3))(vite@8.2.1(@types/node@22.20.2)(esbuild@0.25.12)(jiti@1.21.7)(sass@1.103.1)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@22.20.2)(typescript@5.9.3))(vite@8.2.1(@types/node@22.20.2)(esbuild@0.25.12)(jiti@1.21.7)(sass@1.103.1)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.1(@types/node@22.20.2)(esbuild@0.25.12)(jiti@1.21.7)(sass@1.103.1)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.2 + jsdom: 30.0.1 + transitivePeerDependencies: + - msw + vitest@4.1.10(@types/node@26.2.0)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.2.0)(typescript@7.0.2))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.17.14)(jiti@1.21.7)(sass@1.103.1)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 @@ -8080,6 +8452,34 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.10(@types/node@26.2.0)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.2.0)(typescript@7.0.2))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.25.12)(jiti@1.21.7)(sass@1.103.1)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.2.0)(typescript@7.0.2))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.25.12)(jiti@1.21.7)(sass@1.103.1)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.1(@types/node@26.2.0)(esbuild@0.25.12)(jiti@1.21.7)(sass@1.103.1)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.2.0 + jsdom: 30.0.1 + transitivePeerDependencies: + - msw + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 diff --git a/prototypes/roam-inbox/CHANGELOG.md b/prototypes/roam-inbox/CHANGELOG.md new file mode 100644 index 0000000..b63bd5f --- /dev/null +++ b/prototypes/roam-inbox/CHANGELOG.md @@ -0,0 +1,45 @@ +# Changelog + +## 0.6.0 - 2026-09-10 + +- Ported into this repository as an installable developer extension: an ES + module built with the shared esbuild CLI, loaded from a URL or imported by a + `roam/js` block. Earlier versions were a single pasted `roam/js` file in the + private `DiscourseGraphs/roam-inbox` repository. +- Every graph read now goes through `window.roamAlphaAPI.data.async.*` and + every write through `data.block.*` and `data.page.*`. Datalog inputs are + parameterized. +- Two queries per refresh instead of three: messages and mentions come from + one query over `+Me` and are told apart by the `TODO` reference. Each row + pulls only its direct parent instead of every ancestor. +- `roamInbox.debug()` now reads the current state synchronously. The new + `await roamInbox.refresh()` re-queries the graph first. +- A refresh that finishes after a newer one started, or after unload, drops + its result. The fallback poll pauses while the tab is hidden and runs once + when the tab is shown again. +- The topbar observer now attaches whenever the badge first mounts, not only + when the topbar already existed at load. +- Hovering a picker entry moves the highlight instead of rebuilding the list. +- Fixed: a hashtag-form address (`#[[+You]]`) left a stray `#` in panel rows. +- Fixed: the one-second badge mount retry was not cancelled on unload. +- Tests moved to vitest: the unread-count cases from the roam/js build, text + flattening, result shaping, a source guard against default imports from + `roamjs-components` and legacy `roamAlphaAPI` aliases, and a built-bundle + load check that runs `onload` the way a `roam/js` loader block does. + +## 0.5.1 - 2026-07 (roam/js, before this repository) + +- The inbox glyph is an inline SVG. Blueprint's `bp3-icon-inbox` class + renders nothing in Roam, which left the badge floating over an empty box. + +## 0.5.0 - 2026-07 (roam/js, before this repository) + +- "New" is tracked by message uid rather than by comparing `:create/time` + against a "last looked" clock. The clock made every message added to an + existing block arrive already read, which pinned the badge at zero for the + whole standing backlog. + +## 0.4.0 - 2026-07 (roam/js, before this repository) + +- Initial pilot on `dg-team`: `/message` picker, topbar badge, inbox panel + with Open, Reply, and Done, toasts, and the fallback poll. diff --git a/prototypes/roam-inbox/README.md b/prototypes/roam-inbox/README.md new file mode 100644 index 0000000..e9c613d --- /dev/null +++ b/prototypes/roam-inbox/README.md @@ -0,0 +1,224 @@ +# Roam Inbox + +In-graph messaging for [Roam Research](https://roamresearch.com), with a signal. + +Two halves of one feature, deliberately shipped together: + +- **`/message`**: type it in any block, pick a person, and the block becomes + `{{[[TODO]]}} [[+Person Name]] `, which matches `[[Convention/Inbox]]` verbatim. +- **A topbar badge** that counts messages addressed to you that arrived since you last looked. Click + it for an inbox panel with **Open**, **Reply**, and **✓ Done** on every row. + +Neither half works alone. A nicer send with no signal just grows the pile, and a badge nobody feeds +stays at zero. It is one bundle, so you cannot install half of it. + +## Status + +Internal prototype for evaluation by Discourse Graphs, piloted on the `dg-team` graph. Ported from +the roam/js prototype (`DiscourseGraphs/roam-inbox`, v0.5.1) into this repository's installable +developer-extension form. Check which build you are running with `roamInbox.debug()` in the browser +console. + +## Why this exists + +The `[[Convention/Inbox]]` convention worked on paper and rotted in practice. Measured on the +`dg-team` graph: 68 open messages across 7 people, the oldest from December 2024. That is only +about 4 per month per person, so volume was never the problem. Three things were: + +1. **No signal.** The inbox was a Query Builder block nested under a collapsed heading on a + personal home page, capped at 5 results. Nothing announced a message. +2. **The queue was hand-built per person.** Two active members had no `+Name` page and no inbox + query at all. They were silently unmessageable and nobody noticed. +3. **"Read" and "done" were the same checkbox.** You could not acknowledge a message without + declaring the underlying work finished, so messages sat as TODO forever and the inbox degraded + into a backlog. + +This fixes all three. The picker makes the `+` prefix invisible. The extension owns the query, so no +setup is required. The badge tracks *new since you looked*, while the checkbox keeps meaning +*handled*. + +## The two channels + +| you write | means | shows up in | badges? | +| --- | --- | --- | --- | +| `{{[[TODO]]}} [[+Name]]` (`/message`) | **addressed to** that person | their **Messages** tab | yes | +| `[[+Name]]` with no checkbox | cc / FYI | their **Mentions** tab | no | +| `{{[[TODO]]}} #[[Name]]` (`/task`) | **assigned work** | their **Tasks** tab | no | + +The `+` is an addressing operator, not an accident. It is what separates "here is a message for +you" from "this block is about you". That distinction was never written down anywhere, which is how +`/task` and the inbox drifted into two disconnected channels. Tasks are shown but never badged: there +are far more of them, and a permanently large badge is not a signal. + +## Install + +Load this developer-extension URL in Roam, under **Load Developer Extensions from URL**: + +```text +https://discoursegraphs.com/releases/prototypes/roam-inbox/ +``` + +An inbox icon appears in the topbar next to the search box, and **Send message** appears in the +slash menu when you type `/message`. + +To try a pull-request preview without touching your settings, put a loader in a `roam/js` block +instead. The published bundle is an ES module, so it cannot be pasted into a block directly, but it +can be imported by one: + +```js +(async () => { + const url = "https://discoursegraphs.com/releases/prototypes/roam-inbox/extension.js"; + const globalKey = "__roamInboxExtension"; + + const previous = window[globalKey]; + if (previous?.onunload) await previous.onunload(); + + const module = await import(`${url}?v=${Date.now()}`); + const extension = module.default; + if (!extension?.onload) throw new Error("The loaded module is not a Roam extension."); + + await extension.onload({ extensionAPI: undefined, extension: { version: "roam/js" } }); + window[globalKey] = extension; +})().catch((error) => console.error("Could not load Roam Inbox:", error)); +``` + +Give each extension its own `globalKey`. Two loaders sharing one key will unload each other. This +path never receives a published `extension.css`, so the extension carries its stylesheet inside the +bundle and both paths look the same. + +## Behaviour worth knowing + +- **A fresh browser starts at zero, not at your backlog.** Everything already in your inbox is + recorded as seen the first time the extension runs in a browser, so you get a clean badge instead + of an accusatory 24. The panel still lists everything, and the icon carries a small muted dot so a + non-empty inbox is still discoverable. A *number* is reserved for what is genuinely new, because a + permanent count of everything open stops reading as a signal. +- **Addressing an existing block counts as a new message.** Most messages on `dg-team` are not + typed into a brand-new bullet. Someone adds `[[+You]]` to a bullet that is already there, such as + last week's meeting note or a thread from January. That is a new message to you even though the + block is old, and it badges. +- **Messages you address to yourself do count.** `[[+You]]` is how several people already keep a + personal todo queue. Measured on `dg-team`, 12 of the 68 open messages are self-addressed, and + they are deliberate reminders, not noise. An earlier version filtered them out of the badge and + silently under-reported those inboxes. You still will not get a *toast* for something you just + typed; that exclusion lives at the toast. +- **Opening the panel clears the badge but keeps the blue "new" marks** for that viewing, so you can + still see what arrived while you were away. +- **Replies are auto-addressed back to the sender.** Hitting **Reply** creates a child block + pre-filled with `{{[[TODO]]}} [[+Sender]] `, so the thread notifies both ways instead of + dead-ending in a nested bullet nobody queries. +- **Messaging someone with no `+Name` page just works.** The picker creates the page before + inserting the link, so first-time recipients are not silently unreachable. +- **What you have seen lives in `localStorage`, not the graph.** Writing it on every panel open + would churn edit-times and pollute the recency signals the team's weekly activity review reads. + The cost is that it is per-browser. + +## Customization + +All knobs are in [`src/config.ts`](src/config.ts) and take effect at build time: + +- **`insertFor`**: the inserted format. Default `{{[[TODO]]}} [[+Name]] `. +- **`replyStubFor`**: what **Reply** pre-fills. Return `""` for a plain empty child. +- **`ADDRESS_PREFIX`**: the `+` addressing character. +- **`LABEL`**: the slash-menu label ("Send message"). +- **`POLL_MS`**: fallback refresh interval (default 60 s) if the pull watch does not fire. +- **`TIP_DELAY_MS`**: how long a hover waits before the tooltip appears (default 80 ms). +- **`ACTIVE_WITHIN_DAYS`** and **`EXCLUDE_PATTERNS`**: who is in the picker's browse list. Same + semantics as `roam-task-assign`. + +## How it works (for maintainers) + +The source is split by concern. `picker.ts` is the `/message` half. `badge.ts`, `panel.ts`, and +`toast.ts` are the receive half; each subscribes to `inbox.ts`, which holds the lists and the seen +set, and repaints itself when the lists change. `roam.ts` is the only module that talks to the +graph. `live.ts` triggers refreshes. `lifecycle.ts` is load and unload, and `index.ts` wraps it in +`runExtension`. + +- **The slash command** registers through `window.roamAlphaAPI.ui.slashCommand.addCommand`, which is + real but undocumented. The callback returns `""`, which makes Roam delete the typed `/message` + filter text; the callback context provides `block-uid` and `window-id`. +- **Two Roam editor traps, both verified live, shape the picker** (see `editor.ts`). First, the + datascript store lags the textarea indefinitely while a block is being edited: after Roam strips + the slash text, the textarea reads `""` while `:block/string` still reads `"/message"`, until the + editor closes. So the picker waits for the textarea's own value to change, never for the store to + agree with it. Second, `updateBlock` under an open editor gets clobbered when the editor later + flushes its own stale value. So the insert is written through the textarea, with the native value + setter plus a bubbling `input` event, which is exactly what typing does. It falls back to a store + write only when no editor is mounted. +- **Every graph read is async** through `window.roamAlphaAPI.data.async.q`, as this repository + requires. Pull results are read namespaced-key-first (`pick` in `roam.ts`): on some API surfaces + the short key does not come back `undefined` but resolves to a ClojureScript internal, and reading + short-first silently yields garbage that looks like an empty graph. +- **Two queries per refresh.** One pulls every open block that references `+Me`; messages and + mentions are told apart by whether the block also references `TODO`. The other pulls open TODOs + that reference `Me`, the `/task` channel. A refresh that finishes after a newer one started, or + after unload, drops its result rather than overwriting fresher state. +- **"New" is tracked by message uid, not by timestamp.** A message becomes addressed to you when + someone *edits* a block to add `[[+You]]`, and that block can be any age. An earlier build compared + `:create/time` against a "last looked" clock, so a message added to an existing bullet arrived + already read. Measured on `dg-team`: 65 open messages with create-times spanning Dec 2024 to Jul + 2026, so the whole standing backlog was permanently uncountable. `:edit/time` is not the fix + either, because it moves on every later typo fix. A set of uids in `localStorage` has neither + failure mode. It is capped at 2000 entries; measured traffic is about 4 per person per month. +- **Live updates** come from `addPullWatch` on the `+Me` page with pattern + `[{:block/_refs [:block/uid]}]`, so any new block referencing you fires it. A 60 s poll runs + alongside as insurance, not as the primary path. The poll skips while the tab is hidden and runs + once as soon as the tab is shown again. +- **The badge count is anchored to an inner 16px `.rmi-glyph` span, not to the button.** The + button's width is not stable (24px empty, 37px with a bubble, and a graph's `roam/css` can pad it + further), so anchoring to the button pushes the badge out toward the neighbouring icon. +- **The inbox glyph is an inline SVG, not a Blueprint icon class.** `bp3-icon-inbox` renders nothing + in Roam: Roam ships Blueprint's SVG icon components and not the per-icon font CSS, so the class + resolved to an empty 16px box. Drawing it inline also survives a Blueprint version bump. +- **The re-mount observer watches the topbar's direct parent only**, with `subtree: false`. It is + deliberately not on `document.body`, the busiest node in the app. A real page navigation does not + replace the topbar. Every refresh re-checks the mount as a backstop. +- **Tooltips are hand-rolled.** The native `title` attribute waits about a second and cannot be + styled, and Blueprint's tooltip is a React component with no imperative entry point. +- **Message text is flattened for display.** Block refs collapse before markdown links are + stripped, because an alias whose target is a block ref is three levels of nested parentheses. The + same flattening runs on the breadcrumb's page title, since in a discourse graph most pages are + `[[ISS]] - …` or `[[QUE]] - …`. + +## Diagnosing + +`roamInbox.debug()` in the console reports the running version, who it thinks you are, whether the +pull watch is live, the three counts, what the badge should be showing, how many messages you have +acknowledged, and whether the newest one is unread. `hint` says in words why the badge looks the way +it does. It reads the current state; run `await roamInbox.refresh()` first to re-query the graph. + +## Development + +From the repository root, `pnpm --dir prototypes/roam-inbox test`, `typecheck`, and `build`. + +- `tests/inbox.spec.ts` loads the extension against a fake graph and pins the unread count, which + is the part that was wrong in earlier builds. +- `tests/bundle.spec.ts` imports the built `dist/extension.js` into jsdom and runs `onload` exactly + as the roam/js loader block does. It skips until you have run `pnpm build`. +- `tests/interop.spec.ts` guards the source against default imports from `roamjs-components` and + the legacy top-level `roamAlphaAPI` aliases. +- `pnpm typecheck` is opt-in; the repository has no shared typecheck step. + +## Relationship to the sibling prototypes + +The person picker is forked from **`roam-task-assign`** (the `/task` command), the same way +**`roam-feedback`** (`/feedback`) was. Both still live only in a local working folder, not on +GitHub. Each prototype has to stay independently installable, so they are copies rather than a +shared library. That is three copies of the picker now. If a fourth appears, extract a shared core. + +Note for whoever touches `roam-task-assign` next: its `/task` picker waits for Roam's datascript +store to agree with the textarea before opening. That wait can hang indefinitely, for the reason +described under **How it works**. It has not been fixed there. + +## Verification + +The 0.5.x roam/js build was verified end to end on `sandbox-discourse-graphs`: slash command +registration and de-duplication, picker filtering, insert format, auto-creation of a missing +`+Name` page, pull-watch live refresh, badge clear-on-open with persistent new-marks, check-off, +auto-addressed reply, tooltip alignment, and dark theme. Query counts were cross-checked against an +independent datalog implementation on both `sandbox-discourse-graphs` (22 / 12 / 10) and `dg-team` +(24 / 15 / 14). Measured latency from a new message to the badge repainting was 2.6 s in a +throttled automation tab; the debounce itself is 300 ms. + +The 0.6.0 port is verified by the unit tests and the built-bundle load check above. Its first live +run in Roam goes through the pull-request preview URL. diff --git a/prototypes/roam-inbox/package.json b/prototypes/roam-inbox/package.json new file mode 100644 index 0000000..2eecf2a --- /dev/null +++ b/prototypes/roam-inbox/package.json @@ -0,0 +1,24 @@ +{ + "name": "roam-inbox", + "version": "0.6.0", + "private": true, + "description": "In-graph messaging for Roam: a /message command that addresses a block to a person, and a topbar inbox badge that counts what is new since you last looked.", + "type": "module", + "scripts": { + "dev": "roam-prototype dev", + "build": "roam-prototype build", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -p tsconfig.check.json" + }, + "dependencies": { + "roamjs-components": "catalog:", + "use-sync-external-store": "catalog:" + }, + "devDependencies": { + "@discoursegraphs/extension-base": "workspace:*", + "@types/node": "^22.0.0", + "jsdom": "catalog:", + "typescript": "^5.9.0", + "vitest": "catalog:" + } +} diff --git a/prototypes/roam-inbox/src/actions.ts b/prototypes/roam-inbox/src/actions.ts new file mode 100644 index 0000000..943e183 --- /dev/null +++ b/prototypes/roam-inbox/src/actions.ts @@ -0,0 +1,41 @@ +/* What the panel's row buttons do to the graph. */ +import { logError, replyStubFor } from "~/config"; +import { findTextarea, typeInto } from "~/editor"; +import { blockString, createChildBlock, focusBlock, type Message, openInSidebar, updateBlock } from "~/roam"; +import { toggleDone } from "~/text"; + +export const open = (uid: string): void => { + void openInSidebar(uid).catch((e) => logError("could not open sidebar", e)); +}; + +export const markDone = async (uid: string): Promise => { + // If the row is also open in an editor (common: you clicked Open first), + // write through the textarea so the closing editor cannot flush its stale + // value back over the check-off. Same trap as the picker's insert. + const ta = findTextarea(uid); + const next = toggleDone(ta ? ta.value : await blockString(uid)); + if (!next) return; + if (ta) typeInto(ta, next, next.length); + else await updateBlock(uid, next); +}; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +export const replyTo = async (row: Message): Promise => { + // Open the thread in the sidebar before focusing: a block that is not + // mounted cannot take focus, and the sidebar is where the reply is read. + const [childUid] = await Promise.all([ + createChildBlock(row.uid, replyStubFor(row.author)), + openInSidebar(row.uid), + ]); + await sleep(350); + const ta = findTextarea(childUid); + if (ta) { + ta.focus(); + ta.setSelectionRange(ta.value.length, ta.value.length); + return; + } + await focusBlock(childUid).catch(() => { + /* the stub is in the sidebar; the user can click it */ + }); +}; diff --git a/prototypes/roam-inbox/src/badge.ts b/prototypes/roam-inbox/src/badge.ts new file mode 100644 index 0000000..c68fd59 --- /dev/null +++ b/prototypes/roam-inbox/src/badge.ts @@ -0,0 +1,114 @@ +/* The topbar icon and its count. Subscribes to the inbox and repaints + * itself; never queries. */ +import { BADGE_ID, INBOX_SVG, MOUNT_RETRY_MS } from "~/config"; +import { el } from "~/dom"; +import { state, subscribe, unreadMessages } from "~/inbox"; +import { togglePanel } from "~/panel"; +import { attachTooltip } from "~/tooltip"; + +const openMessages = (n: number): string => `${n} open message${n === 1 ? "" : "s"}`; + +const badgeTooltip = (): string => { + const n = unreadMessages().length; + const open = state.messages.length; + if (n) return `${n} new · ${openMessages(open)}`; + if (open) return `Inbox — ${openMessages(open)}, nothing new`; + return "Inbox — all clear"; +}; + +const renderBadge = (): void => { + const btn = document.getElementById(BADGE_ID); + if (!btn) return; + const glyph = btn.querySelector(".rmi-glyph") || btn; + const n = unreadMessages().length; + const open = state.messages.length; + let bubble = btn.querySelector(".rmi-count"); + if (n > 0 || open > 0) { + if (!bubble) { + bubble = document.createElement("span"); + glyph.appendChild(bubble); // anchored to the glyph, not the button + } + // A number only for what is actually new. A permanent count of everything + // open is the failure mode this whole design avoids: it stops being a + // signal. But a silent icon over a 25-message backlog is not discoverable + // either, so "non-empty" gets a muted dot. + bubble.className = n > 0 ? "rmi-count" : "rmi-count rmi-dot"; + bubble.textContent = n > 0 ? (n > 99 ? "99+" : String(n)) : ""; + } else { + bubble?.remove(); + } +}; + +const mount = (topbar: Element): void => { + // Blueprint classes so it inherits Roam's own topbar button styling. The + // icon lives on an inner span rather than on the button, so the badge has + // a fixed-size element to anchor to (see .rmi-glyph in the CSS). + const btn = el("span", "bp3-button bp3-minimal bp3-small pointer rmi-btn"); + btn.id = BADGE_ID; + // `bp3-icon` alone still buys Blueprint's icon colour inheritance; the + // glyph itself is the inline SVG (see INBOX_SVG for why). + const glyph = el("span", "bp3-icon rmi-glyph"); + glyph.innerHTML = INBOX_SVG; + btn.appendChild(glyph); + btn.addEventListener("mousedown", (e) => { + e.preventDefault(); + e.stopPropagation(); + togglePanel(); + }); + attachTooltip(btn, badgeTooltip); + + const anchor = topbar.querySelector(".rm-find-or-create-wrapper"); + if (anchor && anchor.parentElement === topbar) anchor.insertAdjacentElement("afterend", btn); + else topbar.appendChild(btn); + renderBadge(); +}; + +let observer: MutationObserver | null = null; +let retryTimer: number | null = null; +let unsubscribe: (() => void) | null = null; + +/* Mount into `.rm-topbar` after the search box if not already there, and + * watch for the topbar being replaced. Idempotent, so it is safe to call + * from every place that might notice the badge missing. False only while + * the topbar is not rendered yet. */ +const ensureBadge = (): boolean => { + const topbar = document.querySelector(".rm-topbar"); + if (!topbar) return false; + // Deliberately NOT `observe(document.body, {subtree: true})`. That is the + // busiest node in the app: it fires on every keystroke, every block render, + // every sidebar update. Measured against a real page navigation, the topbar + // was not replaced at all, so the expensive watch buys almost nothing. + // Watching only the topbar's direct parent with subtree:false fires solely + // when the topbar element itself is swapped, which is the only case that + // can orphan the badge. Every inbox refresh re-checks the mount as a + // backstop, so even a missed mutation self-heals within POLL_MS. + if (!observer && topbar.parentElement) { + observer = new MutationObserver(() => ensureBadge()); + observer.observe(topbar.parentElement, { childList: true, subtree: false }); + } + if (!document.getElementById(BADGE_ID)) mount(topbar); + return true; +}; + +export const startBadge = (): void => { + unsubscribe = subscribe(() => { + ensureBadge(); + renderBadge(); + }); + if (!ensureBadge()) { + retryTimer = window.setTimeout(() => { + retryTimer = null; + ensureBadge(); + }, MOUNT_RETRY_MS); + } +}; + +export const stopBadge = (): void => { + unsubscribe?.(); + unsubscribe = null; + observer?.disconnect(); + observer = null; + if (retryTimer !== null) clearTimeout(retryTimer); + retryTimer = null; + document.getElementById(BADGE_ID)?.remove(); +}; diff --git a/prototypes/roam-inbox/src/config.ts b/prototypes/roam-inbox/src/config.ts new file mode 100644 index 0000000..d5c31a0 --- /dev/null +++ b/prototypes/roam-inbox/src/config.ts @@ -0,0 +1,61 @@ +/* Every knob in one place. What each one does is documented in README.md. */ + +// Injected by the shared esbuild CLI from package.json; "0.0.0" under vitest. +export const VERSION = process.env.VERSION || "0.0.0"; + +export const LOG = "[roam-inbox]"; +export const logError = (what: string, error: unknown): void => + console.error(`${LOG} ${what}`, error); + +// The slash-menu entry. Typing "/message" filters Roam's menu down to it. +export const LABEL = "Send message"; + +// `[[+Name]]` means "addressed to Name". `#[[Name]]` (what /task inserts) +// means "assigned work". Both are surfaced in the panel; only the first badges. +const ADDRESS_PREFIX = "+"; +export const addressFor = (name: string): string => `${ADDRESS_PREFIX}${name}`; +// What /message inserts. Matches [[Convention/Inbox]] verbatim. +export const insertFor = (name: string): string => `{{[[TODO]]}} [[${addressFor(name)}]] `; +// What Reply pre-fills: a message back to the sender, so the thread notifies +// both ways instead of dead-ending in a nested bullet nobody queries. +export const replyStubFor = (author: string | null): string => (author ? insertFor(author) : ""); + +export const MEMBER_CACHE_MS = 2 * 60 * 1000; +export const MAX_FILTER_LEN = 30; +// The browse list (nothing typed yet) shows only users active within this +// window, most recent first. Typing a filter searches every graph member. +export const ACTIVE_WITHIN_DAYS = 60; +// Display pages that are not people. Activity filtering alone cannot catch +// these: MCP and API writes make tokens look active. +export const EXCLUDE_PATTERNS = [/^(Local )?API Token:/i, /^Anonymous(_\d+)?$/]; + +export const POLL_MS = 60 * 1000; // fallback if the pull watch does not fire +export const WATCH_DEBOUNCE_MS = 300; +export const MOUNT_RETRY_MS = 1000; // one retry if the topbar is not rendered yet +export const TOAST_MS = 9000; +export const TOAST_MAX = 3; +// Roam's own tooltips feel instant; the browser's native `title` waits ~1 s. +export const TIP_DELAY_MS = 80; +// Acknowledged message uids kept per browser. Measured traffic is about four +// messages per person per month, so this is decades. +export const SEEN_MAX = 2000; + +export const STYLE_ID = "roam-inbox-style"; +export const BADGE_ID = "roam-inbox-badge"; + +// Drawn inline rather than via Blueprint's `bp3-icon-inbox` class. Roam ships +// Blueprint's SVG icon components and not the per-icon font CSS, so +// `.bp3-icon-inbox::before` resolves to `content: none`: the class rendered an +// empty 16px box and the badge looked like a dot floating in the topbar. +// Verified live on sandbox-discourse-graphs: Icons16 is loaded but unmapped, +// and Roam's own search icon has an child. Drawing it here also +// survives Roam changing Blueprint versions. `currentColor` keeps it correct +// in dark theme. +export const INBOX_SVG = + '"; diff --git a/prototypes/roam-inbox/src/dom.ts b/prototypes/roam-inbox/src/dom.ts new file mode 100644 index 0000000..e52b162 --- /dev/null +++ b/prototypes/roam-inbox/src/dom.ts @@ -0,0 +1,22 @@ +/* Two helpers every UI module uses. */ + +export const el = ( + tag: K, + className: string, + text?: string, +): HTMLElementTagNameMap[K] => { + const node = document.createElement(tag); + node.className = className; + if (text !== undefined) node.textContent = text; + return node; +}; + +/* mousedown rather than click, and prevented, so focus stays where it is: + * in the block editor for the picker, and off the panel for its buttons. */ +export const onPress = (node: HTMLElement, fn: () => void): void => { + node.addEventListener("mousedown", (e) => { + e.preventDefault(); + e.stopPropagation(); + fn(); + }); +}; diff --git a/prototypes/roam-inbox/src/editor.ts b/prototypes/roam-inbox/src/editor.ts new file mode 100644 index 0000000..acbf5d0 --- /dev/null +++ b/prototypes/roam-inbox/src/editor.ts @@ -0,0 +1,71 @@ +/* Talking to Roam's block editor: the open textarea, not the store. + * + * Two editor traps, both verified live, shape everything here: + * + * 1. While a block is being edited, the datascript store lags the textarea + * indefinitely. After a slash command strips the typed text, the textarea + * reads "" while `:block/string` still reads "/message", until the editor + * closes. So never wait for the store to agree with the textarea. + * + * 2. `updateBlock` under an open editor gets clobbered when that editor later + * flushes its own stale value. So writes go through the textarea whenever + * one is mounted: the native value setter plus a bubbling `input` event is + * exactly what typing does, so Roam persists it normally. + */ + +/* Roam block textareas have ids ending in the 9-character block uid. */ +export const findTextarea = (uid: string): HTMLTextAreaElement | null => + Array.from(document.querySelectorAll("textarea.rm-block-input")).find( + (ta) => ta.id?.endsWith(uid), + ) || null; + +// The prototype's setter, not `ta.value =`: a plain assignment goes through +// React's per-instance value tracker, so the `input` event that follows is +// seen as a non-change and Roam persists nothing. +const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!; + +export const typeInto = (ta: HTMLTextAreaElement, text: string, cursor: number): void => { + setValue.call(ta, text); + ta.dispatchEvent(new Event("input", { bubbles: true })); + ta.setSelectionRange(cursor, cursor); + ta.focus(); +}; + +const MIRRORED_STYLES = [ + "boxSizing", "width", "paddingTop", "paddingRight", "paddingBottom", + "paddingLeft", "borderTopWidth", "borderRightWidth", "borderBottomWidth", + "borderLeftWidth", "fontFamily", "fontSize", "fontWeight", "fontStyle", + "letterSpacing", "lineHeight", "textTransform", "wordSpacing", + "textIndent", "whiteSpace", "wordWrap", "overflowWrap", "tabSize", +] as const; + +/* Viewport coordinates just below the caret (the standard mirror-div + * technique). Falls back to the textarea's bottom-left corner. */ +export const caretCoords = (textarea: HTMLTextAreaElement): { top: number; left: number } => { + const rect = textarea.getBoundingClientRect(); + try { + const mirror = document.createElement("div"); + const style = window.getComputedStyle(textarea); + for (const p of MIRRORED_STYLES) mirror.style[p] = style[p]; + Object.assign(mirror.style, { + position: "absolute", + visibility: "hidden", + whiteSpace: "pre-wrap", + wordWrap: "break-word", + top: "0", + left: "-9999px", + }); + mirror.textContent = textarea.value.substring(0, textarea.selectionStart); + const marker = document.createElement("span"); + marker.textContent = "​"; + mirror.appendChild(marker); + document.body.appendChild(mirror); + const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2 || 20; + const top = rect.top + marker.offsetTop - textarea.scrollTop + lineHeight; + const left = rect.left + marker.offsetLeft - textarea.scrollLeft; + mirror.remove(); + return { top: Math.min(top, rect.bottom + 4), left }; + } catch { + return { top: rect.bottom + 4, left: rect.left }; + } +}; diff --git a/prototypes/roam-inbox/src/inbox.ts b/prototypes/roam-inbox/src/inbox.ts new file mode 100644 index 0000000..762a7d5 --- /dev/null +++ b/prototypes/roam-inbox/src/inbox.ts @@ -0,0 +1,143 @@ +/* The inbox's state: who I am, the three lists, and which messages I have + * already acknowledged. Everything that renders subscribes here and repaints + * itself when the lists change. + */ +import { addressFor, SEEN_MAX } from "~/config"; +import { fetchList, graphName, type Identity, type Message, Q_OPEN_REFS, Q_OPEN_TODOS } from "~/roam"; + +export type Me = Identity & { address: string }; + +const empty = () => ({ + me: null as Me | null, + messages: [] as Message[], + mentions: [] as Message[], + tasks: [] as Message[], +}); + +export const state = empty(); + +export const setIdentity = (me: Identity): Me => (state.me = { ...me, address: addressFor(me.name) }); + +export const myAddress = (): string | null => state.me?.address ?? null; + +// ---------------------------------------------------------------- listeners + +const listeners = new Set<() => void>(); + +/* Called after every change to the lists or the seen set. */ +export const subscribe = (listener: () => void): (() => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +const notify = (): void => listeners.forEach((fn) => fn()); + +// -------------------------------------------------------------------- lists + +let refreshSeq = 0; + +/* Re-query the graph. Two queries: everything open that references `+Me`, + * split into messages (with a TODO) and mentions (without), and open TODOs + * that reference `Me` (the /task channel). A refresh that finishes after a + * newer one started, or after unload, drops its result. */ +export const refreshLists = async (): Promise => { + const me = state.me; + if (!me) return; + const mine = ++refreshSeq; + const [addressed, named] = await Promise.all([ + fetchList(Q_OPEN_REFS, me.address), + fetchList(Q_OPEN_TODOS, me.name), + ]); + if (mine !== refreshSeq || state.me !== me) return; + state.messages = addressed.filter((m) => m.todo); + state.mentions = addressed.filter((m) => !m.todo); + state.tasks = named; + notify(); +}; + +// ------------------------------------------------------------------- seen set + +/* "New" is tracked by message uid, NOT by timestamp. + * + * A message becomes addressed to you the moment someone edits a block to add + * `[[+You]]`, and that block can be arbitrarily old: a bullet from last + * month's meeting, a reply appended to a January thread. An earlier build + * compared `:create/time` against a "last looked" clock, which made every one + * of those arrive already read. Measured on dg-team: 65 open messages with + * create-times spanning Dec 2024 to Jul 2026, so the whole standing backlog + * was permanently uncountable and only a block born after your last panel + * open could ever light the badge. + * + * `:edit/time` is not the fix either: it moves on every later typo fix, so + * messages you had already dealt with would light up again. A set of uids + * has neither failure mode. + * + * It lives in localStorage, not the graph: writing on every panel open would + * churn edit-times and pollute the recency signals the team's weekly activity + * review reads. The cost is that it is per-browser. */ +let seen: Set | null = null; + +const seenKey = () => `roam-inbox:seen:${graphName()}:${state.me?.uid}`; +const legacyKey = () => `roam-inbox:lastSeen:${graphName()}:${state.me?.uid}`; + +const saveSeen = (): void => { + if (!seen) return; + // Insertion-ordered, so trimming from the front drops the oldest. + if (seen.size > SEEN_MAX) seen = new Set([...seen].slice(-SEEN_MAX)); + try { + window.localStorage.setItem(seenKey(), JSON.stringify([...seen])); + } catch { + /* private mode; the badge just will not persist across reloads */ + } +}; + +export const loadSeen = (): Set => { + if (seen) return seen; + try { + const raw = window.localStorage.getItem(seenKey()); + if (raw != null) { + seen = new Set(JSON.parse(raw) as string[]); + return seen; + } + } catch { + /* private mode, or a corrupt value: re-seed below */ + } + // A fresh browser, or an upgrade from the lastSeen build: everything already + // in the inbox is "not new to me", so the badge starts clean instead of at + // the whole backlog. The panel still lists all of it. + seen = new Set(state.messages.map((m) => m.uid)); + try { + window.localStorage.removeItem(legacyKey()); + } catch { + /* nothing to clean up */ + } + saveSeen(); + return seen; +}; + +export const markSeen = (rows: Message[]): void => { + const s = loadSeen(); + const before = s.size; + rows.forEach((m) => s.add(m.uid)); + if (s.size === before) return; + saveSeen(); + notify(); +}; + +/* Self-addressed messages DO count. Measured on dg-team: 12 of the 68 open + * messages are `[[+Self]]`, and they are deliberate personal todos, not + * noise. Filtering them out silently under-reported those inboxes. What you + * do not want is a toast for something you just typed; that exclusion lives + * at the toast. */ +export const unreadMessages = (): Message[] => { + const s = loadSeen(); + return state.messages.filter((m) => !s.has(m.uid)); +}; + +export const resetInbox = (): void => { + seen = null; + listeners.clear(); + Object.assign(state, empty()); +}; diff --git a/prototypes/roam-inbox/src/index.ts b/prototypes/roam-inbox/src/index.ts new file mode 100644 index 0000000..3c79d89 --- /dev/null +++ b/prototypes/roam-inbox/src/index.ts @@ -0,0 +1,68 @@ +/* roam-inbox: in-graph messaging for Roam Research, with a signal. + * + * Two halves of one feature, deliberately shipped together: + * + * SEND "/message" registers a "Send message" command in Roam's native + * slash menu. Picking a person inserts `{{[[TODO]]}} [[+Name]] `, + * matching [[Convention/Inbox]] verbatim. (picker.ts) + * + * RECEIVE A topbar badge counts messages addressed to you that arrived + * since you last opened the panel. Clicking it opens an inbox + * panel (Messages / Mentions / Tasks) with open-in-sidebar, + * reply, and check-off on every row. (badge.ts, panel.ts) + * + * Read is separate from done: the badge tracks what is new since you last + * looked, the {{[[TODO]]}} checkbox keeps meaning "handled". (inbox.ts) + */ +import { render as renderToast } from "roamjs-components/components/Toast"; +import { runExtension } from "roamjs-components/util"; +import { logError } from "~/config"; +import { load, unload } from "~/lifecycle"; + +/* What this extension needs from Roam, checked before anything else so a + * missing capability reports itself by name instead of as a TypeError deep + * in a helper. */ +const missingCapability = (): string => { + const api = window.roamAlphaAPI as unknown as Record | undefined; + if (!api) return "window.roamAlphaAPI is not available"; + const data = api.data as { async?: { q?: unknown } } | undefined; + if (typeof data?.async?.q !== "function") + return "window.roamAlphaAPI.data.async.q is not available in this Roam build"; + return ""; +}; + +/* Report a load failure loudly, and never lose the cause. + * + * runExtension's own failure path cannot be relied on. In production it does + * not log the error: it posts the message to SamePage and shows a generic + * toast, and while doing so reads `args.extensionAPI.settings.getAll()`. + * `extensionAPI` is undefined whenever the module is loaded by `import()` + * from a roam/js block, so the reporter throws its own TypeError over ours + * and the original error is gone. So the console line comes first, here. */ +const reportLoadFailure = (error: unknown): void => { + const message = error instanceof Error ? error.message : String(error); + logError("failed to load", error); + try { + renderToast({ + id: "roam-inbox-load-failure", + content: `Roam Inbox failed to load: ${message}`, + intent: "danger", + timeout: 0, + }); + } catch (toastError) { + logError("the failure toast also failed", toastError); + } +}; + +export default runExtension(async () => { + try { + const missing = missingCapability(); + if (missing) throw new Error(missing); + await load(); + return { unload }; + } catch (error) { + reportLoadFailure(error); + unload(); + return {}; + } +}); diff --git a/prototypes/roam-inbox/src/lifecycle.ts b/prototypes/roam-inbox/src/lifecycle.ts new file mode 100644 index 0000000..bfa50b8 --- /dev/null +++ b/prototypes/roam-inbox/src/lifecycle.ts @@ -0,0 +1,121 @@ +/* Load, unload, and the `window.roamInbox` console surface. index.ts wraps + * this in runExtension; tests call it directly. */ +import { startBadge, stopBadge } from "~/badge"; +import { LOG, logError, VERSION } from "~/config"; +import { loadSeen, myAddress, refreshLists, resetInbox, setIdentity, state, unreadMessages } from "~/inbox"; +import { isWatching, startWatching, stopWatching } from "~/live"; +import { resetMembers, warmMembers } from "~/members"; +import { closePanel } from "~/panel"; +import { closePicker, registerMessageCommand } from "~/picker"; +import { currentUser, ensurePage } from "~/roam"; +import { injectStyle, removeStyle } from "~/styles"; +import { prettify } from "~/text"; +import { startToasts, stopToasts } from "~/toast"; +import { hideTip } from "~/tooltip"; + +declare global { + interface Window { + roamInbox?: { + version: string; + unload: () => void; + refresh: () => Promise; + debug: () => Record; + }; + } +} + +let disposeSlash: (() => void) | null = null; +// Bumped by unload so a load still waiting on the graph abandons its +// remaining steps instead of mounting into an unloaded page. +let session = 0; + +/* One object that says which build is running and why the badge looks the + * way it does, so "it's still grey" is a fact rather than a guess. Reads the + * current state; `await roamInbox.refresh()` first to re-query. */ +export const debug = (): Record => { + const unread = unreadMessages(); + const newest = state.messages[0]; + const me = state.me; + return { + version: VERSION, + me: me?.name ?? null, + watching: me?.address ?? null, + pullWatchActive: isWatching(), + counts: { + messages: state.messages.length, + mentions: state.mentions.length, + tasks: state.tasks.length, + }, + badge: unread.length ? `blue ${unread.length}` : state.messages.length ? "grey dot" : "hidden", + unreadCount: unread.length, + // How many uids you have acknowledged. `blockCreated` is the block's + // birthday, NOT when it was addressed to you; see inbox.ts. + seenCount: me ? loadSeen().size : 0, + newestMessage: newest + ? { + blockCreated: new Date(newest.time).toISOString(), + unread: !loadSeen().has(newest.uid), + from: newest.author, + text: prettify(newest.string, myAddress()).slice(0, 60), + } + : null, + hint: unread.length + ? "ok" + : state.messages.length + ? "grey dot is correct: every open message is already acknowledged" + : "inbox empty", + }; +}; + +export const unload = (): void => { + session += 1; + closePicker(); + closePanel(); + hideTip(); + try { + disposeSlash?.(); + } catch { + /* already gone */ + } + disposeSlash = null; + stopWatching(); + stopBadge(); + stopToasts(); + removeStyle(); + resetMembers(); + resetInbox(); + if (window.roamInbox?.unload === unload) delete window.roamInbox; +}; + +export const load = async (): Promise => { + const mine = ++session; + window.roamInbox = { version: VERSION, unload, refresh: refreshLists, debug }; + injectStyle(); + disposeSlash = registerMessageCommand(); + if (!disposeSlash) console.warn(`${LOG} this Roam build has no slash-command API; /message is disabled`); + warmMembers(); + + const identity = await currentUser().catch((e) => { + logError("identity lookup failed", e); + return null; + }); + if (mine !== session) return; + if (!identity) { + console.warn(`${LOG} no display page for the current user: /message works, inbox badge disabled`); + return; + } + const me = setIdentity(identity); + await Promise.all([ + ensurePage(me.address).catch((e) => logError(`could not create ${me.address}`, e)), + refreshLists(), + ]); + if (mine !== session) return; + + startToasts(); + startBadge(); + startWatching(); + console.log( + `${LOG} v${VERSION} ready for ${me.name}: ${state.messages.length} open, ` + + `${unreadMessages().length} new. roamInbox.debug() for details.`, + ); +}; diff --git a/prototypes/roam-inbox/src/live.ts b/prototypes/roam-inbox/src/live.ts new file mode 100644 index 0000000..cd5d1fb --- /dev/null +++ b/prototypes/roam-inbox/src/live.ts @@ -0,0 +1,58 @@ +/* Keeping the lists current: a pull watch on the `+Me` page, with a slow + * poll as insurance. Everything that shows the lists repaints through the + * inbox subscription. */ +import { logError, POLL_MS, WATCH_DEBOUNCE_MS } from "~/config"; +import { refreshLists, state } from "~/inbox"; +import { watchReferencesTo } from "~/roam"; + +let stopWatch: (() => void) | null = null; +let pollTimer: number | null = null; +let debounceTimer: number | null = null; + +const refresh = (): void => { + void refreshLists().catch((e) => logError("refresh failed", e)); +}; + +const scheduleRefresh = (): void => { + if (debounceTimer !== null) clearTimeout(debounceTimer); + debounceTimer = window.setTimeout(refresh, WATCH_DEBOUNCE_MS); +}; + +// Roam tabs live in the background all day. The poll skips while the tab is +// hidden (the pull watch still fires) and runs once as soon as it is shown, +// so the badge is right before you can read it. +const onPoll = (): void => { + if (document.visibilityState !== "hidden") refresh(); +}; +const onVisibility = (): void => { + if (document.visibilityState === "visible") refresh(); +}; + +export const isWatching = (): boolean => stopWatch !== null; + +export const startWatching = (): void => { + const me = state.me; + if (!me) return; + // Any new block referencing the `+Me` page changes its reverse-ref set, + // which is what the pattern watches. The poll is insurance, not the path. + try { + stopWatch = watchReferencesTo(me.address, scheduleRefresh); + } catch (e) { + logError("pull watch failed; polling only", e); + } + pollTimer = window.setInterval(onPoll, POLL_MS); + document.addEventListener("visibilitychange", onVisibility); +}; + +export const stopWatching = (): void => { + try { + stopWatch?.(); + } catch { + /* already gone */ + } + stopWatch = null; + if (pollTimer !== null) clearInterval(pollTimer); + if (debounceTimer !== null) clearTimeout(debounceTimer); + pollTimer = debounceTimer = null; + document.removeEventListener("visibilitychange", onVisibility); +}; diff --git a/prototypes/roam-inbox/src/members.ts b/prototypes/roam-inbox/src/members.ts new file mode 100644 index 0000000..e363bc7 --- /dev/null +++ b/prototypes/roam-inbox/src/members.ts @@ -0,0 +1,48 @@ +/* Who the picker offers. Cached, refreshed in the background. */ +import { logError, MEMBER_CACHE_MS } from "~/config"; +import { allMembers, recentlyActiveMembers } from "~/roam"; + +export type Members = { active: string[]; all: string[]; at: number }; + +let cache: Members | null = null; +let inFlight: Promise | null = null; + +const quietly = (what: string) => (e: unknown) => { + logError(what, e); + return [] as string[]; +}; + +const refresh = (): Promise => { + if (!inFlight) { + inFlight = Promise.all([ + allMembers().catch(quietly("member query failed")), + recentlyActiveMembers().catch(quietly("activity query failed")), + ]) + .then(([all, active]) => (cache = { all, active: active.length ? active : all, at: Date.now() })) + .finally(() => { + inFlight = null; + }); + } + return inFlight; +}; + +/* Fill the cache ahead of the first /message so the picker opens instantly. */ +export const warmMembers = (): void => { + void refresh(); +}; + +/* Serves the cache immediately and refreshes it behind the scenes when it is + * stale. A cold cache waits only for the cheap member list; the whole-graph + * activity scan fills in the "recently active" order when it lands. */ +export const getMembers = async (): Promise => { + if (!cache) { + const all = await allMembers().catch(quietly("member query failed")); + cache = { all, active: all, at: 0 }; + } + if (Date.now() - cache.at >= MEMBER_CACHE_MS) warmMembers(); + return cache; +}; + +export const resetMembers = (): void => { + cache = null; +}; diff --git a/prototypes/roam-inbox/src/panel.ts b/prototypes/roam-inbox/src/panel.ts new file mode 100644 index 0000000..d77a454 --- /dev/null +++ b/prototypes/roam-inbox/src/panel.ts @@ -0,0 +1,160 @@ +/* The inbox panel: Messages / Mentions / Tasks, with Open, Reply, and Done + * on every row. Opens under the badge and repaints itself on every inbox + * change while open. */ +import { markDone, open, replyTo } from "~/actions"; +import { BADGE_ID, logError } from "~/config"; +import { el, onPress } from "~/dom"; +import { loadSeen, markSeen, myAddress, refreshLists, state, subscribe, unreadMessages } from "~/inbox"; +import { prettify, relTime } from "~/text"; +import { attachTooltip } from "~/tooltip"; + +type Tab = "messages" | "mentions" | "tasks"; + +const TABS: [Tab, string][] = [ + ["messages", "Messages"], + ["mentions", "Mentions"], + ["tasks", "Tasks"], +]; + +const EMPTY: Record = { + messages: "No open messages. 🎉", + mentions: "No mentions.", + tasks: "No open tasks.", +}; + +const FOOT: Record = { + messages: "[[+you]] — addressed to you · /message to send", + mentions: "[[+you]] with no checkbox — FYI, never badges", + tasks: "#[[you]] — assigned work, from /task", +}; + +let tab: Tab = "messages"; +let panelEl: HTMLDivElement | null = null; +let panelCleanup: (() => void) | null = null; +// Unread snapshot taken as the panel opens, so the blue "new" marks survive +// the acknowledgement that opening the panel performs. +let panelNew = new Set(); + +export const closePanel = (): void => { + const fn = panelCleanup; + panelCleanup = null; + fn?.(); +}; + +const refresh = (): void => { + void refreshLists().catch((e) => logError("refresh failed", e)); +}; + +const renderPanel = (): void => { + if (!panelEl) return; + panelEl.innerHTML = ""; + const address = myAddress(); + + const tabs = el("div", "rmi-tabs"); + for (const [key, label] of TABS) { + const n = state[key].length; + const t = el("div", "rmi-tab" + (tab === key ? " rmi-tab-on" : ""), n ? `${label} ${n}` : label); + onPress(t, () => { + tab = key; + renderPanel(); + }); + tabs.appendChild(t); + } + panelEl.appendChild(tabs); + + const list = el("div", "rmi-list"); + const rows = state[tab]; + if (!rows.length) list.appendChild(el("div", "rmi-empty", EMPTY[tab])); + + const seen = loadSeen(); + for (const row of rows) { + // Matches the badge exactly (see unreadMessages). The second half catches + // anything that lands while the panel is already open. + const isNew = tab === "messages" && (panelNew.has(row.uid) || !seen.has(row.uid)); + const item = el("div", "rmi-row" + (isNew ? " rmi-new" : "")); + + const meta = el("div", "rmi-meta"); + meta.appendChild(el("span", "rmi-who", row.author || "Unknown")); + meta.appendChild(document.createTextNode(` · ${relTime(row.time)}`)); + item.appendChild(meta); + + const text = el("div", "rmi-text", prettify(row.string, address) || "(no text)"); + onPress(text, () => open(row.uid)); + item.appendChild(text); + + // The page title needs flattening too: in a discourse graph most pages + // are `[[ISS]] - …` / `[[QUE]] - …`, so raw titles show their brackets. + // A top-level block's parent is its page; do not show the title twice. + const page = prettify(row.page, address); + const parent = row.parent === row.page ? "" : prettify(row.parent, address); + const ctx = el("div", "rmi-ctx", [page, parent].filter(Boolean).join(" › ")); + item.appendChild(ctx); + attachTooltip(ctx, () => ctx.textContent || ""); + + const acts = el("div", "rmi-acts"); + const act = (label: string, fn: () => void) => { + const b = el("span", "rmi-act", label); + onPress(b, fn); + acts.appendChild(b); + }; + act("Open", () => open(row.uid)); + act("Reply", () => { + closePanel(); + void replyTo(row).catch((e) => logError("reply failed", e)); + }); + if (tab !== "mentions") { + act("✓ Done", () => { + void markDone(row.uid) + .then(refreshLists) + .catch((e) => logError("check-off failed", e)); + }); + } + item.appendChild(acts); + list.appendChild(item); + } + panelEl.appendChild(list); + panelEl.appendChild(el("div", "rmi-foot", FOOT[tab])); +}; + +const openPanel = (): void => { + // Snapshot before acknowledging, so what was new stays visibly new while + // the panel is open. + panelNew = new Set(unreadMessages().map((m) => m.uid)); + markSeen(state.messages); + + panelEl = el("div", "rmi-panel"); + document.body.appendChild(panelEl); + const btn = document.getElementById(BADGE_ID); + const r = btn ? btn.getBoundingClientRect() : { bottom: 44, right: window.innerWidth - 14 }; + panelEl.style.top = `${r.bottom + 6}px`; + panelEl.style.right = `${Math.max(8, window.innerWidth - r.right - 4)}px`; + renderPanel(); + + const onDown = (e: MouseEvent) => { + const target = e.target as Node; + if (panelEl && !panelEl.contains(target) && !btn?.contains(target)) closePanel(); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") closePanel(); + }; + document.addEventListener("mousedown", onDown, true); + document.addEventListener("keydown", onKey, true); + const unsubscribe = subscribe(renderPanel); + panelCleanup = () => { + unsubscribe(); + document.removeEventListener("mousedown", onDown, true); + document.removeEventListener("keydown", onKey, true); + panelEl?.remove(); + panelEl = null; + panelNew = new Set(); + }; + + // Opened from the current lists, which the watch and poll keep current; + // anything that lands during this refresh repaints through the subscription. + refresh(); +}; + +export const togglePanel = (): void => { + if (panelEl) closePanel(); + else openPanel(); +}; diff --git a/prototypes/roam-inbox/src/picker.ts b/prototypes/roam-inbox/src/picker.ts new file mode 100644 index 0000000..54e7c71 --- /dev/null +++ b/prototypes/roam-inbox/src/picker.ts @@ -0,0 +1,228 @@ +/* The /message half: a person picker under the caret that turns the block + * into `{{[[TODO]]}} [[+Name]] `. The picker is forked from roam-task-assign + * (the /task command), as roam-feedback's was; see README.md. */ +import { addressFor, insertFor, LABEL, logError, MAX_FILTER_LEN } from "~/config"; +import { el, onPress } from "~/dom"; +import { caretCoords, findTextarea, typeInto } from "~/editor"; +import { getMembers } from "~/members"; +import { + blockString, + ensurePage, + focusBlock, + registerSlashCommand, + type SlashContext, + updateBlock, +} from "~/roam"; + +let activePicker: (() => void) | null = null; + +export const closePicker = (): void => { + const fn = activePicker; + activePicker = null; + fn?.(); +}; + +const openPicker = async ({ uid, windowId }: { uid: string; windowId?: string }): Promise => { + closePicker(); + const textarea = findTextarea(uid); + if (!textarea) return; + const triggerPosition = textarea.selectionStart; + + // Usually instant: the cache is warmed at load. + const members = await getMembers(); + const live = findTextarea(uid); + if (!live || document.activeElement !== live) return; // the user moved on + const allLower = members.all.map((m) => m.toLowerCase()); + + let filterText = ""; + let activeIndex = 0; + + const menu = el("div", "rmi-menu"); + const coords = caretCoords(live); + menu.style.top = `${coords.top}px`; + menu.style.left = `${coords.left}px`; + document.body.appendChild(menu); + requestAnimationFrame(() => { + const r = menu.getBoundingClientRect(); + if (r.bottom > window.innerHeight) menu.style.top = `${Math.max(4, window.innerHeight - r.height - 4)}px`; + if (r.right > window.innerWidth) menu.style.left = `${Math.max(4, window.innerWidth - r.width - 4)}px`; + }); + + const filtered = (): string[] => { + if (!filterText) return members.active; + const needle = filterText.toLowerCase(); + return members.all.filter((_, i) => allLower[i].includes(needle)); + }; + + // Moving the highlight touches two class lists, not the whole menu. + const highlight = (): void => { + menu.querySelectorAll(".rmi-item").forEach((item, i) => { + item.classList.toggle("rmi-active", i === activeIndex); + }); + }; + + const renderList = (): void => { + const items = filtered(); + if (activeIndex >= items.length) activeIndex = 0; + menu.innerHTML = ""; + if (members.all.length === 0) menu.appendChild(el("div", "rmi-empty", "No graph members found")); + else if (items.length === 0) menu.appendChild(el("div", "rmi-empty", "No matches")); + else { + items.forEach((name, i) => { + const item = el("div", "rmi-item" + (i === activeIndex ? " rmi-active" : ""), name); + onPress(item, () => select(name)); // mousedown keeps the textarea focused + item.addEventListener("mouseenter", () => { + activeIndex = i; + highlight(); + }); + menu.appendChild(item); + }); + } + menu.appendChild( + el( + "div", + "rmi-hint", + filterText + ? "message · searching all members · ↑↓ · Enter · Esc" + : "message · recently active · type to search all · ↑↓ · Enter · Esc", + ), + ); + }; + + const insertRecipient = async (name: string, liveEnd: number): Promise => { + // A recipient with no `+Name` page would otherwise be messageable only by + // accident. Create it up front so the link and their pull watch resolve. + const address = addressFor(name); + await ensurePage(address).catch((e) => logError(`could not create ${address}`, e)); + + const ta = findTextarea(uid); + // The textarea is the live truth while editing; the store is not. + const base = ta ? ta.value : await blockString(uid); + const start = Math.min(triggerPosition, base.length); + const end = Math.min(Math.max(liveEnd, start), base.length); + let insert = insertFor(name); + if (start > 0 && !/\s$/.test(base.slice(0, start))) insert = " " + insert; + const newText = base.slice(0, start) + insert + base.slice(end); + const cursor = start + insert.length; + + if (ta) { + typeInto(ta, newText, cursor); + return; + } + // The editor already closed, so nothing is left to clobber a store write. + await updateBlock(uid, newText); + await focusBlock(uid, windowId, cursor).catch(() => { + /* the text landed; focus is a nicety */ + }); + }; + + const select = (name: string): void => { + const at = findTextarea(uid); + const liveEnd = at ? at.selectionStart : triggerPosition + filterText.length; + cleanup(); + void insertRecipient(name, liveEnd).catch((e) => logError("insert failed", e)); + }; + + // Attached to DOCUMENT in the capture phase: Roam replaces the block's + // textarea element when it strips the typed slash text, so listeners bound + // to one textarea instance go stale and the menu turns inert. Resolve the + // live textarea by uid on every event instead. + const onKeydown = (e: KeyboardEvent): void => { + const ta = findTextarea(uid); + if (!ta || document.activeElement !== ta) { + cleanup(); + return; + } + if (e.key === "Tab") { + cleanup(); + return; + } + if (!["ArrowDown", "ArrowUp", "Enter", "Escape"].includes(e.key)) { + return; // let the keystroke through; it feeds the filter via input + } + const items = filtered(); + if (e.key === "ArrowDown" && items.length) { + activeIndex = (activeIndex + 1) % items.length; + highlight(); + } else if (e.key === "ArrowUp" && items.length) { + activeIndex = (activeIndex - 1 + items.length) % items.length; + highlight(); + } else if (e.key === "Enter") { + if (items.length) select(items[activeIndex]); + else cleanup(); + } else if (e.key === "Escape") { + cleanup(); + } + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + }; + + const onInput = (): void => { + const ta = findTextarea(uid); + if (!ta) { + cleanup(); + return; + } + const sel = ta.selectionStart; + if (sel < triggerPosition) { + cleanup(); + return; + } + filterText = ta.value.substring(triggerPosition, sel); + if (filterText.includes("\n") || filterText.length > MAX_FILTER_LEN) { + cleanup(); + return; + } + renderList(); + }; + + const onClickAway = (e: MouseEvent): void => { + const target = e.target as Node; + if (!menu.contains(target) && target !== findTextarea(uid)) cleanup(); + }; + + let done = false; + const cleanup = (): void => { + if (done) return; + done = true; + if (activePicker === cleanup) activePicker = null; + document.removeEventListener("keydown", onKeydown, true); + document.removeEventListener("input", onInput, true); + document.removeEventListener("mousedown", onClickAway, true); + menu.remove(); + }; + activePicker = cleanup; + + document.addEventListener("keydown", onKeydown, true); + document.addEventListener("input", onInput, true); + document.addEventListener("mousedown", onClickAway, true); + renderList(); +}; + +/* Returning "" makes Roam remove the typed "/message..." text; the block + * re-renders (often swapping in a NEW textarea element), so wait for the + * strip to land before reading the caret. Detect it by watching the + * textarea's own value change, NOT by waiting for the store to agree with + * it: Roam defers the store write while the editor is open and that wait + * can never finish. */ +const slashCallback = (ctx: SlashContext): string => { + const uid = ctx["block-uid"]; + if (!uid) return ""; + const windowId = ctx["window-id"]; + const before = findTextarea(uid)?.value; + const tryOpen = (attempt: number): void => { + const ta = findTextarea(uid); + if (ta && document.activeElement === ta && ta.value !== before) { + void openPicker({ uid, windowId }).catch((e) => logError("picker failed", e)); + } else if (attempt < 40) { + setTimeout(() => tryOpen(attempt + 1), 50); + } + }; + setTimeout(() => tryOpen(0), 50); + return ""; +}; + +/* Registers /message. Returns the disposer, or null when Roam has no + * slash-command API. */ +export const registerMessageCommand = (): (() => void) | null => registerSlashCommand(LABEL, slashCallback); diff --git a/prototypes/roam-inbox/src/roam.ts b/prototypes/roam-inbox/src/roam.ts new file mode 100644 index 0000000..10609d6 --- /dev/null +++ b/prototypes/roam-inbox/src/roam.ts @@ -0,0 +1,226 @@ +/* Every read and write of the graph, through the namespaced async API. + * + * Datalog inputs are parameterized through `:in`, never interpolated. Pull + * results are read through `pick`, which tries the namespaced key first: on + * some API surfaces the short key does not come back undefined but resolves + * to a ClojureScript internal, and short-first silently yields garbage that + * looks like an empty graph. + */ +import { ACTIVE_WITHIN_DAYS, EXCLUDE_PATTERNS, logError } from "~/config"; + +const api = () => window.roamAlphaAPI; + +const q = (query: string, ...params: unknown[]): Promise => + api().data.async.q(query, ...params); + +/* Read one attribute off a pulled entity, namespaced key first. */ +export const pick = (obj: unknown, attr: string): T | undefined => { + if (!obj || typeof obj !== "object") return undefined; + const rec = obj as Record; + const bare = attr.slice(attr.indexOf("/") + 1); + return rec[`:${attr}`] ?? rec[bare] ?? undefined; +}; + +// ------------------------------------------------------------------ identity + +export type Identity = { uid: string; name: string }; + +/* The signed-in user, by display-page title. Null when there is no display + * page: the badge cannot work without one, though /message still can. */ +export const currentUser = async (): Promise => { + const uid = api().user?.uid?.(); + if (!uid) return null; + const rows = await q( + "[:find ?t :in $ ?uid :where [?u :user/uid ?uid] [?u :user/display-page ?p] [?p :node/title ?t]]", + uid, + ); + const name = rows?.[0]?.[0]; + return typeof name === "string" && name ? { uid, name } : null; +}; + +export const graphName = (): string => api().graph?.name || "graph"; + +// ------------------------------------------------------------------ messages + +export type Message = { + uid: string; + string: string; + time: number; // :create/time, the block's birthday, NOT when it was addressed + todo: boolean; // carries an open checkbox + authorUid: string | null; + author: string | null; + page: string; + parent: string; // the direct parent's text, or the page title when top-level +}; + +/* `:block/_children` is the direct parent only; `:block/parents` would pull + * every ancestor's text and the panel shows one. */ +const PULL = `[:block/uid :block/string :create/time + {:block/refs [:node/title]} + {:create/user [:user/uid {:user/display-page [:node/title]}]} + {:block/page [:node/title]} + {:block/_children [:block/string :node/title]}]`; + +const OPEN_REFS = `[?p :node/title ?title] [?b :block/refs ?p] + (not-join [?b] [?done :node/title "DONE"] [?b :block/refs ?done])`; + +/* Blocks that reference the page titled ?title and are not checked off. For + * `+Name` these are Name's messages (with a TODO) and mentions (without), + * told apart in JS from the pulled refs. Each row is [pulled block]. */ +export const Q_OPEN_REFS = `[:find (pull ?b ${PULL}) :in $ ?title :where ${OPEN_REFS}]`; + +/* The same, restricted to open TODOs. For `Name` this is the /task channel + * (`#[[Name]]`); a person's display page is referenced far more often than + * their `+Name` page, so the checkbox filter stays in the query. */ +export const Q_OPEN_TODOS = `[:find (pull ?b ${PULL}) :in $ ?title :where ${OPEN_REFS} + [?todo :node/title "TODO"] [?b :block/refs ?todo]]`; + +export const shape = (row: unknown[]): Message => { + const b = row[0]; + const user = pick(b, "create/user"); + const displayPage = pick(user, "user/display-page"); + const refs = pick(b, "block/refs") || []; + const parent = (pick(b, "block/_children") || [])[0]; + return { + uid: pick(b, "block/uid") || "", + string: pick(b, "block/string") || "", + time: pick(b, "create/time") || 0, + todo: refs.some((r) => pick(r, "node/title") === "TODO"), + authorUid: pick(user, "user/uid") || null, + author: pick(displayPage, "node/title") || null, + page: pick(pick(b, "block/page"), "node/title") || "", + parent: pick(parent, "block/string") || pick(parent, "node/title") || "", + }; +}; + +/* Newest first. A failed query is an empty list, not a crash: the badge + * would rather show nothing than take the topbar down with it. */ +export const fetchList = async (query: string, title: string): Promise => { + try { + return ((await q(query, title)) || []) + .map(shape) + .filter((m) => m.uid) + .sort((a, b) => b.time - a.time); + } catch (e) { + logError("query failed", e); + return []; + } +}; + +// ------------------------------------------------------------------- members + +const isExcluded = (name: string): boolean => EXCLUDE_PATTERNS.some((re) => re.test(name)); + +/* Every display-page title in the graph, alphabetical. */ +export const allMembers = async (): Promise => { + const rows = await q("[:find (pull ?n [:node/title]) :where [?u :user/display-page ?n]]"); + return (rows || []) + .map((row) => pick(row[0], "node/title") || "") + .filter((name) => name && !isExcluded(name)) + .sort((a, b) => a.localeCompare(b)); +}; + +// Tuple order: [display-page title, that user's most recent edit time]. +const ACTIVITY_QUERY = + "[:find ?name (max ?time) :where " + + "[?u :user/display-page ?p] [?p :node/title ?name] " + + "[?b :edit/user ?u] [?b :edit/time ?time]]"; + +/* Members who edited something within ACTIVE_WITHIN_DAYS, most recent first. + * This scans every block's edit time, so it runs on the backend (off the + * main thread) where that surface exists. */ +export const recentlyActiveMembers = async (): Promise => { + const backend = api().data.backend; + const rows = backend?.q ? await backend.q(ACTIVITY_QUERY) : await q(ACTIVITY_QUERY); + const since = Date.now() - ACTIVE_WITHIN_DAYS * 24 * 60 * 60 * 1000; + return (rows || []) + .map(([name, last]) => ({ name: typeof name === "string" ? name : "", last: Number(last) || 0 })) + .filter((r) => r.name && !isExcluded(r.name) && r.last >= since) + .sort((a, b) => b.last - a.last) + .map((r) => r.name); +}; + +// --------------------------------------------------------- pages and blocks + +const pageUid = async (title: string): Promise => + pick( + (await q("[:find (pull ?p [:block/uid]) :in $ ?t :where [?p :node/title ?t]]", title))?.[0]?.[0], + "block/uid", + ) || ""; + +/* A first-time recipient has no `+Name` page, which makes them silently + * unmessageable and leaves the pull watch with no entity to bind to. */ +export const ensurePage = async (title: string): Promise => { + if (await pageUid(title)) return; + await api().data.page.create({ page: { title } }); +}; + +export const blockString = async (uid: string): Promise => + pick(await api().data.async.pull("[:block/string]", [":block/uid", uid]), "block/string") || + ""; + +export const updateBlock = (uid: string, string: string): Promise => + api().data.block.update({ block: { uid, string } }); + +/* Appends a child and returns its uid, which the caller needs to focus it. */ +export const createChildBlock = async (parentUid: string, string: string): Promise => { + const uid = api().util.generateUID(); + await api().data.block.create({ + location: { "parent-uid": parentUid, order: "last" }, + block: { uid, string }, + }); + return uid; +}; + +export const openInSidebar = async (uid: string): Promise => { + const sidebar = api().ui.rightSidebar; + await sidebar.addWindow({ window: { type: "block", "block-uid": uid } }); + if (sidebar.open) await sidebar.open(); +}; + +export const focusBlock = (uid: string, windowId?: string, start?: number): Promise => + api().ui.setBlockFocusAndSelection({ + // Roam accepts a missing window id; the typing does not. + location: { "block-uid": uid, "window-id": windowId } as { "block-uid": string; "window-id": string }, + ...(start === undefined ? {} : { selection: { start } }), + }); + +// ---------------------------------------------------------------- pull watch + +/* The entity for addPullWatch is an EDN lookup ref built from text. There is + * no `:in` for pull watches, so the title is quoted as a JSON string, which + * is also a valid EDN string for every title Roam allows. */ +export const pullWatchEntity = (title: string): string => `[:node/title ${JSON.stringify(title)}]`; + +/* Fires when any block starts or stops referencing the page. Returns the + * disposer. */ +export const watchReferencesTo = (title: string, callback: () => void): (() => void) => { + const pattern = "[{:block/_refs [:block/uid]}]"; + const entity = pullWatchEntity(title); + api().data.addPullWatch(pattern, entity, callback); + return () => { + api().data.removePullWatch(pattern, entity, callback); + }; +}; + +// ------------------------------------------------------------- slash command + +/* `ui.slashCommand` is real but undocumented, so it is absent from the + * roamjs-components typings. The callback's return value replaces the typed + * "/message..." text; returning "" deletes it. */ +export type SlashContext = { "block-uid"?: string; "window-id"?: string }; +type SlashCommandApi = { + addCommand: (command: { label: string; callback: (ctx: SlashContext) => string }) => void; + removeCommand: (command: { label: string }) => void; +}; + +/* Returns the disposer, or null when this Roam build has no slash-command API. */ +export const registerSlashCommand = ( + label: string, + callback: (ctx: SlashContext) => string, +): (() => void) | null => { + const slash = (api().ui as unknown as { slashCommand?: SlashCommandApi }).slashCommand; + if (!slash?.addCommand) return null; + slash.addCommand({ label, callback }); + return () => slash.removeCommand({ label }); +}; diff --git a/prototypes/roam-inbox/src/styles.ts b/prototypes/roam-inbox/src/styles.ts new file mode 100644 index 0000000..2d8b9ad --- /dev/null +++ b/prototypes/roam-inbox/src/styles.ts @@ -0,0 +1,180 @@ +/* Carried in the bundle rather than as a published extension.css: Roam only + * injects that file on the URL-loading path, not when a roam/js block + * `import()`s the bundle for testing. Every class is prefixed `rmi-` so + * nothing here can restyle the graph. */ +import { STYLE_ID } from "~/config"; + +export const CSS = ` + /* ---- picker (shared shape with roam-task-assign) ---- */ + .rmi-menu { + position: fixed; z-index: 10000; + min-width: 220px; max-width: 320px; max-height: 280px; overflow-y: auto; + background: #fff; border-radius: 4px; padding: 4px 0; font-size: 14px; + box-shadow: 0 0 0 1px rgba(16,22,26,.1), 0 2px 4px rgba(16,22,26,.2), + 0 8px 24px rgba(16,22,26,.2); + } + .rmi-item { + padding: 5px 12px; cursor: pointer; white-space: nowrap; + overflow: hidden; text-overflow: ellipsis; color: #182026; + } + .rmi-item.rmi-active { background: #137cbd; color: #fff; } + .rmi-empty { padding: 8px 12px; color: #5c7080; font-style: italic; } + .rmi-hint { + padding: 4px 12px; color: #5c7080; font-size: 11px; + border-top: 1px solid rgba(16,22,26,.1); margin-top: 4px; + } + + /* ---- topbar badge ---- */ + .rmi-btn { margin-left: 2px; } + /* The badge is anchored to the 16px GLYPH, not to the button box. The + button's width is not stable (24px empty, 37px with a bubble, and a + graph's own roam/css can pad it further), so anchoring to the button + floats the badge out toward the next icon. .rmi-glyph is a fixed 16px + square we control, so the corner is the corner at any button size. */ + .rmi-glyph { + position: relative; display: inline-flex; + width: 16px; height: 16px; line-height: 16px; + } + .rmi-count { + position: absolute; top: -5px; right: -6px; + min-width: 15px; height: 15px; padding: 0 3px; + border-radius: 8px; background: #137cbd; color: #fff; + font-size: 10px; font-weight: 700; line-height: 15px; + text-align: center; box-shadow: 0 0 0 2px #fff; pointer-events: none; + } + /* Nothing new, but the inbox is not empty: say "there is something here" + without a permanent number, which would stop reading as a signal. */ + .rmi-count.rmi-dot { + min-width: 0; width: 7px; height: 7px; padding: 0; + top: -2px; right: -3px; background: #a7b6c2; + } + + /* ---- tooltip ---- */ + .rmi-tip { position: fixed; z-index: 10002; pointer-events: none; } + .rmi-tip-body { + background: #fff; color: #182026; + font-size: 12px; line-height: 1.4; font-weight: 400; + padding: 6px 10px; border-radius: 4px; white-space: nowrap; + box-shadow: 0 0 0 1px rgba(16,22,26,.1), 0 2px 4px rgba(16,22,26,.2), + 0 8px 24px rgba(16,22,26,.2); + } + .rmi-tip-caret { + position: absolute; top: -4px; width: 9px; height: 9px; + background: #fff; transform: rotate(45deg); + box-shadow: -1px -1px 0 0 rgba(16,22,26,.1); + } + + /* ---- panel ---- */ + .rmi-panel { + position: fixed; z-index: 10000; + width: 500px; max-width: calc(100vw - 24px); max-height: 72vh; + display: flex; flex-direction: column; + background: #fff; border-radius: 6px; overflow: hidden; + font-size: 13px; color: #182026; + box-shadow: 0 0 0 1px rgba(16,22,26,.1), 0 2px 4px rgba(16,22,26,.2), + 0 8px 24px rgba(16,22,26,.2); + } + .rmi-tabs { + display: flex; gap: 4px; padding: 8px 10px 0; flex: 0 0 auto; + border-bottom: 1px solid rgba(16,22,26,.1); + } + .rmi-tab { + padding: 6px 11px 7px; cursor: pointer; border-radius: 3px 3px 0 0; + color: #5c7080; font-weight: 600; white-space: nowrap; + } + .rmi-tab:hover { background: rgba(167,182,194,.2); } + .rmi-tab.rmi-tab-on { color: #137cbd; box-shadow: inset 0 -2px 0 #137cbd; } + .rmi-list { overflow-y: auto; flex: 1 1 auto; padding: 2px 0 6px; } + .rmi-row { + padding: 12px 14px 13px 15px; border-left: 3px solid transparent; + } + .rmi-row + .rmi-row { border-top: 1px solid rgba(16,22,26,.08); } + .rmi-row:hover { background: rgba(167,182,194,.15); } + .rmi-row.rmi-new { border-left-color: #137cbd; background: rgba(19,124,189,.05); } + .rmi-meta { font-size: 11px; color: #5c7080; margin-bottom: 4px; } + .rmi-who { font-weight: 700; color: #394b59; } + .rmi-text { cursor: pointer; line-height: 1.45; } + .rmi-text:hover { text-decoration: underline; } + .rmi-ctx { + font-size: 11px; color: #8a9ba8; margin-top: 6px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .rmi-acts { margin-top: 9px; display: flex; gap: 6px; } + .rmi-act { + font-size: 11px; padding: 3px 9px; border-radius: 3px; cursor: pointer; + color: #5c7080; background: rgba(167,182,194,.25); font-weight: 600; + } + .rmi-act:hover { background: rgba(167,182,194,.5); color: #182026; } + .rmi-foot { + padding: 7px 14px; font-size: 11px; color: #5c7080; flex: 0 0 auto; + border-top: 1px solid rgba(16,22,26,.1); + } + + /* ---- toast ---- */ + .rmi-toasts { + position: fixed; z-index: 10001; top: 52px; right: 14px; + display: flex; flex-direction: column; gap: 8px; pointer-events: none; + } + .rmi-toast { + pointer-events: auto; cursor: pointer; + width: 320px; padding: 9px 12px; border-radius: 5px; + background: #202b33; color: #f5f8fa; font-size: 12.5px; line-height: 1.35; + box-shadow: 0 2px 4px rgba(16,22,26,.2), 0 8px 24px rgba(16,22,26,.2); + } + .rmi-toast-who { font-weight: 700; color: #48aff0; margin-bottom: 2px; } + .rmi-toast-body { + display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; + overflow: hidden; + } + + /* ---- dark theme ---- */ + .bp3-dark .rmi-menu, .rm-dark-theme .rmi-menu, + .bp3-dark .rmi-panel, .rm-dark-theme .rmi-panel { + background: #30404d; color: #f5f8fa; + box-shadow: 0 0 0 1px rgba(16,22,26,.2), 0 2px 4px rgba(16,22,26,.4), + 0 8px 24px rgba(16,22,26,.4); + } + .bp3-dark .rmi-item, .rm-dark-theme .rmi-item { color: #f5f8fa; } + .bp3-dark .rmi-item.rmi-active, .rm-dark-theme .rmi-item.rmi-active { + background: #137cbd; color: #fff; + } + .bp3-dark .rmi-who, .rm-dark-theme .rmi-who { color: #ced9e0; } + .bp3-dark .rmi-meta, .rm-dark-theme .rmi-meta, + .bp3-dark .rmi-tab, .rm-dark-theme .rmi-tab, + .bp3-dark .rmi-foot, .rm-dark-theme .rmi-foot, + .bp3-dark .rmi-act, .rm-dark-theme .rmi-act { color: #a7b6c2; } + .bp3-dark .rmi-tab.rmi-tab-on, .rm-dark-theme .rmi-tab.rmi-tab-on { + color: #48aff0; box-shadow: inset 0 -2px 0 #48aff0; + } + .bp3-dark .rmi-act:hover, .rm-dark-theme .rmi-act:hover { color: #f5f8fa; } + .bp3-dark .rmi-count, .rm-dark-theme .rmi-count { box-shadow: 0 0 0 2px #202b33; } + .bp3-dark .rmi-row + .rmi-row, .rm-dark-theme .rmi-row + .rmi-row { + border-top-color: rgba(255,255,255,.09); + } + .bp3-dark .rmi-tip-body, .rm-dark-theme .rmi-tip-body { + background: #394b59; color: #f5f8fa; + box-shadow: 0 0 0 1px rgba(16,22,26,.2), 0 2px 4px rgba(16,22,26,.4), + 0 8px 24px rgba(16,22,26,.4); + } + .bp3-dark .rmi-tip-caret, .rm-dark-theme .rmi-tip-caret { + background: #394b59; box-shadow: -1px -1px 0 0 rgba(16,22,26,.2); + } +`; + +/* Not roamjs-components' addStyle. Its subpath form is a default export, + * which this repository's ESM build breaks (see tests/interop.spec.ts), and + * its barrel form, `import { addStyle } from "roamjs-components/dom"`, + * works but drags the whole dom barrel into the bundle: parseRoamBlocksToHtml + * pulls in marked, prismjs, and refractor, measured at 900 KB against the + * 58 KB this extension ships. Four lines is cheaper. */ +export const injectStyle = (): void => { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = CSS; + document.head.appendChild(style); +}; + +export const removeStyle = (): void => { + document.getElementById(STYLE_ID)?.remove(); +}; diff --git a/prototypes/roam-inbox/src/text.ts b/prototypes/roam-inbox/src/text.ts new file mode 100644 index 0000000..3dee4c7 --- /dev/null +++ b/prototypes/roam-inbox/src/text.ts @@ -0,0 +1,39 @@ +/* Pure text helpers: no DOM, no graph. */ + +const TODO_RE = /\{{2,3}\[?\[?TODO\]?\]?\}{2,3}/; +const CHECKBOX_RE = /\{{2,3}\[?\[?(TODO|DONE)\]?\]?\}{2,3}/g; + +export const relTime = (ms: number): string => { + const s = Math.max(0, (Date.now() - ms) / 1000); + if (s < 60) return "just now"; + const m = s / 60; + if (m < 60) return `${Math.floor(m)}m ago`; + const h = m / 60; + if (h < 24) return `${Math.floor(h)}h ago`; + const d = h / 24; + if (d < 7) return `${Math.floor(d)}d ago`; + if (d < 365) return `${Math.floor(d / 7)}w ago`; + return `${Math.floor(d / 365)}y ago`; +}; + +/* Roam markup is noise in a one-line inbox row. Strip it down to prose. */ +export const prettify = (raw: string, myAddress: string | null): string => { + let s = raw || ""; + s = s.replace(CHECKBOX_RE, ""); + // The hashtag form first, or its `#` is left behind. + if (myAddress) s = s.split(`#[[${myAddress}]]`).join("").split(`[[${myAddress}]]`).join(""); + // Collapse block refs FIRST: a Roam alias whose target is a block ref + // (`[label](((uid)))`) is three levels of parens, which no sane link regex + // survives. Once it is `[label](⟨ref⟩)` the link strip is trivial. + s = s.replace(/\(\([^()]{9,}\)\)/g, "⟨ref⟩"); + s = s.replace(/\[([^[\]]+)\]\((?:[^()]|\([^()]*\))*\)/g, "$1"); // markdown links + // Nested page refs (`[[[[ART]] - x]]`) need repeated inside-out passes. + for (let i = 0; i < 4; i++) s = s.replace(/#?\[\[([^[\]]*)\]\]/g, "$1"); + s = s.replace(/\*\*([^*]+)\*\*/g, "$1").replace(/__([^_]+)__/g, "$1"); + s = s.replace(/!\[\]\(\S+\)/g, "🖼"); + return s.replace(/\s+/g, " ").trim(); +}; + +/* The checked-off form of a TODO block, or null when there is no TODO in it. */ +export const toggleDone = (raw: string): string | null => + TODO_RE.test(raw) ? raw.replace(TODO_RE, "{{[[DONE]]}}") : null; diff --git a/prototypes/roam-inbox/src/toast.ts b/prototypes/roam-inbox/src/toast.ts new file mode 100644 index 0000000..434f797 --- /dev/null +++ b/prototypes/roam-inbox/src/toast.ts @@ -0,0 +1,59 @@ +/* "X messaged you" toasts for messages that arrive while you are here. */ +import { TOAST_MAX, TOAST_MS } from "~/config"; +import { el } from "~/dom"; +import { myAddress, state, subscribe, unreadMessages } from "~/inbox"; +import { togglePanel } from "~/panel"; +import type { Message } from "~/roam"; +import { prettify } from "~/text"; + +let host: HTMLDivElement | null = null; +let unsubscribe: (() => void) | null = null; + +/* Uids that have already had their toast, so an edit to a message does not + * announce it twice. */ +const toasted = new Set(); + +const showToast = (row: Message): void => { + if (!host || !document.body.contains(host)) { + host = el("div", "rmi-toasts"); + document.body.appendChild(host); + } + const toast = el("div", "rmi-toast"); + toast.appendChild(el("div", "rmi-toast-who", `${row.author || "Someone"} messaged you`)); + toast.appendChild( + el("div", "rmi-toast-body", prettify(row.string, myAddress()) || "(no text)"), + ); + toast.addEventListener("click", () => { + toast.remove(); + togglePanel(); + }); + host.appendChild(toast); + setTimeout(() => toast.remove(), TOAST_MS); +}; + +const announce = (): void => { + unreadMessages() + .filter((m) => !toasted.has(m.uid)) + // No toast for a note you just typed to yourself: you are looking straight + // at it. It still counts toward the badge. + .filter((m) => m.authorUid !== state.me?.uid) + .slice(0, TOAST_MAX) + .forEach((m) => { + toasted.add(m.uid); + showToast(m); + }); +}; + +/* Everything already in the inbox at load is "not new to this session". */ +export const startToasts = (): void => { + unreadMessages().forEach((m) => toasted.add(m.uid)); + unsubscribe = subscribe(announce); +}; + +export const stopToasts = (): void => { + unsubscribe?.(); + unsubscribe = null; + host?.remove(); + host = null; + toasted.clear(); +}; diff --git a/prototypes/roam-inbox/src/tooltip.ts b/prototypes/roam-inbox/src/tooltip.ts new file mode 100644 index 0000000..331ece7 --- /dev/null +++ b/prototypes/roam-inbox/src/tooltip.ts @@ -0,0 +1,53 @@ +/* A hand-rolled tooltip. The native `title` attribute waits about a second + * and cannot be styled; Blueprint's tooltip is a React component with no + * imperative entry point. Roam's own topbar tooltips appear immediately, so + * this matches that: a small card with a caret, after a short delay, themed + * for light and dark. */ +import { TIP_DELAY_MS } from "~/config"; + +let tipEl: HTMLDivElement | null = null; +let tipTimer: number | null = null; + +export const hideTip = (): void => { + if (tipTimer !== null) { + clearTimeout(tipTimer); + tipTimer = null; + } + tipEl?.remove(); + tipEl = null; +}; + +const showTip = (anchor: HTMLElement, text: string): void => { + hideTip(); + if (!text || !document.body.contains(anchor)) return; + tipEl = document.createElement("div"); + tipEl.className = "rmi-tip"; + const caret = document.createElement("div"); + caret.className = "rmi-tip-caret"; + const body = document.createElement("div"); + body.className = "rmi-tip-body"; + body.textContent = text; + tipEl.append(caret, body); + document.body.appendChild(tipEl); + + const a = anchor.getBoundingClientRect(); + // Measure the CARD, not the wrapper: the absolutely-positioned caret + // inflates the wrapper's bounding box and skews the centring by ~7px. + const w = body.getBoundingClientRect().width; + const anchorCenter = a.left + a.width / 2; + const left = Math.max(6, Math.min(anchorCenter - w / 2, window.innerWidth - w - 6)); + tipEl.style.top = `${a.bottom + 9}px`; + tipEl.style.left = `${left}px`; + // Point the caret at the anchor even when the card was clamped on-screen, + // but keep it inside the card's rounded corners. + caret.style.left = `${Math.max(7, Math.min(anchorCenter - left - 4.5, w - 16))}px`; +}; + +export const attachTooltip = (el: HTMLElement, getText: () => string): void => { + el.addEventListener("mouseenter", () => { + if (tipTimer !== null) clearTimeout(tipTimer); + tipTimer = window.setTimeout(() => showTip(el, getText()), TIP_DELAY_MS); + }); + el.addEventListener("mouseleave", hideTip); + el.addEventListener("mousedown", hideTip); +}; diff --git a/prototypes/roam-inbox/tailwind.config.cjs b/prototypes/roam-inbox/tailwind.config.cjs new file mode 100644 index 0000000..394b553 --- /dev/null +++ b/prototypes/roam-inbox/tailwind.config.cjs @@ -0,0 +1,6 @@ +const base = require("../../packages/extension-base/tailwind.config.cjs"); + +module.exports = { + ...base, + content: ["./src/**/*.{js,jsx,ts,tsx}"], +}; diff --git a/prototypes/roam-inbox/tests/bundle.spec.ts b/prototypes/roam-inbox/tests/bundle.spec.ts new file mode 100644 index 0000000..ddda2af --- /dev/null +++ b/prototypes/roam-inbox/tests/bundle.spec.ts @@ -0,0 +1,66 @@ +/* Load the BUILT bundle the way a roam/js block does. + * + * The interop bug this guards against (see interop.spec.ts) is invisible to + * unit tests, because vitest resolves CommonJS with ordinary interop; only + * the esbuild-built bundle has the broken `{ default: fn }` shape. So this + * spec imports dist/extension.js into jsdom with the host globals stubbed, + * runs onload exactly as the roam/js loader block does (extensionAPI + * undefined), and asserts the console surface appears and unloads. + * + * Skips when dist/ has not been built yet: run `pnpm build` first. + */ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterAll, describe, expect, it, vi } from "vitest"; +import { installFakeRoam } from "./fixtures"; + +const BUNDLE = join(process.cwd(), "dist", "extension.js"); +const { version } = JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf8")); + +/* Permissive stand-in for host libraries the bundle destructures lazily + * (Blueprint, RoamLazy, …): any property access or call yields another + * proxy, so module-scope destructuring never throws. */ +const anyProxy = (): any => + new Proxy(function () {}, { + get: (_t, p) => (p === Symbol.toPrimitive ? () => "" : anyProxy()), + set: () => true, + apply: () => anyProxy(), + construct: () => anyProxy(), + }); + +describe.skipIf(!existsSync(BUNDLE))("built bundle", () => { + const w = window as any; + const cleanup: (() => void)[] = []; + afterAll(() => cleanup.forEach((fn) => fn())); + + it("loads via import(), onloads with extensionAPI undefined, and unloads", async () => { + w.React = {}; + w.ReactDOM = {}; + w.Blueprint = anyProxy(); + w.RoamLazy = anyProxy(); + w.TSLib = anyProxy(); + w.Nanoid = anyProxy(); + installFakeRoam({ me: "Tester" }); + cleanup.push(() => { + for (const k of ["React", "ReactDOM", "Blueprint", "RoamLazy", "TSLib", "Nanoid", "roamAlphaAPI"]) + delete w[k]; + }); + + const module = await import(/* @vite-ignore */ BUNDLE); + const extension = module.default; + expect(typeof extension?.onload).toBe("function"); + + // Exactly what the roam/js loader block passes. runExtension's onload + // returns void, not the load promise, so wait for identity to resolve. + extension.onload({ extensionAPI: undefined, extension: { version: "roam/js" } }); + await vi.waitFor(() => expect(w.roamInbox?.debug().me).toBe("Tester")); + + expect(w.roamInbox.version).toBe(version); + expect(document.getElementById("roam-inbox-style")).toBeTruthy(); + + if (typeof extension.onunload === "function") await extension.onunload(); + else w.roamInbox.unload(); + expect(w.roamInbox).toBeUndefined(); + expect(document.getElementById("roam-inbox-style")).toBeNull(); + }); +}); diff --git a/prototypes/roam-inbox/tests/fixtures.ts b/prototypes/roam-inbox/tests/fixtures.ts new file mode 100644 index 0000000..fb2f567 --- /dev/null +++ b/prototypes/roam-inbox/tests/fixtures.ts @@ -0,0 +1,63 @@ +/* A fake `window.roamAlphaAPI` with just enough surface for load(). */ +import { Q_OPEN_REFS, Q_OPEN_TODOS } from "~/roam"; + +export const ME = "Matt Akamatsu"; +export const MY_UID = "uid-me"; + +/* One pulled row in the namespaced key style. `author: "me"` means the + * signed-in user; `todo: false` makes it a mention rather than a message. */ +export const row = ( + uid: string, + string: string, + createTime: number, + author: string, + { todo = true, addressed = `+${ME}` }: { todo?: boolean; addressed?: string } = {}, +): unknown[] => [ + { + ":block/uid": uid, + ":block/string": string, + ":create/time": createTime, + ":block/refs": [{ ":node/title": addressed }, ...(todo ? [{ ":node/title": "TODO" }] : [])], + ":create/user": { + ":user/uid": author === "me" ? MY_UID : "uid-them", + ":user/display-page": { ":node/title": author === "me" ? ME : author }, + }, + ":block/page": { ":node/title": "Sync / Roam Product" }, + ":block/_children": [{ ":block/string": "next actions" }], + }, +]; + +export type FakeGraph = { + me?: string | null; // display-page title; null for a user with no display page + addressed?: unknown[][]; // rows referencing +Me (messages and mentions) + tasks?: unknown[][]; // rows referencing Me with a TODO +}; + +export const installFakeRoam = ({ me = ME, addressed = [], tasks = [] }: FakeGraph = {}) => { + const q = async (query: string, ...params: unknown[]): Promise => { + if (query.includes(":user/uid ?uid")) return me ? [[me]] : []; + if (query === Q_OPEN_REFS && params[0] === `+${me}`) return addressed; + if (query === Q_OPEN_TODOS && params[0] === me) return tasks; + return []; + }; + const api = { + graph: { name: "dg-team" }, + user: { uid: () => MY_UID }, + util: { generateUID: () => "new-uid" }, + data: { + async: { q, pull: async () => null }, + backend: { q: async () => [] }, + addPullWatch: () => true, + removePullWatch: () => true, + page: { create: async () => {} }, + block: { create: async () => {}, update: async () => {} }, + }, + ui: { + slashCommand: { addCommand() {}, removeCommand() {} }, + rightSidebar: { addWindow: async () => {}, open: async () => {} }, + setBlockFocusAndSelection: async () => {}, + }, + }; + (window as unknown as Record).roamAlphaAPI = api; + return api; +}; diff --git a/prototypes/roam-inbox/tests/inbox.spec.ts b/prototypes/roam-inbox/tests/inbox.spec.ts new file mode 100644 index 0000000..8304ecd --- /dev/null +++ b/prototypes/roam-inbox/tests/inbox.spec.ts @@ -0,0 +1,145 @@ +/* The badge count, end to end through load(): fake graph in, unread count + * out. The badge itself never mounts (no `.rm-topbar` in jsdom) and that is + * deliberate: these pin the COUNT, which is the part that was wrong. + */ +import { afterEach, describe, expect, it } from "vitest"; +import { refreshLists, state, subscribe } from "~/inbox"; +import { debug, load, unload } from "~/lifecycle"; +import { installFakeRoam, ME, row } from "./fixtures"; + +const MINUTE = 60 * 1000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +const SEEN_KEY = "roam-inbox:seen:dg-team:uid-me"; +const LEGACY_KEY = "roam-inbox:lastSeen:dg-team:uid-me"; + +const loadWith = async (addressed: unknown[][], storage: Record = {}) => { + window.localStorage.clear(); + for (const [k, v] of Object.entries(storage)) window.localStorage.setItem(k, v); + installFakeRoam({ addressed }); + await load(); +}; + +const msg = (uid: string, text: string, createTime: number, author: string) => + row(uid, `{{[[TODO]]}} [[+${ME}]] ${text}`, createTime, author); + +afterEach(() => { + unload(); + window.localStorage.clear(); +}); + +describe("unread count", () => { + const now = Date.now(); + + /* The bug this file exists for. A message is "new" when it becomes addressed + * to you, which is an EDIT to a block that may be arbitrarily old. Keying + * off `:create/time` means an address added to last month's bullet is born + * already read. Measured on dg-team: every one of the 65 open messages had + * a create-time older than any plausible lastSeen, so the badge was pinned + * at zero and only a brand-new block could ever light it up. */ + it("counts an address added to a month-old block as new", async () => { + await loadWith([msg("blk-old-1", "^above", now - 30 * DAY, "Joel Chan")], { + [SEEN_KEY]: JSON.stringify([]), + }); + expect(debug().unreadCount).toBe(1); + }); + + it("counts a brand-new message as new", async () => { + await loadWith([msg("blk-fresh", "can you read me?", now - MINUTE, "Michael Gartner")], { + [SEEN_KEY]: JSON.stringify([]), + }); + expect(debug().unreadCount).toBe(1); + }); + + /* Opening the panel records the uid; later edits to the same block (a typo + * fix, a reply appended) must not re-light the badge. */ + it("does not count an already-seen message", async () => { + await loadWith([msg("blk-seen", "older news", now - 2 * DAY, "Sid")], { + [SEEN_KEY]: JSON.stringify(["blk-seen"]), + }); + expect(debug().unreadCount).toBe(0); + }); + + it("starts a fresh browser at zero, not at the backlog, and seeds the seen set", async () => { + await loadWith([ + msg("blk-a", "backlog one", now - 200 * DAY, "Trang Doan"), + msg("blk-b", "backlog two", now - 100 * DAY, "Sid"), + ]); + expect(debug().unreadCount).toBe(0); + expect(JSON.parse(window.localStorage.getItem(SEEN_KEY) || "[]").sort()).toEqual(["blk-a", "blk-b"]); + }); + + it("starts clean when upgrading from the lastSeen build", async () => { + await loadWith([msg("blk-old-2", "pre-upgrade", now - 50 * DAY, "Karola Kirsanow")], { + [LEGACY_KEY]: String(now - HOUR), + }); + expect(debug().unreadCount).toBe(0); + expect(window.localStorage.getItem(LEGACY_KEY)).toBeNull(); + }); + + /* 12 of the 68 measured on dg-team were `[[+Self]]` personal todos, and + * filtering them under-reported those inboxes. */ + it("counts self-addressed messages", async () => { + await loadWith([msg("blk-self", "remind myself", now - 10 * MINUTE, "me")], { + [SEEN_KEY]: JSON.stringify([]), + }); + expect(debug().unreadCount).toBe(1); + }); + + /* Messages and mentions come from one query and are told apart by the + * TODO reference; only messages badge. */ + it("does not count a mention without a checkbox", async () => { + await loadWith( + [ + row("blk-cc", `[[+${ME}]] fyi`, now - MINUTE, "Sid", { todo: false }), + msg("blk-msg", "please", now - MINUTE, "Sid"), + ], + { [SEEN_KEY]: JSON.stringify([]) }, + ); + const d = debug(); + expect(d.unreadCount).toBe(1); + expect(d.counts).toEqual({ messages: 1, mentions: 1, tasks: 0 }); + }); + + it("reports what the badge should show", async () => { + await loadWith([msg("blk-x", "x", now - MINUTE, "Sid")], { [SEEN_KEY]: JSON.stringify([]) }); + const d = debug(); + expect(d.badge).toBe("blue 1"); + expect(d.me).toBe(ME); + expect(d.watching).toBe(`+${ME}`); + expect(d.pullWatchActive).toBe(true); + }); +}); + +describe("lifecycle", () => { + it("exposes and then removes the console surface", async () => { + await loadWith([]); + expect(window.roamInbox?.version).toBeTruthy(); + expect(document.getElementById("roam-inbox-style")).toBeTruthy(); + unload(); + expect(window.roamInbox).toBeUndefined(); + expect(document.getElementById("roam-inbox-style")).toBeNull(); + }); + + it("still loads /message when the user has no display page", async () => { + installFakeRoam({ me: null }); + await load(); + expect(window.roamInbox).toBeTruthy(); + expect(debug().me).toBeNull(); + expect(debug().badge).toBe("hidden"); + }); + + it("notifies subscribers after a refresh and drops a refresh that lands after unload", async () => { + await loadWith([]); + let notified = 0; + subscribe(() => notified++); + await refreshLists(); + expect(notified).toBe(1); + const pending = refreshLists(); + unload(); + await pending; + expect(state.me).toBeNull(); + expect(notified).toBe(1); + }); +}); diff --git a/prototypes/roam-inbox/tests/interop.spec.ts b/prototypes/roam-inbox/tests/interop.spec.ts new file mode 100644 index 0000000..69585d5 --- /dev/null +++ b/prototypes/roam-inbox/tests/interop.spec.ts @@ -0,0 +1,53 @@ +/* A source-level guard for a bug two sibling prototypes shipped. + * + * roamjs-components is CommonJS. This repository builds with esbuild in ESM + * format, and its __toESM helper runs in Node-interop mode, where a default + * import of a CommonJS module resolves to the whole module object. So + * + * import addStyle from "roamjs-components/dom/addStyle"; + * addStyle(css); + * + * compiles to a call on `{ default: fn }` and throws "is not a function" at + * load. Named imports are unaffected. Vitest cannot catch this: it resolves + * CommonJS with ordinary interop, so the same code passes every unit test and + * only fails in the built bundle. Hence a check on the source text. + */ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const SRC = join(process.cwd(), "src"); + +const sourceFiles = (dir: string): string[] => + readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = join(dir, entry.name); + if (entry.isDirectory()) return sourceFiles(full); + return entry.name.endsWith(".ts") ? [full] : []; + }); + +describe("module interop", () => { + it("never default-imports from roamjs-components", () => { + const offenders = sourceFiles(SRC).flatMap((file) => + readFileSync(file, "utf8") + .split("\n") + .filter((line) => /^import\s+[A-Za-z_$][\w$]*\s*(,|from)/.test(line)) + .filter((line) => line.includes("roamjs-components")) + .map((line) => `${file.replace(SRC, "src")}: ${line.trim()}`), + ); + expect(offenders).toEqual([]); + }); + + it("never uses the legacy top-level roamAlphaAPI aliases", () => { + const offenders = sourceFiles(SRC).flatMap((file) => + readFileSync(file, "utf8") + .split("\n") + .filter((line) => /roamAlphaAPI\.(q|pull|createBlock|updateBlock|createPage)\b/.test(line)) + .map((line) => `${file.replace(SRC, "src")}: ${line.trim()}`), + ); + expect(offenders).toEqual([]); + }); + + it("finds the source files it is supposed to be checking", () => { + expect(sourceFiles(SRC).length).toBeGreaterThan(8); + }); +}); diff --git a/prototypes/roam-inbox/tests/roam.spec.ts b/prototypes/roam-inbox/tests/roam.spec.ts new file mode 100644 index 0000000..c1f3a41 --- /dev/null +++ b/prototypes/roam-inbox/tests/roam.spec.ts @@ -0,0 +1,83 @@ +/* Result shaping and the query surface. Nothing here talks to Roam. */ +import { describe, expect, it, vi } from "vitest"; +import { pick, pullWatchEntity, registerSlashCommand, shape, watchReferencesTo } from "~/roam"; +import { row } from "./fixtures"; + +describe("pick", () => { + it("reads a namespaced key", () => { + expect(pick({ ":block/uid": "u1" }, "block/uid")).toBe("u1"); + }); + it("falls back to the bare attribute name", () => { + expect(pick({ uid: "u1" }, "block/uid")).toBe("u1"); + }); + /* The trap: on some surfaces the short key does not come back undefined but + * resolves to an internal object. Namespaced must win. */ + it("prefers the namespaced key when both exist", () => { + expect(pick({ ":block/uid": "u1", uid: { garbage: true } }, "block/uid")).toBe("u1"); + }); + it("returns undefined for a missing entity", () => { + expect(pick(null, "block/uid")).toBeUndefined(); + expect(pick(undefined, "block/uid")).toBeUndefined(); + }); +}); + +describe("shape", () => { + it("flattens a pulled block, using the direct parent as context", () => { + const m = shape(row("b1", "{{[[TODO]]}} [[+Me]] hi", 42, "Joel", { addressed: "+Me" })); + expect(m).toEqual({ + uid: "b1", + string: "{{[[TODO]]}} [[+Me]] hi", + time: 42, + todo: true, + authorUid: "uid-them", + author: "Joel", + page: "Sync / Roam Product", + parent: "next actions", + }); + }); + it("reads a mention (no TODO reference) as not a todo", () => { + expect(shape(row("b1", "[[+Me]] fyi", 1, "Joel", { todo: false })).todo).toBe(false); + }); + it("uses the page title as context when the block is top-level", () => { + const m = shape([{ ":block/uid": "b1", ":block/_children": [{ ":node/title": "Sync" }] }]); + expect(m.parent).toBe("Sync"); + }); + it("tolerates a block with no author", () => { + const m = shape([{ ":block/uid": "b1" }]); + expect(m.author).toBeNull(); + expect(m.authorUid).toBeNull(); + }); +}); + +describe("pull watch", () => { + it("quotes the page title as an EDN string", () => { + expect(pullWatchEntity('+Ann "Quotey" O\'Neil')).toBe('[:node/title "+Ann \\"Quotey\\" O\'Neil"]'); + }); + it("registers and disposes with the same pattern, entity, and callback", () => { + const add = vi.fn(); + const remove = vi.fn(); + (window as unknown as Record).roamAlphaAPI = { + data: { addPullWatch: add, removePullWatch: remove }, + }; + const cb = () => {}; + const dispose = watchReferencesTo("+Me", cb); + expect(add).toHaveBeenCalledWith("[{:block/_refs [:block/uid]}]", '[:node/title "+Me"]', cb); + dispose(); + expect(remove).toHaveBeenCalledWith("[{:block/_refs [:block/uid]}]", '[:node/title "+Me"]', cb); + }); +}); + +describe("registerSlashCommand", () => { + it("returns null when the (undocumented) API is missing", () => { + (window as unknown as Record).roamAlphaAPI = { ui: {} }; + expect(registerSlashCommand("x", () => "")).toBeNull(); + }); + it("removes by label on dispose", () => { + const removeCommand = vi.fn(); + (window as unknown as Record).roamAlphaAPI = { + ui: { slashCommand: { addCommand: vi.fn(), removeCommand } }, + }; + registerSlashCommand("Send message", () => "")?.(); + expect(removeCommand).toHaveBeenCalledWith({ label: "Send message" }); + }); +}); diff --git a/prototypes/roam-inbox/tests/text.spec.ts b/prototypes/roam-inbox/tests/text.spec.ts new file mode 100644 index 0000000..531f27e --- /dev/null +++ b/prototypes/roam-inbox/tests/text.spec.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { prettify, relTime, toggleDone } from "~/text"; + +const ME = "+Matt Akamatsu"; + +describe("prettify", () => { + it("drops the checkbox and my own address", () => { + expect(prettify("{{[[TODO]]}} [[+Matt Akamatsu]] can you read me?", ME)).toBe("can you read me?"); + }); + it("drops a hashtag form of my address too", () => { + expect(prettify("#[[+Matt Akamatsu]] fyi", ME)).toBe("fyi"); + }); + it("flattens nested page refs inside out", () => { + expect(prettify("see [[[[ART]] - the figure]]", null)).toBe("see ART - the figure"); + }); + /* A Roam alias whose target is a block ref is three levels of parens. */ + it("collapses a block-ref alias to its label", () => { + expect(prettify("as in [the paper](((abcdefghi))) here", null)).toBe("as in the paper here"); + }); + it("collapses a bare block ref", () => { + expect(prettify("see ((abcdefghi))", null)).toBe("see ⟨ref⟩"); + }); + it("strips bold and marks images", () => { + expect(prettify("**bold** and __also__ ![](https://x/y.png)", null)).toBe("bold and also 🖼"); + }); + it("collapses whitespace", () => { + expect(prettify(" a b \n c ", null)).toBe("a b c"); + }); +}); + +describe("toggleDone", () => { + it("checks off a TODO", () => { + expect(toggleDone("{{[[TODO]]}} [[+A]] x")).toBe("{{[[DONE]]}} [[+A]] x"); + }); + it("accepts the short checkbox form", () => { + expect(toggleDone("{{TODO}} x")).toBe("{{[[DONE]]}} x"); + }); + it("returns null when there is nothing to check off", () => { + expect(toggleDone("[[+A]] x")).toBeNull(); + expect(toggleDone("{{[[DONE]]}} x")).toBeNull(); + }); +}); + +describe("relTime", () => { + const now = Date.now(); + it.each([ + [now - 5 * 1000, "just now"], + [now - 5 * 60 * 1000, "5m ago"], + [now - 3 * 3600 * 1000, "3h ago"], + [now - 2 * 86400 * 1000, "2d ago"], + [now - 20 * 86400 * 1000, "2w ago"], + [now - 800 * 86400 * 1000, "2y ago"], + ])("formats %d as %s", (ms, expected) => { + expect(relTime(ms)).toBe(expected); + }); +}); diff --git a/prototypes/roam-inbox/tsconfig.check.json b/prototypes/roam-inbox/tsconfig.check.json new file mode 100644 index 0000000..8760e1c --- /dev/null +++ b/prototypes/roam-inbox/tsconfig.check.json @@ -0,0 +1,18 @@ +{ + // Opt-in strict typecheck (`pnpm typecheck`): the repository has no + // typecheck step and the shared tsconfig predates TypeScript 6's removal + // of node10 moduleResolution, so this carries its own compilerOptions. + "compilerOptions": { + "target": "es2022", + "module": "esnext", + "moduleResolution": "bundler", + "lib": ["DOM", "ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"], + "paths": { "~/*": ["./src/*"] } + }, + "include": ["src", "tests"] +} diff --git a/prototypes/roam-inbox/tsconfig.json b/prototypes/roam-inbox/tsconfig.json new file mode 100644 index 0000000..a3469d4 --- /dev/null +++ b/prototypes/roam-inbox/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../packages/extension-base/tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "~/*": [ + "./src/*" + ] + } + }, + "include": [ + "src", + "tests", + "vitest.config.ts" + ] +} diff --git a/prototypes/roam-inbox/vitest.config.ts b/prototypes/roam-inbox/vitest.config.ts new file mode 100644 index 0000000..ba7c03b --- /dev/null +++ b/prototypes/roam-inbox/vitest.config.ts @@ -0,0 +1,19 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + // The generated tsconfig declares a "~/*" path alias and the esbuild CLI + // honours it, but the generated vitest config does not. Mirrored here. + resolve: { + alias: { + "~": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, + test: { + environment: "jsdom", + restoreMocks: true, + // Spec files, not test files: the repository root's `node --test` picks + // up dot-test dot-ts files itself and cannot resolve vitest or the alias. + include: ["tests/**/*.spec.ts"], + }, +});