From 167171a682387c65c21c9033e1ccb209180a44de Mon Sep 17 00:00:00 2001 From: maaz519 Date: Fri, 17 Jul 2026 19:26:18 +0530 Subject: [PATCH 01/26] chore: scaffold @insignia/iios-messaging-ui with jsdom test setup Co-Authored-By: Claude Sonnet 5 --- packages/iios-messaging-ui/package.json | 48 +++ packages/iios-messaging-ui/src/index.ts | 1 + packages/iios-messaging-ui/src/smoke.test.tsx | 9 + packages/iios-messaging-ui/src/test-setup.ts | 8 + packages/iios-messaging-ui/tsconfig.json | 14 + packages/iios-messaging-ui/tsup.config.ts | 14 + packages/iios-messaging-ui/vitest.config.ts | 13 + pnpm-lock.yaml | 400 +++++++++++++++++- 8 files changed, 505 insertions(+), 2 deletions(-) create mode 100644 packages/iios-messaging-ui/package.json create mode 100644 packages/iios-messaging-ui/src/index.ts create mode 100644 packages/iios-messaging-ui/src/smoke.test.tsx create mode 100644 packages/iios-messaging-ui/src/test-setup.ts create mode 100644 packages/iios-messaging-ui/tsconfig.json create mode 100644 packages/iios-messaging-ui/tsup.config.ts create mode 100644 packages/iios-messaging-ui/vitest.config.ts diff --git a/packages/iios-messaging-ui/package.json b/packages/iios-messaging-ui/package.json new file mode 100644 index 0000000..652c6e7 --- /dev/null +++ b/packages/iios-messaging-ui/package.json @@ -0,0 +1,48 @@ +{ + "name": "@insignia/iios-messaging-ui", + "version": "0.1.0", + "type": "module", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./adapters/mock": { + "types": "./dist/adapters/mock.d.ts", + "import": "./dist/adapters/mock.js" + }, + "./conformance": { + "types": "./dist/conformance.d.ts", + "import": "./dist/conformance.js" + }, + "./styles.css": "./dist/styles.css" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "peerDependencies": { + "react": ">=18" + }, + "devDependencies": { + "@testing-library/dom": "^10.4.0", + "@testing-library/react": "^16.1.0", + "@types/react": "^19.0.0", + "jsdom": "^26.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tsup": "^8.3.5", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + }, + "publishConfig": { + "registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/" + } +} diff --git a/packages/iios-messaging-ui/src/index.ts b/packages/iios-messaging-ui/src/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/packages/iios-messaging-ui/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/iios-messaging-ui/src/smoke.test.tsx b/packages/iios-messaging-ui/src/smoke.test.tsx new file mode 100644 index 0000000..3af897f --- /dev/null +++ b/packages/iios-messaging-ui/src/smoke.test.tsx @@ -0,0 +1,9 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +describe('test infrastructure', () => { + it('renders React components in jsdom', () => { + render(
messaging-ui
); + expect(screen.getByText('messaging-ui')).toBeDefined(); + }); +}); diff --git a/packages/iios-messaging-ui/src/test-setup.ts b/packages/iios-messaging-ui/src/test-setup.ts new file mode 100644 index 0000000..8919f3f --- /dev/null +++ b/packages/iios-messaging-ui/src/test-setup.ts @@ -0,0 +1,8 @@ +import { afterEach } from 'vitest'; +import { cleanup } from '@testing-library/react'; + +// @testing-library/react auto-registers cleanup only when afterEach is a global. +// We run with globals: false, so register it explicitly. +afterEach(() => { + cleanup(); +}); diff --git a/packages/iios-messaging-ui/tsconfig.json b/packages/iios-messaging-ui/tsconfig.json new file mode 100644 index 0000000..4a4d5c4 --- /dev/null +++ b/packages/iios-messaging-ui/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "jsx": "react-jsx", + "types": ["react"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] +} diff --git a/packages/iios-messaging-ui/tsup.config.ts b/packages/iios-messaging-ui/tsup.config.ts new file mode 100644 index 0000000..b444403 --- /dev/null +++ b/packages/iios-messaging-ui/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + // `src/adapters/mock.ts` and `src/conformance.ts` are added as separate entries + // in later tasks (they don't exist yet). `conformance` in particular is its own + // entry, never reachable from `index`, because it imports vitest, and bundling + // that into the main barrel would drag a test runner into every consumer's + // production build. + entry: ['src/index.ts'], + format: ['esm'], + dts: true, + clean: true, + external: ['react', 'react-dom', 'vitest'], +}); diff --git a/packages/iios-messaging-ui/vitest.config.ts b/packages/iios-messaging-ui/vitest.config.ts new file mode 100644 index 0000000..853c7e8 --- /dev/null +++ b/packages/iios-messaging-ui/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +// This package owns its vitest config on purpose. The ROOT config includes only +// `.ts` (never `.tsx`) and provisions a Postgres DB via globalSetup for every run — +// a pure-UI package must not drag a database along, and its tests are .tsx. +export default defineConfig({ + test: { + environment: 'jsdom', + include: ['src/**/*.{test,spec}.{ts,tsx}'], + setupFiles: ['./src/test-setup.ts'], + globals: false, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 251f3b0..cdaaecf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,7 +16,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.5 - version: 3.2.6(@types/node@26.0.1)(jiti@2.7.0)(terser@5.48.0) + version: 3.2.6(@types/node@26.0.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0) apps/adapter-inspector: dependencies: @@ -326,6 +326,36 @@ importers: specifier: ^5.7.3 version: 5.9.3 + packages/iios-messaging-ui: + devDependencies: + '@testing-library/dom': + specifier: ^10.4.0 + version: 10.4.1 + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@types/react': + specifier: ^19.0.0 + version: 19.2.17 + jsdom: + specifier: ^26.0.0 + version: 26.1.0 + react: + specifier: ^19.0.0 + version: 19.2.7 + react-dom: + specifier: ^19.0.0 + version: 19.2.7(react@19.2.7) + tsup: + specifier: ^8.3.5 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.16)(typescript@5.9.3) + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^3.0.5 + version: 3.2.6(@types/node@26.0.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0) + packages/iios-service: dependencies: '@aws-sdk/client-s3': @@ -562,6 +592,9 @@ packages: resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} engines: {node: '>=18.0.0'} + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -633,6 +666,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -652,6 +689,34 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -1561,6 +1626,25 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@tokenizer/inflate@0.4.1': resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} engines: {node: '>=18'} @@ -1568,6 +1652,9 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1813,6 +1900,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansis@4.2.0: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} @@ -1826,6 +1917,9 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + array-timsort@1.0.3: resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} @@ -2060,9 +2154,17 @@ packages: typescript: optional: true + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + debug@4.3.7: resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} engines: {node: '>=6.0'} @@ -2081,6 +2183,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -2107,9 +2212,16 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -2156,6 +2268,10 @@ packages: resolution: {integrity: sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==} engines: {node: '>=10.13.0'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -2355,10 +2471,18 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + http_ece@1.2.0: resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==} engines: {node: '>=16'} @@ -2367,6 +2491,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -2400,6 +2528,9 @@ packages: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -2436,6 +2567,15 @@ packages: resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -2534,6 +2674,9 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.1: resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} engines: {node: 20 || >=22} @@ -2544,6 +2687,10 @@ packages: lru-memoizer@3.0.0: resolution: {integrity: sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -2663,6 +2810,9 @@ packages: notepack.io@3.0.1: resolution: {integrity: sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==} + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + nypm@0.6.8: resolution: {integrity: sha512-Q9K4Diu6l5u6xJQogeFSs/zKtyMSgFKFtRQV+tHP4kL7KPm2grpBU0dFIwFaXwNxN0MtfKWc43VpCugAa+LPsw==} engines: {node: '>=18'} @@ -2706,6 +2856,9 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -2774,6 +2927,10 @@ packages: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prisma@6.19.3: resolution: {integrity: sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==} engines: {node: '>=18.18'} @@ -2815,6 +2972,9 @@ packages: peerDependencies: react: ^19.2.7 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -2867,6 +3027,9 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + rxjs@7.8.1: resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} @@ -2879,6 +3042,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -3026,6 +3193,9 @@ packages: resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} engines: {node: '>=0.10'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} @@ -3111,6 +3281,13 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -3119,6 +3296,14 @@ packages: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -3336,6 +3521,10 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + watchpack@2.5.2: resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} engines: {node: '>=10.13.0'} @@ -3348,6 +3537,10 @@ packages: engines: {node: '>= 16'} hasBin: true + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + webpack-node-externals@3.0.0: resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==} engines: {node: '>=6'} @@ -3366,6 +3559,19 @@ packages: webpack-cli: optional: true + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -3393,6 +3599,13 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xmlhttprequest-ssl@2.1.2: resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} engines: {node: '>=0.4.0'} @@ -3629,6 +3842,14 @@ snapshots: '@aws/lambda-invoke-store@0.3.0': {} + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -3718,6 +3939,8 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -3746,6 +3969,26 @@ snapshots: '@colors/colors@1.5.0': optional: true + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -4418,6 +4661,27 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@tokenizer/inflate@0.4.1': dependencies: debug: 4.4.3 @@ -4427,6 +4691,8 @@ snapshots: '@tokenizer/token@0.3.0': {} + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.7 @@ -4741,6 +5007,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansis@4.2.0: {} any-promise@1.3.0: {} @@ -4749,6 +5017,10 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + array-timsort@1.0.3: {} asn1.js@5.4.1: @@ -4973,8 +5245,18 @@ snapshots: optionalDependencies: typescript: 5.9.3 + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + csstype@3.2.3: {} + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + debug@4.3.7: dependencies: ms: 2.1.3 @@ -4983,6 +5265,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-eql@5.0.2: {} deepmerge-ts@7.1.5: {} @@ -4999,8 +5283,12 @@ snapshots: depd@2.0.0: {} + dequal@2.0.3: {} + destr@2.0.5: {} + dom-accessibility-api@0.5.16: {} + dotenv@16.6.1: {} dunder-proto@1.0.1: @@ -5064,6 +5352,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@6.0.1: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -5365,6 +5655,10 @@ snapshots: dependencies: function-bind: 1.1.2 + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -5373,6 +5667,13 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + http_ece@1.2.0: {} https-proxy-agent@7.0.6: @@ -5382,6 +5683,10 @@ snapshots: transitivePeerDependencies: - supports-color + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -5415,6 +5720,8 @@ snapshots: is-interactive@1.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-unicode-supported@0.1.0: {} @@ -5441,6 +5748,33 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.1.0: {} json-parse-even-better-errors@2.3.1: {} @@ -5533,6 +5867,8 @@ snapshots: loupe@3.2.1: {} + lru-cache@10.4.3: {} + lru-cache@11.5.1: {} lru-cache@5.1.1: @@ -5544,6 +5880,8 @@ snapshots: lodash.clonedeep: 4.5.0 lru-cache: 11.5.1 + lz-string@1.5.0: {} + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5640,6 +5978,8 @@ snapshots: notepack.io@3.0.1: {} + nwsapi@2.2.24: {} + nypm@0.6.8: dependencies: citty: 0.2.2 @@ -5689,6 +6029,10 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parseurl@1.3.3: {} path-scurry@2.0.2: @@ -5739,6 +6083,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prisma@6.19.3(typescript@5.9.3): dependencies: '@prisma/config': 6.19.3 @@ -5781,6 +6131,8 @@ snapshots: react: 19.2.7 scheduler: 0.27.0 + react-is@17.0.2: {} + react-refresh@0.17.0: {} react@19.2.7: {} @@ -5853,6 +6205,8 @@ snapshots: transitivePeerDependencies: - supports-color + rrweb-cssom@0.8.0: {} + rxjs@7.8.1: dependencies: tslib: 2.8.1 @@ -5865,6 +6219,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} schema-utils@3.3.0: @@ -6053,6 +6411,8 @@ snapshots: symbol-observable@4.0.0: {} + symbol-tree@3.2.4: {} + tapable@2.3.3: {} terser-webpack-plugin@5.6.1(webpack@5.106.2): @@ -6095,6 +6455,12 @@ snapshots: tinyspy@4.0.4: {} + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + toidentifier@1.0.1: {} token-types@6.1.2: @@ -6103,6 +6469,14 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} ts-interface-checker@0.1.13: {} @@ -6249,7 +6623,7 @@ snapshots: jiti: 2.7.0 terser: 5.48.0 - vitest@3.2.6(@types/node@26.0.1)(jiti@2.7.0)(terser@5.48.0): + vitest@3.2.6(@types/node@26.0.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.6 @@ -6276,6 +6650,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.0.1 + jsdom: 26.1.0 transitivePeerDependencies: - jiti - less @@ -6290,6 +6665,10 @@ snapshots: - tsx - yaml + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + watchpack@2.5.2: dependencies: graceful-fs: 4.2.11 @@ -6308,6 +6687,8 @@ snapshots: transitivePeerDependencies: - supports-color + webidl-conversions@7.0.0: {} + webpack-node-externals@3.0.0: {} webpack-sources@3.5.0: {} @@ -6352,6 +6733,17 @@ snapshots: - postcss - uglify-js + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -6369,6 +6761,10 @@ snapshots: ws@8.21.0: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + xmlhttprequest-ssl@2.1.2: {} yallist@3.1.1: {} From 0c9b27684bd1e03a99382b303c232253fedba095 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 00:41:05 +0530 Subject: [PATCH 02/26] feat: add messaging-ui domain types --- packages/iios-messaging-ui/src/types.test.ts | 28 +++++++++ packages/iios-messaging-ui/src/types.ts | 64 ++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 packages/iios-messaging-ui/src/types.test.ts create mode 100644 packages/iios-messaging-ui/src/types.ts diff --git a/packages/iios-messaging-ui/src/types.test.ts b/packages/iios-messaging-ui/src/types.test.ts new file mode 100644 index 0000000..da334a2 --- /dev/null +++ b/packages/iios-messaging-ui/src/types.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { isOwnMessage } from './types'; +import type { Message } from './types'; + +const base: Message = { + id: 'm1', + actorId: 'actor_a', + text: 'hello', + at: '2026-07-17T10:00:00.000Z', +}; + +describe('isOwnMessage', () => { + it('is true when the message actor matches the current actor', () => { + expect(isOwnMessage(base, 'actor_a')).toBe(true); + }); + + it('is false when the actors differ', () => { + expect(isOwnMessage(base, 'actor_b')).toBe(false); + }); + + it('is false when the current actor is unknown', () => { + expect(isOwnMessage(base, null)).toBe(false); + }); + + it('is false when the message has no actor', () => { + expect(isOwnMessage({ ...base, actorId: null }, 'actor_a')).toBe(false); + }); +}); diff --git a/packages/iios-messaging-ui/src/types.ts b/packages/iios-messaging-ui/src/types.ts new file mode 100644 index 0000000..ae99f9b --- /dev/null +++ b/packages/iios-messaging-ui/src/types.ts @@ -0,0 +1,64 @@ +// Domain types for the messaging UI. Zero imports on purpose: this file must never +// reach for a transport package. Adapters map their own DTOs onto these. + +export type Membership = 'dm' | 'group'; + +export interface Person { + id: string; + name: string; + kind: 'staff' | 'customer'; +} + +export interface Conversation { + threadId: string; + title: string; + subject: string | null; + membership: Membership | null; + participants: string[]; + unread: number; + lastMessage?: string; + lastAt?: string; +} + +export interface Reaction { + emoji: string; + count: number; + mine: boolean; +} + +export interface Attachment { + url: string; + mime: string; + name: string; +} + +export interface Message { + id: string; + actorId: string | null; + text: string; + at: string; + parentInteractionId?: string | null; + reactions?: Reaction[]; + attachment?: Attachment; + /** True only when the transport is optimistic-local and not yet acknowledged. */ + pending?: boolean; +} + +export interface SendOpts { + parentInteractionId?: string; + attachment?: Attachment; +} + +export type MessageEvent = + | { kind: 'message'; message: Message } + | { kind: 'typing'; userId: string } + | { kind: 'receipt'; messageId: string; actorId: string } + | { kind: 'reaction'; messageId: string; reactions: Reaction[] }; + +export type Unsubscribe = () => void; + +/** Ownership is a pure function of explicit identity — never inferred from history. + * See the spec: inferring it is the bug this SDK exists partly to kill. */ +export function isOwnMessage(message: Message, currentActorId: string | null): boolean { + return currentActorId !== null && message.actorId !== null && message.actorId === currentActorId; +} From 3e9951b3a8096a9c381d3d39e5bd51f0e2d3d042 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 00:45:03 +0530 Subject: [PATCH 03/26] feat: define MessagingAdapter contract --- packages/iios-messaging-ui/src/adapter.ts | 56 +++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 packages/iios-messaging-ui/src/adapter.ts diff --git a/packages/iios-messaging-ui/src/adapter.ts b/packages/iios-messaging-ui/src/adapter.ts new file mode 100644 index 0000000..1a13ff4 --- /dev/null +++ b/packages/iios-messaging-ui/src/adapter.ts @@ -0,0 +1,56 @@ +import type { + Attachment, + Conversation, + Membership, + Message, + MessageEvent, + SendOpts, + Unsubscribe, +} from './types'; + +/** + * The one seam of this SDK. Hosts implement this; the SDK renders it. + * + * Lifted from lynkeduppro-crm's MessengerData/ThreadData, which already survived + * two implementations (live data-door + mock) — the minimum real evidence that a + * seam is genuine rather than imagined. + * + * Optional methods degrade gracefully: the UI hides the reaction picker when + * `react` is absent, and the attach button when `upload` is absent. That is how one + * component set serves both a full CRM messenger and a stripped-down widget with no + * `mode` prop. + */ +export interface MessagingAdapter { + listConversations(): Promise; + + openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }>; + + history(threadId: string): Promise; + + send(threadId: string, content: string, opts?: SendOpts): Promise; + + /** Returns an unsubscribe fn. Implementations MUST be idempotent on repeat unsubscribe. */ + subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe; + + sendTyping(threadId: string): void; + + markRead(threadId: string, messageId: string): Promise; + + /** + * The current user's actor id, or null if not yet known. + * + * MUST NOT be inferred from message history. The CRM's bug was exactly that: + * scanning for a sent message meant every message read as not-yours until you + * had spoken. Adapters derive this from auth/session. + */ + currentActorId(): string | null; + + /** Absent => the UI hides reactions entirely. */ + react?(threadId: string, messageId: string, emoji: string): Promise; + + /** Absent => the UI hides attachments. Storage/auth/limits are the host's concern. */ + upload?(file: File): Promise; + + /** Absent => treated as always connected (e.g. a pure-REST adapter). */ + isConnected?(): boolean; +} From 0273d1c70f5b4b0b3ca3c9958ec4a9826fddcd21 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 00:46:25 +0530 Subject: [PATCH 04/26] feat: add adapter conformance suite --- packages/iios-messaging-ui/src/conformance.ts | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 packages/iios-messaging-ui/src/conformance.ts diff --git a/packages/iios-messaging-ui/src/conformance.ts b/packages/iios-messaging-ui/src/conformance.ts new file mode 100644 index 0000000..8b397fb --- /dev/null +++ b/packages/iios-messaging-ui/src/conformance.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest'; +import type { MessagingAdapter } from './adapter'; +import type { MessageEvent } from './types'; + +export interface ConformanceOptions { + /** Build a fresh, isolated adapter per test. */ + makeAdapter: () => Promise | MessagingAdapter; + /** A thread id that exists in the fixture. */ + seededThreadId: string; + /** Participant ids openThread can legally be called with. */ + openWith: string[]; +} + +/** + * The definition of a correct MessagingAdapter. Every adapter runs this. + * + * Imports vitest, so it ships from the './conformance' subpath ONLY and is never + * reachable from the main barrel. Consumers supply their own vitest. + * + * Usage from another package: + * import { runAdapterConformance } from '@insignia/iios-messaging-ui/conformance'; + * runAdapterConformance({ makeAdapter: () => new MockAdapter(), seededThreadId: 'th_1', openWith: ['pp_a'] }); + */ +export function runAdapterConformance(opts: ConformanceOptions): void { + const make = async () => await opts.makeAdapter(); + + describe('MessagingAdapter conformance', () => { + it('lists conversations', async () => { + const a = await make(); + const list = await a.listConversations(); + expect(Array.isArray(list)).toBe(true); + }); + + it('returns history for a seeded thread', async () => { + const a = await make(); + const msgs = await a.history(opts.seededThreadId); + expect(Array.isArray(msgs)).toBe(true); + }); + + it('send resolves with a message carrying the sent text and a stable id', async () => { + const a = await make(); + const m = await a.send(opts.seededThreadId, 'conformance hello'); + expect(m.text).toBe('conformance hello'); + expect(typeof m.id).toBe('string'); + expect(m.id.length).toBeGreaterThan(0); + }); + + it('a sent message is attributed to the current actor', async () => { + const a = await make(); + const m = await a.send(opts.seededThreadId, 'whose is this'); + expect(m.actorId).toBe(a.currentActorId()); + }); + + it('currentActorId is known BEFORE any message is sent', async () => { + // The bug this SDK exists to kill: identity must come from auth, never be + // inferred from history. A fresh adapter already knows who you are. + const a = await make(); + expect(a.currentActorId()).not.toBeNull(); + }); + + it('a sent message appears in history', async () => { + const a = await make(); + await a.send(opts.seededThreadId, 'persist me'); + const msgs = await a.history(opts.seededThreadId); + expect(msgs.some((m) => m.text === 'persist me')).toBe(true); + }); + + it('subscribe delivers a message event on send', async () => { + const a = await make(); + const seen: MessageEvent[] = []; + const off = a.subscribe(opts.seededThreadId, (e) => seen.push(e)); + await a.send(opts.seededThreadId, 'live one'); + off(); + const msgs = seen.filter((e) => e.kind === 'message'); + expect(msgs.length).toBeGreaterThan(0); + }); + + it('unsubscribe stops delivery', async () => { + const a = await make(); + const seen: MessageEvent[] = []; + const off = a.subscribe(opts.seededThreadId, (e) => seen.push(e)); + off(); + await a.send(opts.seededThreadId, 'should not be heard'); + expect(seen).toHaveLength(0); + }); + + it('unsubscribe is idempotent', async () => { + const a = await make(); + const off = a.subscribe(opts.seededThreadId, () => {}); + off(); + expect(() => off()).not.toThrow(); + }); + + it('openThread returns a thread id', async () => { + const a = await make(); + const { threadId } = await a.openThread({ participantIds: opts.openWith }); + expect(typeof threadId).toBe('string'); + expect(threadId.length).toBeGreaterThan(0); + }); + + it('markRead resolves', async () => { + const a = await make(); + const m = await a.send(opts.seededThreadId, 'read me'); + await expect(a.markRead(opts.seededThreadId, m.id)).resolves.toBeUndefined(); + }); + + it('sendTyping does not throw', async () => { + const a = await make(); + expect(() => a.sendTyping(opts.seededThreadId)).not.toThrow(); + }); + }); +} From 54f426e4f0e9f3054164e26bd43980882ca28d32 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 00:48:18 +0530 Subject: [PATCH 05/26] feat: add MockAdapter passing the conformance suite Co-Authored-By: Claude Opus 4.8 (1M context) --- .../iios-messaging-ui/src/adapters/mock.ts | 168 ++++++++++++++++++ .../iios-messaging-ui/src/conformance.test.ts | 8 + 2 files changed, 176 insertions(+) create mode 100644 packages/iios-messaging-ui/src/adapters/mock.ts create mode 100644 packages/iios-messaging-ui/src/conformance.test.ts diff --git a/packages/iios-messaging-ui/src/adapters/mock.ts b/packages/iios-messaging-ui/src/adapters/mock.ts new file mode 100644 index 0000000..fbd31b8 --- /dev/null +++ b/packages/iios-messaging-ui/src/adapters/mock.ts @@ -0,0 +1,168 @@ +import type { MessagingAdapter } from '../adapter'; +import type { + Attachment, + Conversation, + Membership, + Message, + MessageEvent, + Person, + SendOpts, + Unsubscribe, +} from '../types'; + +const ME = 'me'; + +export const MOCK_PEOPLE: Person[] = [ + { id: 'pp_sofia', name: 'Sofia Ramirez', kind: 'staff' }, + { id: 'pp_dan', name: 'Dan Whitaker', kind: 'staff' }, + { id: 'pp_priya', name: 'Priya Nair', kind: 'staff' }, + { id: 'cust_acme', name: 'Acme Roofing (Client)', kind: 'customer' }, + { id: 'cust_globex', name: 'Globex Homes (Client)', kind: 'customer' }, +]; + +interface MockThread { + threadId: string; + membership: Membership; + subject: string | null; + participants: string[]; + messages: Message[]; +} + +const nameById = new Map(MOCK_PEOPLE.map((p) => [p.id, p.name])); + +/** + * In-memory adapter for demos and tests. State is PER INSTANCE — the CRM's version + * used a module-level Map, which leaks between tests. Each `new MockAdapter()` is + * fully isolated. + */ +export class MockAdapter implements MessagingAdapter { + private seq = 100; + private threads = new Map(); + private listeners = new Map void>>(); + + constructor(private readonly now: () => string = () => new Date().toISOString()) { + const seededAt = this.now(); + this.threads.set('th_mock_1', { + threadId: 'th_mock_1', + membership: 'dm', + subject: null, + participants: [ME, 'pp_sofia'], + messages: [ + { + id: 'm1', + actorId: 'pp_sofia', + text: 'Can you review the Henderson estimate?', + at: seededAt, + reactions: [], + }, + ], + }); + this.threads.set('th_mock_2', { + threadId: 'th_mock_2', + membership: 'group', + subject: 'Storm response — East side', + participants: [ME, 'pp_dan', 'pp_priya'], + messages: [ + { id: 'm2', actorId: 'pp_dan', text: 'Crew is rolling out at 7.', at: seededAt, reactions: [] }, + ], + }); + } + + currentActorId(): string { + return ME; + } + + async listConversations(): Promise { + return [...this.threads.values()].map((t) => { + const last = t.messages[t.messages.length - 1]; + const others = t.participants.filter((p) => p !== ME); + return { + threadId: t.threadId, + title: t.subject || others.map((id) => nameById.get(id) ?? id).join(', ') || 'Conversation', + subject: t.subject, + membership: t.membership, + participants: [...t.participants], + unread: 0, + ...(last ? { lastMessage: last.text, lastAt: last.at } : {}), + }; + }); + } + + async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> { + const membership = p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group'); + const threadId = `th_mock_${this.seq++}`; + this.threads.set(threadId, { + threadId, + membership, + subject: p.subject ?? null, + participants: [ME, ...p.participantIds], + messages: [], + }); + return { threadId }; + } + + async history(threadId: string): Promise { + return [...(this.threads.get(threadId)?.messages ?? [])]; + } + + async send(threadId: string, content: string, opts?: SendOpts): Promise { + const t = this.threads.get(threadId); + if (!t) throw new Error(`Unknown thread: ${threadId}`); + const message: Message = { + id: `m_${this.seq++}`, + actorId: ME, + text: content, + at: this.now(), + reactions: [], + ...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}), + ...(opts?.attachment ? { attachment: opts.attachment } : {}), + }; + t.messages = [...t.messages, message]; + this.emit(threadId, { kind: 'message', message }); + return message; + } + + async react(threadId: string, messageId: string, emoji: string): Promise { + const t = this.threads.get(threadId); + if (!t) return; + let next: Message | undefined; + t.messages = t.messages.map((m) => { + if (m.id !== messageId) return m; + const existing = (m.reactions ?? []).find((r) => r.emoji === emoji); + const reactions = existing + ? (m.reactions ?? []).filter((r) => r.emoji !== emoji) + : [...(m.reactions ?? []), { emoji, count: 1, mine: true }]; + next = { ...m, reactions }; + return next; + }); + if (next) this.emit(threadId, { kind: 'reaction', messageId, reactions: next.reactions ?? [] }); + } + + async upload(file: File): Promise { + return { url: `mock://uploads/${file.name}`, mime: file.type, name: file.name }; + } + + subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe { + if (!this.listeners.has(threadId)) this.listeners.set(threadId, new Set()); + this.listeners.get(threadId)!.add(cb); + return () => { + this.listeners.get(threadId)?.delete(cb); + }; + } + + sendTyping(): void { + // No-op: nobody is typing back in a mock. + } + + async markRead(): Promise { + // No-op: the mock has no second party to report a read. + } + + isConnected(): boolean { + return true; + } + + private emit(threadId: string, e: MessageEvent): void { + this.listeners.get(threadId)?.forEach((cb) => cb(e)); + } +} diff --git a/packages/iios-messaging-ui/src/conformance.test.ts b/packages/iios-messaging-ui/src/conformance.test.ts new file mode 100644 index 0000000..bef335a --- /dev/null +++ b/packages/iios-messaging-ui/src/conformance.test.ts @@ -0,0 +1,8 @@ +import { runAdapterConformance } from './conformance'; +import { MockAdapter } from './adapters/mock'; + +runAdapterConformance({ + makeAdapter: () => new MockAdapter(), + seededThreadId: 'th_mock_1', + openWith: ['pp_sofia'], +}); From 256a5dcea0271d9f1edb7cfc382a23ecd16bd034 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 00:57:39 +0530 Subject: [PATCH 06/26] feat: add MessagingProvider and useAdapter --- .../iios-messaging-ui/src/provider.test.tsx | 25 +++++++++++++++++++ packages/iios-messaging-ui/src/provider.tsx | 22 ++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 packages/iios-messaging-ui/src/provider.test.tsx create mode 100644 packages/iios-messaging-ui/src/provider.tsx diff --git a/packages/iios-messaging-ui/src/provider.test.tsx b/packages/iios-messaging-ui/src/provider.test.tsx new file mode 100644 index 0000000..7054c46 --- /dev/null +++ b/packages/iios-messaging-ui/src/provider.test.tsx @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MessagingProvider, useAdapter } from './provider'; +import { MockAdapter } from './adapters/mock'; + +function ShowActor() { + const adapter = useAdapter(); + return {adapter.currentActorId()}; +} + +describe('MessagingProvider', () => { + it('supplies the injected adapter to descendants', () => { + render( + + + , + ); + expect(screen.getByText('me')).toBeDefined(); + }); + + it('throws a helpful error when a hook is used outside the provider', () => { + // React logs the error boundary trace; that noise is expected. + expect(() => render()).toThrow(/useAdapter must be used within a /); + }); +}); diff --git a/packages/iios-messaging-ui/src/provider.tsx b/packages/iios-messaging-ui/src/provider.tsx new file mode 100644 index 0000000..748c00f --- /dev/null +++ b/packages/iios-messaging-ui/src/provider.tsx @@ -0,0 +1,22 @@ +import { createContext, useContext, type ReactNode } from 'react'; +import type { MessagingAdapter } from './adapter'; + +const AdapterContext = createContext(null); + +export function MessagingProvider({ + adapter, + children, +}: { + adapter: MessagingAdapter; + children: ReactNode; +}) { + return {children}; +} + +/** Access the host-injected adapter. Throws outside a provider — a missing provider is + * a wiring bug, and failing loudly beats a confusing null-deref three layers down. */ +export function useAdapter(): MessagingAdapter { + const adapter = useContext(AdapterContext); + if (!adapter) throw new Error('useAdapter must be used within a '); + return adapter; +} From 0ffe21a0c2d7577075ce7529fce02178b89f69ae Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 00:59:16 +0530 Subject: [PATCH 07/26] feat: add useConversations hook --- .../src/hooks/use-conversations.test.tsx | 49 +++++++++++++++++++ .../src/hooks/use-conversations.ts | 45 +++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 packages/iios-messaging-ui/src/hooks/use-conversations.test.tsx create mode 100644 packages/iios-messaging-ui/src/hooks/use-conversations.ts diff --git a/packages/iios-messaging-ui/src/hooks/use-conversations.test.tsx b/packages/iios-messaging-ui/src/hooks/use-conversations.test.tsx new file mode 100644 index 0000000..0970680 --- /dev/null +++ b/packages/iios-messaging-ui/src/hooks/use-conversations.test.tsx @@ -0,0 +1,49 @@ +import { describe, it, expect, vi } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { MessagingProvider } from '../provider'; +import { MockAdapter } from '../adapters/mock'; +import { useConversations } from './use-conversations'; +import type { MessagingAdapter } from '../adapter'; + +const wrap = (adapter: MessagingAdapter) => + function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; + +describe('useConversations', () => { + it('starts loading, then resolves the adapter list', async () => { + const { result } = renderHook(() => useConversations(), { wrapper: wrap(new MockAdapter()) }); + expect(result.current.loading).toBe(true); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.conversations).toHaveLength(2); + expect(result.current.conversations[0]!.threadId).toBe('th_mock_1'); + expect(result.current.error).toBeNull(); + }); + + it('surfaces adapter failure as error state and never throws', async () => { + const adapter = new MockAdapter(); + vi.spyOn(adapter, 'listConversations').mockRejectedValue(new Error('data door down')); + + const { result } = renderHook(() => useConversations(), { wrapper: wrap(adapter) }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBe('data door down'); + expect(result.current.conversations).toEqual([]); + }); + + it('refetch picks up newly opened threads', async () => { + const adapter = new MockAdapter(); + const { result } = renderHook(() => useConversations(), { wrapper: wrap(adapter) }); + await waitFor(() => expect(result.current.conversations).toHaveLength(2)); + + await act(async () => { + await adapter.openThread({ participantIds: ['pp_dan'] }); + }); + await act(async () => { + result.current.refetch(); + }); + + await waitFor(() => expect(result.current.conversations).toHaveLength(3)); + }); +}); diff --git a/packages/iios-messaging-ui/src/hooks/use-conversations.ts b/packages/iios-messaging-ui/src/hooks/use-conversations.ts new file mode 100644 index 0000000..f8507eb --- /dev/null +++ b/packages/iios-messaging-ui/src/hooks/use-conversations.ts @@ -0,0 +1,45 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useAdapter } from '../provider'; +import type { Conversation } from '../types'; + +export interface ConversationsState { + conversations: Conversation[]; + loading: boolean; + error: string | null; + refetch: () => void; +} + +export function useConversations(): ConversationsState { + const adapter = useAdapter(); + const [conversations, setConversations] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [nonce, setNonce] = useState(0); + + useEffect(() => { + let alive = true; + setLoading(true); + adapter + .listConversations() + .then((list) => { + if (!alive) return; + setConversations(list); + setError(null); + }) + .catch((e: unknown) => { + if (!alive) return; + setError(e instanceof Error ? e.message : String(e)); + setConversations([]); + }) + .finally(() => { + if (alive) setLoading(false); + }); + return () => { + alive = false; + }; + }, [adapter, nonce]); + + const refetch = useCallback(() => setNonce((n) => n + 1), []); + + return { conversations, loading, error, refetch }; +} From 99f9ac84fb1f0f199046fc70371434ea3aac5f11 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 01:02:00 +0530 Subject: [PATCH 08/26] feat: add useMessages hook with explicit ownership and optimistic send Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/hooks/use-messages.test.tsx | 160 ++++++++++++++ .../src/hooks/use-messages.ts | 205 ++++++++++++++++++ 2 files changed, 365 insertions(+) create mode 100644 packages/iios-messaging-ui/src/hooks/use-messages.test.tsx create mode 100644 packages/iios-messaging-ui/src/hooks/use-messages.ts diff --git a/packages/iios-messaging-ui/src/hooks/use-messages.test.tsx b/packages/iios-messaging-ui/src/hooks/use-messages.test.tsx new file mode 100644 index 0000000..f6d0771 --- /dev/null +++ b/packages/iios-messaging-ui/src/hooks/use-messages.test.tsx @@ -0,0 +1,160 @@ +import { describe, it, expect, vi } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { MessagingProvider } from '../provider'; +import { MockAdapter } from '../adapters/mock'; +import { useMessages } from './use-messages'; +import type { MessagingAdapter } from '../adapter'; +import type { Message } from '../types'; + +const wrap = (adapter: MessagingAdapter) => + function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; + +describe('useMessages', () => { + it('loads history for the thread', async () => { + const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(new MockAdapter()) }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages[0]!.text).toBe('Can you review the Henderson estimate?'); + }); + + // REGRESSION: the CRM inferred actor identity by scanning for a sent message, so + // before you had spoken in a thread EVERY message rendered as not-yours. + it('marks ownership correctly before the user has sent anything', async () => { + const adapter = new MockAdapter(); + await adapter.send('th_mock_1', 'an earlier message of mine'); + + const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) }); + await waitFor(() => expect(result.current.messages).toHaveLength(2)); + + // Never sent anything via the hook — ownership still resolves from currentActorId(). + expect(result.current.messages[0]!.mine).toBe(false); // from pp_sofia + expect(result.current.messages[1]!.mine).toBe(true); // from me + }); + + it('appends an optimistic message immediately on send', async () => { + const adapter = new MockAdapter(); + let release!: () => void; + vi.spyOn(adapter, 'send').mockImplementation( + () => new Promise((res) => { release = () => res({ id: 'srv_1', actorId: 'me', text: 'hi', at: '2026-07-17T10:00:00.000Z' }); }), + ); + + const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { void result.current.send('hi'); }); + + await waitFor(() => expect(result.current.messages).toHaveLength(2)); + expect(result.current.messages[1]!.pending).toBe(true); + expect(result.current.messages[1]!.mine).toBe(true); + + await act(async () => { release(); }); + await waitFor(() => expect(result.current.messages[1]!.pending).toBeFalsy()); + }); + + it('rolls back the optimistic message and reports error when send fails', async () => { + const adapter = new MockAdapter(); + vi.spyOn(adapter, 'send').mockRejectedValue(new Error('offline')); + + const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await expect(result.current.send('doomed')).rejects.toThrow('offline'); + }); + + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages.some((m) => m.text === 'doomed')).toBe(false); + expect(result.current.error).toBe('offline'); + }); + + it('does not duplicate a message when the transport echoes it back', async () => { + const adapter = new MockAdapter(); + const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + // MockAdapter.send emits a 'message' event AND resolves with the same message. + await act(async () => { await result.current.send('echo once'); }); + + expect(result.current.messages.filter((m) => m.text === 'echo once')).toHaveLength(1); + }); + + it('collects typing user ids from subscribe events', async () => { + const adapter = new MockAdapter(); + let emit!: (userId: string) => void; + vi.spyOn(adapter, 'subscribe').mockImplementation((_t, cb) => { + emit = (userId) => cb({ kind: 'typing', userId }); + return () => {}; + }); + + const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => emit('pp_sofia')); + expect(result.current.typingUserIds).toEqual(['pp_sofia']); + }); + + it('unsubscribes on unmount', async () => { + const adapter = new MockAdapter(); + const off = vi.fn(); + vi.spyOn(adapter, 'subscribe').mockReturnValue(off); + + const { unmount, result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) }); + await waitFor(() => expect(result.current.loading).toBe(false)); + unmount(); + + expect(off).toHaveBeenCalled(); + }); + + it('keeps a live message that arrives before history resolves', async () => { + const adapter = new MockAdapter(); + let resolveHistory!: (msgs: Message[]) => void; + vi.spyOn(adapter, 'history').mockImplementation( + () => new Promise((res) => { resolveHistory = res; }), + ); + let emit!: (m: Message) => void; + vi.spyOn(adapter, 'subscribe').mockImplementation((_t, cb) => { + emit = (m) => cb({ kind: 'message', message: m }); + return () => {}; + }); + + const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) }); + + // A live message arrives while history() is still pending. + act(() => emit({ id: 'live_1', actorId: 'pp_sofia', text: 'ping before history', at: '2026-07-17T10:00:00.000Z' })); + + // History resolves afterwards with an older message. + await act(async () => { + resolveHistory([{ id: 'hist_1', actorId: 'pp_sofia', text: 'older', at: '2026-07-17T09:00:00.000Z' }]); + }); + + const texts = result.current.messages.map((m) => m.text); + expect(texts).toContain('older'); + expect(texts).toContain('ping before history'); // must NOT be clobbered by history load + }); + + it('clears a typing indicator after its TTL elapses', async () => { + vi.useFakeTimers(); + try { + const adapter = new MockAdapter(); + let emit!: (userId: string) => void; + vi.spyOn(adapter, 'subscribe').mockImplementation((_t, cb) => { + emit = (userId) => cb({ kind: 'typing', userId }); + return () => {}; + }); + + const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) }); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); // flush history microtask + + act(() => emit('pp_sofia')); + expect(result.current.typingUserIds).toEqual(['pp_sofia']); + + await act(async () => { await vi.advanceTimersByTimeAsync(3600); }); + expect(result.current.typingUserIds).toEqual([]); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/iios-messaging-ui/src/hooks/use-messages.ts b/packages/iios-messaging-ui/src/hooks/use-messages.ts new file mode 100644 index 0000000..d50954d --- /dev/null +++ b/packages/iios-messaging-ui/src/hooks/use-messages.ts @@ -0,0 +1,205 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useAdapter } from '../provider'; +import { isOwnMessage } from '../types'; +import type { Message, SendOpts } from '../types'; + +const TYPING_TTL_MS = 3500; + +export interface UiMessage extends Message { + mine: boolean; +} + +export interface MessagesState { + messages: UiMessage[]; + loading: boolean; + error: string | null; + send: (content: string, opts?: SendOpts) => Promise; + react: (messageId: string, emoji: string) => Promise; + typingUserIds: string[]; + seenIds: Set; + sendTyping: () => void; + canReact: boolean; + canUpload: boolean; +} + +let optimisticSeq = 0; + +export function useMessages(threadId: string | null): MessagesState { + const adapter = useAdapter(); + const [raw, setRaw] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [typing, setTyping] = useState>({}); + const [seenIds, setSeenIds] = useState>(new Set()); + + const currentActorId = adapter.currentActorId(); + const actorRef = useRef(currentActorId); + actorRef.current = currentActorId; + + // Load history, then subscribe. Reconciliation is by message id, so an echoed + // send never duplicates the optimistic row. + useEffect(() => { + if (!threadId) { + setRaw([]); + setLoading(false); + return; + } + let alive = true; + setLoading(true); + setRaw([]); + setError(null); + setSeenIds(new Set()); + setTyping({}); + + adapter + .history(threadId) + .then((h) => { + if (!alive) return; + // Merge, don't clobber: a live message can arrive via subscribe while this + // history fetch is still in flight. Blindly setting raw = h would drop it. + setRaw((live) => { + const histIds = new Set(h.map((m) => m.id)); + const extras = live.filter((m) => !histIds.has(m.id)); + return extras.length ? [...h, ...extras] : h; + }); + setError(null); + }) + .catch((e: unknown) => { + if (alive) setError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => { + if (alive) setLoading(false); + }); + + const off = adapter.subscribe(threadId, (e) => { + if (!alive) return; + switch (e.kind) { + case 'message': + setRaw((l) => (l.some((m) => m.id === e.message.id) ? l : [...l, e.message])); + break; + case 'typing': + if (e.userId !== actorRef.current) { + setTyping((t) => ({ ...t, [e.userId]: Date.now() + TYPING_TTL_MS })); + } + break; + case 'receipt': + // Only the OTHER side reading my message counts as "seen". + if (e.actorId !== actorRef.current) { + setSeenIds((s) => (s.has(e.messageId) ? s : new Set(s).add(e.messageId))); + } + break; + case 'reaction': + setRaw((l) => l.map((m) => (m.id === e.messageId ? { ...m, reactions: e.reactions } : m))); + break; + } + }); + + return () => { + alive = false; + off(); + }; + }, [adapter, threadId]); + + const messages: UiMessage[] = useMemo( + () => raw.map((m) => ({ ...m, mine: isOwnMessage(m, currentActorId) })), + [raw, currentActorId], + ); + + const send = useCallback( + async (content: string, opts?: SendOpts) => { + if (!threadId) return; + const tempId = `optimistic_${optimisticSeq++}`; + const optimistic: Message = { + id: tempId, + actorId: actorRef.current, + text: content, + at: new Date().toISOString(), + pending: true, + reactions: [], + ...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}), + ...(opts?.attachment ? { attachment: opts.attachment } : {}), + }; + setRaw((l) => [...l, optimistic]); + + try { + const saved = await adapter.send(threadId, content, opts); + setError(null); + // Replace the optimistic row with the server's. If the subscribe echo already + // added the real message, just drop the optimistic one. + setRaw((l) => { + const withoutTemp = l.filter((m) => m.id !== tempId); + return withoutTemp.some((m) => m.id === saved.id) ? withoutTemp : [...withoutTemp, saved]; + }); + } catch (e: unknown) { + setRaw((l) => l.filter((m) => m.id !== tempId)); + setError(e instanceof Error ? e.message : String(e)); + throw e; + } + }, + [adapter, threadId], + ); + + const react = useCallback( + async (messageId: string, emoji: string) => { + if (!threadId || !adapter.react) return; + await adapter.react(threadId, messageId, emoji); + }, + [adapter, threadId], + ); + + const sendTyping = useCallback(() => { + if (threadId) adapter.sendTyping(threadId); + }, [adapter, threadId]); + + // The newest acknowledged (non-pending) message id — what we report as read. + const lastReadableId = useMemo(() => { + for (let i = raw.length - 1; i >= 0; i--) { + if (!raw[i]!.pending) return raw[i]!.id; + } + return null; + }, [raw]); + + // Report my read of the newest message (drives the other side's "seen" tick). + // Keyed on the id, not the whole array, so reaction/optimistic churn doesn't re-fire it. + useEffect(() => { + if (!threadId || !lastReadableId) return; + void adapter.markRead(threadId, lastReadableId).catch(() => {}); + }, [adapter, threadId, lastReadableId]); + + const typingUserIds = useMemo(() => { + const now = Date.now(); + return Object.entries(typing) + .filter(([, exp]) => exp > now) + .map(([u]) => u); + }, [typing]); + + // Expire stale typing entries. Bumping `typing` to a new reference forces the + // memo above to recompute with a fresh `now`, dropping entries past their TTL. + // (A bump of unrelated state can't do this — the memo is keyed on `typing`, so it + // would return its cached array and the indicator would stick forever.) + useEffect(() => { + if (typingUserIds.length === 0) return; + const t = setTimeout(() => setTyping((p) => ({ ...p })), TYPING_TTL_MS); + return () => clearTimeout(t); + }, [typingUserIds.length, typing]); + + // Only my messages that the other side has read. + const seenMine = useMemo(() => { + const out = new Set(); + for (const id of seenIds) if (messages.some((m) => m.id === id && m.mine)) out.add(id); + return out; + }, [seenIds, messages]); + + return { + messages, + loading, + error, + send, + react, + typingUserIds, + seenIds: seenMine, + sendTyping, + canReact: typeof adapter.react === 'function', + canUpload: typeof adapter.upload === 'function', + }; +} From 778e98134c7f1ff174ac834e18574a35fc2f8367 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 01:28:42 +0530 Subject: [PATCH 09/26] feat: export messaging-ui public API and enforce transport boundary Co-Authored-By: Claude Opus 4.8 (1M context) --- .../iios-messaging-ui/src/boundary.test.ts | 41 +++++++++++++++++++ packages/iios-messaging-ui/src/index.ts | 25 ++++++++++- packages/iios-messaging-ui/tsup.config.ts | 2 +- scripts/check-import-boundary.mjs | 4 ++ 4 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 packages/iios-messaging-ui/src/boundary.test.ts diff --git a/packages/iios-messaging-ui/src/boundary.test.ts b/packages/iios-messaging-ui/src/boundary.test.ts new file mode 100644 index 0000000..4efa2de --- /dev/null +++ b/packages/iios-messaging-ui/src/boundary.test.ts @@ -0,0 +1,41 @@ +// @vitest-environment node +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; + +// This package is ESM ("type": "module") — __dirname does not exist here. +const SRC = fileURLToPath(new URL('.', import.meta.url)); +const ADAPTERS = join(SRC, 'adapters'); + +function tsFilesIn(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) out.push(...tsFilesIn(full)); + else if (/\.tsx?$/.test(full)) out.push(full); + } + return out; +} + +// The whole premise of this package: core renders, adapters transport. If core ever +// imports a transport, an app on a different backend pays for socket code it cannot +// use — which is exactly how iios-message-web welded itself to MessageSocket. +describe('transport boundary', () => { + const coreFiles = tsFilesIn(SRC).filter((f) => !f.startsWith(ADAPTERS) && !/\.test\.tsx?$/.test(f)); + + it('has core files to check', () => { + expect(coreFiles.length).toBeGreaterThan(0); + }); + + it('core never imports a transport package', () => { + const offenders: string[] = []; + for (const file of coreFiles) { + const content = readFileSync(file, 'utf8'); + if (/@insignia\/iios-kernel-client|socket\.io|@abe-kap\/appshell-sdk/.test(content)) { + offenders.push(file.replace(SRC, 'src')); + } + } + expect(offenders).toEqual([]); + }); +}); diff --git a/packages/iios-messaging-ui/src/index.ts b/packages/iios-messaging-ui/src/index.ts index cb0ff5c..802d60d 100644 --- a/packages/iios-messaging-ui/src/index.ts +++ b/packages/iios-messaging-ui/src/index.ts @@ -1 +1,24 @@ -export {}; +// Public API. Components land in Plan 2; adapters/kernel in Plan 3. +// +// `runAdapterConformance` is deliberately NOT exported here — it imports vitest and +// ships from the './conformance' subpath so consumers never pull a test runner into +// their production bundle. +export { MessagingProvider, useAdapter } from './provider'; +export { useConversations } from './hooks/use-conversations'; +export { useMessages } from './hooks/use-messages'; +export { isOwnMessage } from './types'; + +export type { MessagingAdapter } from './adapter'; +export type { ConversationsState } from './hooks/use-conversations'; +export type { MessagesState, UiMessage } from './hooks/use-messages'; +export type { + Attachment, + Conversation, + Membership, + Message, + MessageEvent, + Person, + Reaction, + SendOpts, + Unsubscribe, +} from './types'; diff --git a/packages/iios-messaging-ui/tsup.config.ts b/packages/iios-messaging-ui/tsup.config.ts index b444403..3baff55 100644 --- a/packages/iios-messaging-ui/tsup.config.ts +++ b/packages/iios-messaging-ui/tsup.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ // entry, never reachable from `index`, because it imports vitest, and bundling // that into the main barrel would drag a test runner into every consumer's // production build. - entry: ['src/index.ts'], + entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/conformance.ts'], format: ['esm'], dts: true, clean: true, diff --git a/scripts/check-import-boundary.mjs b/scripts/check-import-boundary.mjs index 8294bc1..bfde152 100644 --- a/scripts/check-import-boundary.mjs +++ b/scripts/check-import-boundary.mjs @@ -20,6 +20,10 @@ const ALLOWED = { '@insignia/iios-adapter-sdk': ['@insignia/iios-contracts'], '@insignia/iios-service': ['@insignia/iios-contracts', '@insignia/iios-testkit', '@insignia/iios-adapter-sdk'], '@insignia/iios-message-web': ['@insignia/iios-contracts', '@insignia/iios-kernel-client'], + // Core is transport-free; only ./adapters/kernel may import the kernel client. + // This map is package-level and cannot express that subpath rule — the finer + // constraint is enforced by packages/iios-messaging-ui/src/boundary.test.ts. + '@insignia/iios-messaging-ui': ['@insignia/iios-kernel-client'], '@insignia/iios-inbox-web': ['@insignia/iios-contracts', '@insignia/iios-kernel-client'], '@insignia/iios-community-web': ['@insignia/iios-contracts', '@insignia/iios-kernel-client'], '@insignia/iios-ai-web': ['@insignia/iios-contracts', '@insignia/iios-kernel-client'], From 191c9748c58e9dd0b8bf441cd63554906b9e02d8 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 01:42:54 +0530 Subject: [PATCH 10/26] docs: messaging-ui foundation implementation plan (plan 1 of 3) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-17-messaging-ui-foundation.md | 1547 +++++++++++++++++ 1 file changed, 1547 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-17-messaging-ui-foundation.md diff --git a/docs/superpowers/plans/2026-07-17-messaging-ui-foundation.md b/docs/superpowers/plans/2026-07-17-messaging-ui-foundation.md new file mode 100644 index 0000000..5c14ff3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-messaging-ui-foundation.md @@ -0,0 +1,1547 @@ +# Messaging UI SDK — Foundation (Plan 1 of 3) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the transport-free core of `@insignia/iios-messaging-ui` — types, the `MessagingAdapter` contract, a shared conformance suite, a mock adapter, and the provider + hooks — proving the seam works with zero network. + +**Architecture:** The core knows nothing about any transport. Hosts inject a `MessagingAdapter`; the provider holds it in context; hooks (`useConversations`, `useMessages`) call it. `iios-kernel-client` is reachable only from the `./adapters/kernel` subpath (Plan 3), never from core — enforced by a test, not convention. A conformance suite defines "a correct adapter" once, so the mock, kernel, and CRM adapters are all verified against one definition. + +**Tech Stack:** TypeScript, React 19 (peer), tsup (ESM), vitest 3 + jsdom + @testing-library/react, pnpm workspaces. + +**Spec:** `lynkeduppro-crm/docs/superpowers/specs/2026-07-17-messaging-sdk-design.md` + +**Scope of this plan:** Package scaffold, test infra, types, adapter contract + conformance suite, mock adapter, provider, hooks, boundary enforcement. **Out of scope:** UI components and CSS (Plan 2); kernel adapter, CRM adapter, attachments, and the CRM migration (Plan 3). + +--- + +## Context an engineer needs + +**Read these first:** +- `packages/iios-message-web/` — the existing headless package. **This is the anti-pattern**, not the model: 109 lines, narrowed `send` API, no consumers. We are deliberately building the layer it refused to build. +- `lynkeduppro-crm/src/lib/messenger-api.ts` — the source of the adapter contract. Its `MessengerData`/`ThreadData` interfaces already survived two implementations (live + mock). +- `scripts/check-import-boundary.mjs` — the repo's dependency law. + +**Repo facts that will bite you:** +- Root `vitest.config.ts` includes only `packages/**/src/**/*.{test,spec}.ts` — **`.tsx` is not matched**, and its `globalSetup` provisions a Postgres DB on port 5434 for every run. This package therefore gets its **own** `vitest.config.ts` with `environment: 'jsdom'` and **no** `globalSetup`. Never add a DB dependency to a pure-UI package. +- `tsconfig.base.json` sets `"module": "commonjs"` — every web package overrides this to `ESNext` + `moduleResolution: bundler` + `jsx: react-jsx`. Copy that override; don't fight the base. +- Packages are ESM-only (`"type": "module"`). + +**The bug we are fixing (do not port it):** the CRM infers the current actor id by scanning for a message you sent (`messenger-api.ts:200-203`). Until you have sent in a thread, `myActorId` is `null`, so the REST poll fallback computes `mine: false` for **every** message. Root cause: the kernel's receipt event carries no `threadId`. Our fix is `adapter.currentActorId()` — identity is the adapter's job, stated explicitly. Task 8 has the regression test. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `packages/iios-messaging-ui/package.json` | Package manifest, exports map, scripts | +| `packages/iios-messaging-ui/tsup.config.ts` | ESM build, 3 entries (core, adapters/mock, adapters/kernel) | +| `packages/iios-messaging-ui/tsconfig.json` | ESNext + jsx override over base | +| `packages/iios-messaging-ui/vitest.config.ts` | jsdom, includes `.tsx`, **no** globalSetup | +| `src/types.ts` | Domain types only. Zero imports. | +| `src/adapter.ts` | The `MessagingAdapter` interface | +| `src/conformance.ts` | Shared suite defining a correct adapter (shipped, not test-only) | +| `src/conformance.test.ts` | Runs the suite against the mock adapter | +| `src/adapters/mock.ts` | In-memory adapter for demos/tests | +| `src/provider.tsx` | `MessagingProvider` + `useAdapter` | +| `src/hooks/use-conversations.ts` | Conversation list state | +| `src/hooks/use-messages.ts` | Thread state, optimistic send, subscribe reconciliation | +| `src/index.ts` | Public barrel | +| `src/boundary.test.ts` | Asserts core never imports a transport | + +Files split by responsibility, not layer. `conformance.ts` ships in `src/` (not a test file) because Plan 3's adapters import it to verify themselves. + +--- + +### Task 1: Package scaffold + test infrastructure + +**Files:** +- Create: `packages/iios-messaging-ui/package.json` +- Create: `packages/iios-messaging-ui/tsconfig.json` +- Create: `packages/iios-messaging-ui/tsup.config.ts` +- Create: `packages/iios-messaging-ui/vitest.config.ts` +- Create: `packages/iios-messaging-ui/src/index.ts` +- Test: `packages/iios-messaging-ui/src/smoke.test.tsx` + +- [ ] **Step 1: Create the package manifest** + +Create `packages/iios-messaging-ui/package.json`: + +```json +{ + "name": "@insignia/iios-messaging-ui", + "version": "0.1.0", + "type": "module", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./adapters/mock": { + "types": "./dist/adapters/mock.d.ts", + "import": "./dist/adapters/mock.js" + }, + "./conformance": { + "types": "./dist/conformance.d.ts", + "import": "./dist/conformance.js" + }, + "./styles.css": "./dist/styles.css" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "peerDependencies": { + "react": ">=18" + }, + "devDependencies": { + "@testing-library/dom": "^10.4.0", + "@testing-library/react": "^16.1.0", + "@types/react": "^19.0.0", + "jsdom": "^26.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tsup": "^8.3.5", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + }, + "publishConfig": { + "registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/" + } +} +``` + +Note: **no `dependencies`.** The core must stay transport-free. `./adapters/kernel` and its `@insignia/iios-kernel-client` dependency arrive in Plan 3. `./styles.css` is declared now but produced in Plan 2. + +- [ ] **Step 2: Create tsconfig** + +Create `packages/iios-messaging-ui/tsconfig.json`: + +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "jsx": "react-jsx", + "types": ["react"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] +} +``` + +- [ ] **Step 3: Create the build config** + +Create `packages/iios-messaging-ui/tsup.config.ts`: + +```ts +import { defineConfig } from 'tsup'; + +export default defineConfig({ + // `conformance` is its own entry, never reachable from `index`. It imports vitest, + // and bundling that into the main barrel would drag a test runner into every + // consumer's production build. + entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/conformance.ts'], + format: ['esm'], + dts: true, + clean: true, + external: ['react', 'react-dom', 'vitest'], +}); +``` + +- [ ] **Step 4: Create the package's own vitest config** + +Create `packages/iios-messaging-ui/vitest.config.ts`: + +```ts +import { defineConfig } from 'vitest/config'; + +// This package owns its vitest config on purpose. The ROOT config includes only +// `.ts` (never `.tsx`) and provisions a Postgres DB via globalSetup for every run — +// a pure-UI package must not drag a database along, and its tests are .tsx. +export default defineConfig({ + test: { + environment: 'jsdom', + include: ['src/**/*.{test,spec}.{ts,tsx}'], + globals: false, + }, +}); +``` + +- [ ] **Step 5: Create a placeholder barrel** + +Create `packages/iios-messaging-ui/src/index.ts`: + +```ts +export {}; +``` + +- [ ] **Step 6: Write the failing smoke test** + +Create `packages/iios-messaging-ui/src/smoke.test.tsx`: + +```tsx +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +describe('test infrastructure', () => { + it('renders React components in jsdom', () => { + render(
messaging-ui
); + expect(screen.getByText('messaging-ui')).toBeDefined(); + }); +}); +``` + +- [ ] **Step 7: Install dependencies** + +Run from the repo root: + +```bash +pnpm install +``` + +Expected: pnpm links the new workspace package; `@testing-library/react`, `jsdom`, `react-dom` resolve. + +- [ ] **Step 8: Run the test to verify it passes** + +Run: `pnpm --filter @insignia/iios-messaging-ui test` +Expected: PASS, 1 test. If it fails with "document is not defined", `environment: 'jsdom'` did not take effect — check you created `vitest.config.ts` in the package dir, not the root. + +- [ ] **Step 9: Verify typecheck and build** + +Run: `pnpm --filter @insignia/iios-messaging-ui typecheck && pnpm --filter @insignia/iios-messaging-ui build` +Expected: no type errors; `dist/index.js` + `dist/index.d.ts` produced. + +- [ ] **Step 10: Commit** + +```bash +git add packages/iios-messaging-ui +git commit -m "chore: scaffold @insignia/iios-messaging-ui with jsdom test setup" +``` + +--- + +### Task 2: Domain types + +**Files:** +- Create: `packages/iios-messaging-ui/src/types.ts` +- Test: `packages/iios-messaging-ui/src/types.test.ts` + +These are the SDK's own types — deliberately **not** re-exported from `iios-kernel-client`, because core must not depend on it. Adapters map kernel/CRM DTOs onto these. Shape is taken from the CRM's `UiConversation`/`UiMessage`/`UiPerson`, which are already proven. + +- [ ] **Step 1: Write the failing test** + +Create `packages/iios-messaging-ui/src/types.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { isOwnMessage } from './types'; +import type { Message } from './types'; + +const base: Message = { + id: 'm1', + actorId: 'actor_a', + text: 'hello', + at: '2026-07-17T10:00:00.000Z', +}; + +describe('isOwnMessage', () => { + it('is true when the message actor matches the current actor', () => { + expect(isOwnMessage(base, 'actor_a')).toBe(true); + }); + + it('is false when the actors differ', () => { + expect(isOwnMessage(base, 'actor_b')).toBe(false); + }); + + it('is false when the current actor is unknown', () => { + expect(isOwnMessage(base, null)).toBe(false); + }); + + it('is false when the message has no actor', () => { + expect(isOwnMessage({ ...base, actorId: null }, 'actor_a')).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @insignia/iios-messaging-ui test` +Expected: FAIL — "Failed to resolve import ./types" or "isOwnMessage is not a function". + +- [ ] **Step 3: Write the implementation** + +Create `packages/iios-messaging-ui/src/types.ts`: + +```ts +// Domain types for the messaging UI. Zero imports on purpose: this file must never +// reach for a transport package. Adapters map their own DTOs onto these. + +export type Membership = 'dm' | 'group'; + +export interface Person { + id: string; + name: string; + kind: 'staff' | 'customer'; +} + +export interface Conversation { + threadId: string; + title: string; + subject: string | null; + membership: Membership | null; + participants: string[]; + unread: number; + lastMessage?: string; + lastAt?: string; +} + +export interface Reaction { + emoji: string; + count: number; + mine: boolean; +} + +export interface Attachment { + url: string; + mime: string; + name: string; +} + +export interface Message { + id: string; + actorId: string | null; + text: string; + at: string; + parentInteractionId?: string | null; + reactions?: Reaction[]; + attachment?: Attachment; + /** True only when the transport is optimistic-local and not yet acknowledged. */ + pending?: boolean; +} + +export interface SendOpts { + parentInteractionId?: string; + attachment?: Attachment; +} + +export type MessageEvent = + | { kind: 'message'; message: Message } + | { kind: 'typing'; userId: string } + | { kind: 'receipt'; messageId: string; actorId: string } + | { kind: 'reaction'; messageId: string; reactions: Reaction[] }; + +export type Unsubscribe = () => void; + +/** Ownership is a pure function of explicit identity — never inferred from history. + * See the spec: inferring it is the bug this SDK exists partly to kill. */ +export function isOwnMessage(message: Message, currentActorId: string | null): boolean { + return currentActorId !== null && message.actorId !== null && message.actorId === currentActorId; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @insignia/iios-messaging-ui test` +Expected: PASS, 5 tests (4 new + smoke). + +- [ ] **Step 5: Commit** + +```bash +git add packages/iios-messaging-ui/src/types.ts packages/iios-messaging-ui/src/types.test.ts +git commit -m "feat: add messaging-ui domain types" +``` + +--- + +### Task 3: The MessagingAdapter contract + +**Files:** +- Create: `packages/iios-messaging-ui/src/adapter.ts` + +An interface has no runtime behaviour to test directly; Task 4's conformance suite is its test. This task is type-only, so it is verified by `typecheck`. + +- [ ] **Step 1: Write the interface** + +Create `packages/iios-messaging-ui/src/adapter.ts`: + +```ts +import type { + Attachment, + Conversation, + Membership, + Message, + MessageEvent, + SendOpts, + Unsubscribe, +} from './types'; + +/** + * The one seam of this SDK. Hosts implement this; the SDK renders it. + * + * Lifted from lynkeduppro-crm's MessengerData/ThreadData, which already survived + * two implementations (live data-door + mock) — the minimum real evidence that a + * seam is genuine rather than imagined. + * + * Optional methods degrade gracefully: the UI hides the reaction picker when + * `react` is absent, and the attach button when `upload` is absent. That is how one + * component set serves both a full CRM messenger and a stripped-down widget with no + * `mode` prop. + */ +export interface MessagingAdapter { + listConversations(): Promise; + + openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }>; + + history(threadId: string): Promise; + + send(threadId: string, content: string, opts?: SendOpts): Promise; + + /** Returns an unsubscribe fn. Implementations MUST be idempotent on repeat unsubscribe. */ + subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe; + + sendTyping(threadId: string): void; + + markRead(threadId: string, messageId: string): Promise; + + /** + * The current user's actor id, or null if not yet known. + * + * MUST NOT be inferred from message history. The CRM's bug was exactly that: + * scanning for a sent message meant every message read as not-yours until you + * had spoken. Adapters derive this from auth/session. + */ + currentActorId(): string | null; + + /** Absent => the UI hides reactions entirely. */ + react?(threadId: string, messageId: string, emoji: string): Promise; + + /** Absent => the UI hides attachments. Storage/auth/limits are the host's concern. */ + upload?(file: File): Promise; + + /** Absent => treated as always connected (e.g. a pure-REST adapter). */ + isConnected?(): boolean; +} +``` + +- [ ] **Step 2: Verify it typechecks** + +Run: `pnpm --filter @insignia/iios-messaging-ui typecheck` +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add packages/iios-messaging-ui/src/adapter.ts +git commit -m "feat: define MessagingAdapter contract" +``` + +--- + +### Task 4: Adapter conformance suite + +**Files:** +- Create: `packages/iios-messaging-ui/src/conformance.ts` + +This ships in `src/` (not as a `.test.ts`) because Plan 3's kernel and CRM adapters import it to verify themselves. One definition of "correct adapter", three implementations checked against it. + +- [ ] **Step 1: Write the suite** + +Create `packages/iios-messaging-ui/src/conformance.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import type { MessagingAdapter } from './adapter'; +import type { MessageEvent } from './types'; + +export interface ConformanceOptions { + /** Build a fresh, isolated adapter per test. */ + makeAdapter: () => Promise | MessagingAdapter; + /** A thread id that exists in the fixture. */ + seededThreadId: string; + /** Participant ids openThread can legally be called with. */ + openWith: string[]; +} + +/** + * The definition of a correct MessagingAdapter. Every adapter runs this. + * + * Imports vitest, so it ships from the './conformance' subpath ONLY and is never + * reachable from the main barrel. Consumers supply their own vitest. + * + * Usage from another package: + * import { runAdapterConformance } from '@insignia/iios-messaging-ui/conformance'; + * runAdapterConformance({ makeAdapter: () => new MockAdapter(), seededThreadId: 'th_1', openWith: ['pp_a'] }); + */ +export function runAdapterConformance(opts: ConformanceOptions): void { + const make = async () => await opts.makeAdapter(); + + describe('MessagingAdapter conformance', () => { + it('lists conversations', async () => { + const a = await make(); + const list = await a.listConversations(); + expect(Array.isArray(list)).toBe(true); + }); + + it('returns history for a seeded thread', async () => { + const a = await make(); + const msgs = await a.history(opts.seededThreadId); + expect(Array.isArray(msgs)).toBe(true); + }); + + it('send resolves with a message carrying the sent text and a stable id', async () => { + const a = await make(); + const m = await a.send(opts.seededThreadId, 'conformance hello'); + expect(m.text).toBe('conformance hello'); + expect(typeof m.id).toBe('string'); + expect(m.id.length).toBeGreaterThan(0); + }); + + it('a sent message is attributed to the current actor', async () => { + const a = await make(); + const m = await a.send(opts.seededThreadId, 'whose is this'); + expect(m.actorId).toBe(a.currentActorId()); + }); + + it('currentActorId is known BEFORE any message is sent', async () => { + // The bug this SDK exists to kill: identity must come from auth, never be + // inferred from history. A fresh adapter already knows who you are. + const a = await make(); + expect(a.currentActorId()).not.toBeNull(); + }); + + it('a sent message appears in history', async () => { + const a = await make(); + await a.send(opts.seededThreadId, 'persist me'); + const msgs = await a.history(opts.seededThreadId); + expect(msgs.some((m) => m.text === 'persist me')).toBe(true); + }); + + it('subscribe delivers a message event on send', async () => { + const a = await make(); + const seen: MessageEvent[] = []; + const off = a.subscribe(opts.seededThreadId, (e) => seen.push(e)); + await a.send(opts.seededThreadId, 'live one'); + off(); + const msgs = seen.filter((e) => e.kind === 'message'); + expect(msgs.length).toBeGreaterThan(0); + }); + + it('unsubscribe stops delivery', async () => { + const a = await make(); + const seen: MessageEvent[] = []; + const off = a.subscribe(opts.seededThreadId, (e) => seen.push(e)); + off(); + await a.send(opts.seededThreadId, 'should not be heard'); + expect(seen).toHaveLength(0); + }); + + it('unsubscribe is idempotent', async () => { + const a = await make(); + const off = a.subscribe(opts.seededThreadId, () => {}); + off(); + expect(() => off()).not.toThrow(); + }); + + it('openThread returns a thread id', async () => { + const a = await make(); + const { threadId } = await a.openThread({ participantIds: opts.openWith }); + expect(typeof threadId).toBe('string'); + expect(threadId.length).toBeGreaterThan(0); + }); + + it('markRead resolves', async () => { + const a = await make(); + const m = await a.send(opts.seededThreadId, 'read me'); + await expect(a.markRead(opts.seededThreadId, m.id)).resolves.toBeUndefined(); + }); + + it('sendTyping does not throw', async () => { + const a = await make(); + expect(() => a.sendTyping(opts.seededThreadId)).not.toThrow(); + }); + }); +} +``` + +- [ ] **Step 2: Verify it typechecks** + +Run: `pnpm --filter @insignia/iios-messaging-ui typecheck` +Expected: no errors. (The suite has no adapter to run against yet — that is Task 5.) + +- [ ] **Step 3: Commit** + +```bash +git add packages/iios-messaging-ui/src/conformance.ts +git commit -m "feat: add adapter conformance suite" +``` + +--- + +### Task 5: Mock adapter + +**Files:** +- Create: `packages/iios-messaging-ui/src/adapters/mock.ts` +- Test: `packages/iios-messaging-ui/src/conformance.test.ts` + +Ported from the CRM's `MOCK_STORE`, with one deliberate change: **the store is per-instance, not module-level.** The CRM's module-level `Map` leaks state between tests. Fixtures match the CRM's so demo mode looks identical after migration. + +- [ ] **Step 1: Write the failing test** + +Create `packages/iios-messaging-ui/src/conformance.test.ts`: + +```ts +import { runAdapterConformance } from './conformance'; +import { MockAdapter } from './adapters/mock'; + +runAdapterConformance({ + makeAdapter: () => new MockAdapter(), + seededThreadId: 'th_mock_1', + openWith: ['pp_sofia'], +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @insignia/iios-messaging-ui test` +Expected: FAIL — "Failed to resolve import ./adapters/mock". + +- [ ] **Step 3: Write the implementation** + +Create `packages/iios-messaging-ui/src/adapters/mock.ts`: + +```ts +import type { MessagingAdapter } from '../adapter'; +import type { + Attachment, + Conversation, + Membership, + Message, + MessageEvent, + Person, + SendOpts, + Unsubscribe, +} from '../types'; + +const ME = 'me'; + +export const MOCK_PEOPLE: Person[] = [ + { id: 'pp_sofia', name: 'Sofia Ramirez', kind: 'staff' }, + { id: 'pp_dan', name: 'Dan Whitaker', kind: 'staff' }, + { id: 'pp_priya', name: 'Priya Nair', kind: 'staff' }, + { id: 'cust_acme', name: 'Acme Roofing (Client)', kind: 'customer' }, + { id: 'cust_globex', name: 'Globex Homes (Client)', kind: 'customer' }, +]; + +interface MockThread { + threadId: string; + membership: Membership; + subject: string | null; + participants: string[]; + messages: Message[]; +} + +const nameById = new Map(MOCK_PEOPLE.map((p) => [p.id, p.name])); + +/** + * In-memory adapter for demos and tests. State is PER INSTANCE — the CRM's version + * used a module-level Map, which leaks between tests. Each `new MockAdapter()` is + * fully isolated. + */ +export class MockAdapter implements MessagingAdapter { + private seq = 100; + private threads = new Map(); + private listeners = new Map void>>(); + + constructor(private readonly now: () => string = () => new Date().toISOString()) { + this.threads.set('th_mock_1', { + threadId: 'th_mock_1', + membership: 'dm', + subject: null, + participants: [ME, 'pp_sofia'], + messages: [ + { + id: 'm1', + actorId: 'pp_sofia', + text: 'Can you review the Henderson estimate?', + at: this.now(), + reactions: [], + }, + ], + }); + this.threads.set('th_mock_2', { + threadId: 'th_mock_2', + membership: 'group', + subject: 'Storm response — East side', + participants: [ME, 'pp_dan', 'pp_priya'], + messages: [ + { id: 'm2', actorId: 'pp_dan', text: 'Crew is rolling out at 7.', at: this.now(), reactions: [] }, + ], + }); + } + + currentActorId(): string { + return ME; + } + + async listConversations(): Promise { + return [...this.threads.values()].map((t) => { + const last = t.messages[t.messages.length - 1]; + const others = t.participants.filter((p) => p !== ME); + return { + threadId: t.threadId, + title: t.subject || others.map((id) => nameById.get(id) ?? id).join(', ') || 'Conversation', + subject: t.subject, + membership: t.membership, + participants: t.participants, + unread: 0, + ...(last ? { lastMessage: last.text, lastAt: last.at } : {}), + }; + }); + } + + async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> { + const membership = p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group'); + const threadId = `th_mock_${this.seq++}`; + this.threads.set(threadId, { + threadId, + membership, + subject: p.subject ?? null, + participants: [ME, ...p.participantIds], + messages: [], + }); + return { threadId }; + } + + async history(threadId: string): Promise { + return this.threads.get(threadId)?.messages ?? []; + } + + async send(threadId: string, content: string, opts?: SendOpts): Promise { + const t = this.threads.get(threadId); + if (!t) throw new Error(`Unknown thread: ${threadId}`); + const message: Message = { + id: `m_${this.seq++}`, + actorId: ME, + text: content, + at: this.now(), + reactions: [], + ...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}), + ...(opts?.attachment ? { attachment: opts.attachment } : {}), + }; + t.messages = [...t.messages, message]; + this.emit(threadId, { kind: 'message', message }); + return message; + } + + async react(threadId: string, messageId: string, emoji: string): Promise { + const t = this.threads.get(threadId); + if (!t) return; + let next: Message | undefined; + t.messages = t.messages.map((m) => { + if (m.id !== messageId) return m; + const existing = (m.reactions ?? []).find((r) => r.emoji === emoji); + const reactions = existing + ? (m.reactions ?? []).filter((r) => r.emoji !== emoji) + : [...(m.reactions ?? []), { emoji, count: 1, mine: true }]; + next = { ...m, reactions }; + return next; + }); + if (next) this.emit(threadId, { kind: 'reaction', messageId, reactions: next.reactions ?? [] }); + } + + async upload(file: File): Promise { + return { url: `mock://uploads/${file.name}`, mime: file.type, name: file.name }; + } + + subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe { + if (!this.listeners.has(threadId)) this.listeners.set(threadId, new Set()); + this.listeners.get(threadId)!.add(cb); + return () => { + this.listeners.get(threadId)?.delete(cb); + }; + } + + sendTyping(): void { + // No-op: nobody is typing back in a mock. + } + + async markRead(): Promise { + // No-op: the mock has no second party to report a read. + } + + isConnected(): boolean { + return true; + } + + private emit(threadId: string, e: MessageEvent): void { + this.listeners.get(threadId)?.forEach((cb) => cb(e)); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @insignia/iios-messaging-ui test` +Expected: PASS — 12 conformance tests green. + +- [ ] **Step 5: Commit** + +```bash +git add packages/iios-messaging-ui/src/adapters/mock.ts packages/iios-messaging-ui/src/conformance.test.ts +git commit -m "feat: add MockAdapter passing the conformance suite" +``` + +--- + +### Task 6: MessagingProvider + +**Files:** +- Create: `packages/iios-messaging-ui/src/provider.tsx` +- Test: `packages/iios-messaging-ui/src/provider.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `packages/iios-messaging-ui/src/provider.test.tsx`: + +```tsx +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MessagingProvider, useAdapter } from './provider'; +import { MockAdapter } from './adapters/mock'; + +function ShowActor() { + const adapter = useAdapter(); + return {adapter.currentActorId()}; +} + +describe('MessagingProvider', () => { + it('supplies the injected adapter to descendants', () => { + render( + + + , + ); + expect(screen.getByText('me')).toBeDefined(); + }); + + it('throws a helpful error when a hook is used outside the provider', () => { + // React logs the error boundary trace; that noise is expected. + expect(() => render()).toThrow(/useAdapter must be used within a /); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @insignia/iios-messaging-ui test` +Expected: FAIL — "Failed to resolve import ./provider". + +- [ ] **Step 3: Write the implementation** + +Create `packages/iios-messaging-ui/src/provider.tsx`: + +```tsx +import { createContext, useContext, type ReactNode } from 'react'; +import type { MessagingAdapter } from './adapter'; + +const AdapterContext = createContext(null); + +export function MessagingProvider({ + adapter, + children, +}: { + adapter: MessagingAdapter; + children: ReactNode; +}) { + return {children}; +} + +/** Access the host-injected adapter. Throws outside a provider — a missing provider is + * a wiring bug, and failing loudly beats a confusing null-deref three layers down. */ +export function useAdapter(): MessagingAdapter { + const adapter = useContext(AdapterContext); + if (!adapter) throw new Error('useAdapter must be used within a '); + return adapter; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @insignia/iios-messaging-ui test` +Expected: PASS, 2 new tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/iios-messaging-ui/src/provider.tsx packages/iios-messaging-ui/src/provider.test.tsx +git commit -m "feat: add MessagingProvider and useAdapter" +``` + +--- + +### Task 7: useConversations + +**Files:** +- Create: `packages/iios-messaging-ui/src/hooks/use-conversations.ts` +- Test: `packages/iios-messaging-ui/src/hooks/use-conversations.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `packages/iios-messaging-ui/src/hooks/use-conversations.test.tsx`: + +```tsx +import { describe, it, expect, vi } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { MessagingProvider } from '../provider'; +import { MockAdapter } from '../adapters/mock'; +import { useConversations } from './use-conversations'; +import type { MessagingAdapter } from '../adapter'; + +const wrap = (adapter: MessagingAdapter) => + function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; + +describe('useConversations', () => { + it('starts loading, then resolves the adapter list', async () => { + const { result } = renderHook(() => useConversations(), { wrapper: wrap(new MockAdapter()) }); + expect(result.current.loading).toBe(true); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.conversations).toHaveLength(2); + expect(result.current.conversations[0]!.threadId).toBe('th_mock_1'); + expect(result.current.error).toBeNull(); + }); + + it('surfaces adapter failure as error state and never throws', async () => { + const adapter = new MockAdapter(); + vi.spyOn(adapter, 'listConversations').mockRejectedValue(new Error('data door down')); + + const { result } = renderHook(() => useConversations(), { wrapper: wrap(adapter) }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBe('data door down'); + expect(result.current.conversations).toEqual([]); + }); + + it('refetch picks up newly opened threads', async () => { + const adapter = new MockAdapter(); + const { result } = renderHook(() => useConversations(), { wrapper: wrap(adapter) }); + await waitFor(() => expect(result.current.conversations).toHaveLength(2)); + + await act(async () => { + await adapter.openThread({ participantIds: ['pp_dan'] }); + }); + await act(async () => { + result.current.refetch(); + }); + + await waitFor(() => expect(result.current.conversations).toHaveLength(3)); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @insignia/iios-messaging-ui test` +Expected: FAIL — "Failed to resolve import ./use-conversations". + +- [ ] **Step 3: Write the implementation** + +Create `packages/iios-messaging-ui/src/hooks/use-conversations.ts`: + +```ts +import { useCallback, useEffect, useState } from 'react'; +import { useAdapter } from '../provider'; +import type { Conversation } from '../types'; + +export interface ConversationsState { + conversations: Conversation[]; + loading: boolean; + error: string | null; + refetch: () => void; +} + +export function useConversations(): ConversationsState { + const adapter = useAdapter(); + const [conversations, setConversations] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [nonce, setNonce] = useState(0); + + useEffect(() => { + let alive = true; + setLoading(true); + adapter + .listConversations() + .then((list) => { + if (!alive) return; + setConversations(list); + setError(null); + }) + .catch((e: unknown) => { + if (!alive) return; + setError(e instanceof Error ? e.message : String(e)); + setConversations([]); + }) + .finally(() => { + if (alive) setLoading(false); + }); + return () => { + alive = false; + }; + }, [adapter, nonce]); + + const refetch = useCallback(() => setNonce((n) => n + 1), []); + + return { conversations, loading, error, refetch }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @insignia/iios-messaging-ui test` +Expected: PASS, 3 new tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/iios-messaging-ui/src/hooks +git commit -m "feat: add useConversations hook" +``` + +--- + +### Task 8: useMessages + +**Files:** +- Create: `packages/iios-messaging-ui/src/hooks/use-messages.ts` +- Test: `packages/iios-messaging-ui/src/hooks/use-messages.test.tsx` + +The heart of the SDK. Three behaviours matter most: **ownership before you speak** (the CRM bug), **optimistic send with rollback**, and **event reconciliation by id** (no duplicates when the echo arrives). + +- [ ] **Step 1: Write the failing test** + +Create `packages/iios-messaging-ui/src/hooks/use-messages.test.tsx`: + +```tsx +import { describe, it, expect, vi } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { MessagingProvider } from '../provider'; +import { MockAdapter } from '../adapters/mock'; +import { useMessages } from './use-messages'; +import type { MessagingAdapter } from '../adapter'; + +const wrap = (adapter: MessagingAdapter) => + function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; + +describe('useMessages', () => { + it('loads history for the thread', async () => { + const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(new MockAdapter()) }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages[0]!.text).toBe('Can you review the Henderson estimate?'); + }); + + // REGRESSION: the CRM inferred actor identity by scanning for a sent message, so + // before you had spoken in a thread EVERY message rendered as not-yours. + it('marks ownership correctly before the user has sent anything', async () => { + const adapter = new MockAdapter(); + await adapter.send('th_mock_1', 'an earlier message of mine'); + + const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) }); + await waitFor(() => expect(result.current.messages).toHaveLength(2)); + + // Never sent anything via the hook — ownership still resolves from currentActorId(). + expect(result.current.messages[0]!.mine).toBe(false); // from pp_sofia + expect(result.current.messages[1]!.mine).toBe(true); // from me + }); + + it('appends an optimistic message immediately on send', async () => { + const adapter = new MockAdapter(); + let release!: () => void; + vi.spyOn(adapter, 'send').mockImplementation( + () => new Promise((res) => { release = () => res({ id: 'srv_1', actorId: 'me', text: 'hi', at: '2026-07-17T10:00:00.000Z' }); }), + ); + + const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => { void result.current.send('hi'); }); + + await waitFor(() => expect(result.current.messages).toHaveLength(2)); + expect(result.current.messages[1]!.pending).toBe(true); + expect(result.current.messages[1]!.mine).toBe(true); + + await act(async () => { release(); }); + await waitFor(() => expect(result.current.messages[1]!.pending).toBeFalsy()); + }); + + it('rolls back the optimistic message and reports error when send fails', async () => { + const adapter = new MockAdapter(); + vi.spyOn(adapter, 'send').mockRejectedValue(new Error('offline')); + + const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await expect(result.current.send('doomed')).rejects.toThrow('offline'); + }); + + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages.some((m) => m.text === 'doomed')).toBe(false); + expect(result.current.error).toBe('offline'); + }); + + it('does not duplicate a message when the transport echoes it back', async () => { + const adapter = new MockAdapter(); + const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + // MockAdapter.send emits a 'message' event AND resolves with the same message. + await act(async () => { await result.current.send('echo once'); }); + + expect(result.current.messages.filter((m) => m.text === 'echo once')).toHaveLength(1); + }); + + it('collects typing user ids from subscribe events', async () => { + const adapter = new MockAdapter(); + let emit!: (userId: string) => void; + vi.spyOn(adapter, 'subscribe').mockImplementation((_t, cb) => { + emit = (userId) => cb({ kind: 'typing', userId }); + return () => {}; + }); + + const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + act(() => emit('pp_sofia')); + expect(result.current.typingUserIds).toEqual(['pp_sofia']); + }); + + it('unsubscribes on unmount', async () => { + const adapter = new MockAdapter(); + const off = vi.fn(); + vi.spyOn(adapter, 'subscribe').mockReturnValue(off); + + const { unmount, result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) }); + await waitFor(() => expect(result.current.loading).toBe(false)); + unmount(); + + expect(off).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --filter @insignia/iios-messaging-ui test` +Expected: FAIL — "Failed to resolve import ./use-messages". + +- [ ] **Step 3: Write the implementation** + +Create `packages/iios-messaging-ui/src/hooks/use-messages.ts`: + +```ts +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useAdapter } from '../provider'; +import { isOwnMessage } from '../types'; +import type { Message, SendOpts } from '../types'; + +const TYPING_TTL_MS = 3500; + +export interface UiMessage extends Message { + mine: boolean; +} + +export interface MessagesState { + messages: UiMessage[]; + loading: boolean; + error: string | null; + send: (content: string, opts?: SendOpts) => Promise; + react: (messageId: string, emoji: string) => Promise; + typingUserIds: string[]; + seenIds: Set; + sendTyping: () => void; + canReact: boolean; + canUpload: boolean; +} + +let optimisticSeq = 0; + +export function useMessages(threadId: string | null): MessagesState { + const adapter = useAdapter(); + const [raw, setRaw] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [typing, setTyping] = useState>({}); + const [seenIds, setSeenIds] = useState>(new Set()); + const [, forceTick] = useState(0); + + const currentActorId = adapter.currentActorId(); + const actorRef = useRef(currentActorId); + actorRef.current = currentActorId; + + // Load history, then subscribe. Reconciliation is by message id, so an echoed + // send never duplicates the optimistic row. + useEffect(() => { + if (!threadId) { + setRaw([]); + setLoading(false); + return; + } + let alive = true; + setLoading(true); + setRaw([]); + setSeenIds(new Set()); + setTyping({}); + + adapter + .history(threadId) + .then((h) => { + if (alive) { + setRaw(h); + setError(null); + } + }) + .catch((e: unknown) => { + if (alive) setError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => { + if (alive) setLoading(false); + }); + + const off = adapter.subscribe(threadId, (e) => { + if (!alive) return; + switch (e.kind) { + case 'message': + setRaw((l) => (l.some((m) => m.id === e.message.id) ? l : [...l, e.message])); + break; + case 'typing': + if (e.userId !== actorRef.current) { + setTyping((t) => ({ ...t, [e.userId]: Date.now() + TYPING_TTL_MS })); + } + break; + case 'receipt': + // Only the OTHER side reading my message counts as "seen". + if (e.actorId !== actorRef.current) { + setSeenIds((s) => (s.has(e.messageId) ? s : new Set(s).add(e.messageId))); + } + break; + case 'reaction': + setRaw((l) => l.map((m) => (m.id === e.messageId ? { ...m, reactions: e.reactions } : m))); + break; + } + }); + + return () => { + alive = false; + off(); + }; + }, [adapter, threadId]); + + const messages: UiMessage[] = useMemo( + () => raw.map((m) => ({ ...m, mine: isOwnMessage(m, currentActorId) })), + [raw, currentActorId], + ); + + const send = useCallback( + async (content: string, opts?: SendOpts) => { + if (!threadId) return; + const tempId = `optimistic_${optimisticSeq++}`; + const optimistic: Message = { + id: tempId, + actorId: actorRef.current, + text: content, + at: new Date().toISOString(), + pending: true, + reactions: [], + ...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}), + ...(opts?.attachment ? { attachment: opts.attachment } : {}), + }; + setRaw((l) => [...l, optimistic]); + + try { + const saved = await adapter.send(threadId, content, opts); + setError(null); + // Replace the optimistic row with the server's. If the subscribe echo already + // added the real message, just drop the optimistic one. + setRaw((l) => { + const withoutTemp = l.filter((m) => m.id !== tempId); + return withoutTemp.some((m) => m.id === saved.id) ? withoutTemp : [...withoutTemp, saved]; + }); + } catch (e: unknown) { + setRaw((l) => l.filter((m) => m.id !== tempId)); + setError(e instanceof Error ? e.message : String(e)); + throw e; + } + }, + [adapter, threadId], + ); + + const react = useCallback( + async (messageId: string, emoji: string) => { + if (!threadId || !adapter.react) return; + await adapter.react(threadId, messageId, emoji); + }, + [adapter, threadId], + ); + + const sendTyping = useCallback(() => { + if (threadId) adapter.sendTyping(threadId); + }, [adapter, threadId]); + + // Report my read of the newest message (drives the other side's "seen" tick). + useEffect(() => { + if (!threadId || raw.length === 0) return; + const last = raw[raw.length - 1]!; + if (last.pending) return; + void adapter.markRead(threadId, last.id).catch(() => {}); + }, [adapter, threadId, raw]); + + const typingUserIds = useMemo(() => { + const now = Date.now(); + return Object.entries(typing) + .filter(([, exp]) => exp > now) + .map(([u]) => u); + }, [typing]); + + // Expire stale typing entries by re-evaluating after the TTL. + useEffect(() => { + if (typingUserIds.length === 0) return; + const t = setTimeout(() => forceTick((n) => n + 1), TYPING_TTL_MS); + return () => clearTimeout(t); + }, [typingUserIds.length, typing]); + + // Only my messages that the other side has read. + const seenMine = useMemo(() => { + const out = new Set(); + for (const id of seenIds) if (messages.some((m) => m.id === id && m.mine)) out.add(id); + return out; + }, [seenIds, messages]); + + return { + messages, + loading, + error, + send, + react, + typingUserIds, + seenIds: seenMine, + sendTyping, + canReact: typeof adapter.react === 'function', + canUpload: typeof adapter.upload === 'function', + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --filter @insignia/iios-messaging-ui test` +Expected: PASS, 7 new tests. + +- [ ] **Step 5: Commit** + +```bash +git add packages/iios-messaging-ui/src/hooks/use-messages.ts packages/iios-messaging-ui/src/hooks/use-messages.test.tsx +git commit -m "feat: add useMessages hook with explicit ownership and optimistic send" +``` + +--- + +### Task 9: Public barrel + transport-freedom enforcement + +**Files:** +- Modify: `packages/iios-messaging-ui/src/index.ts` +- Create: `packages/iios-messaging-ui/src/boundary.test.ts` +- Modify: `scripts/check-import-boundary.mjs:22` (add the new package to `ALLOWED`) + +The repo's boundary script is **package-level**, so it cannot express "core must not import kernel-client, but `adapters/kernel` may". That distinction is this SDK's core premise, so it gets its own test. + +- [ ] **Step 1: Write the failing test** + +Create `packages/iios-messaging-ui/src/boundary.test.ts`: + +```ts +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; + +// This package is ESM ("type": "module") — __dirname does not exist here. +const SRC = fileURLToPath(new URL('.', import.meta.url)); +const ADAPTERS = join(SRC, 'adapters'); + +function tsFilesIn(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) out.push(...tsFilesIn(full)); + else if (/\.tsx?$/.test(full)) out.push(full); + } + return out; +} + +// The whole premise of this package: core renders, adapters transport. If core ever +// imports a transport, an app on a different backend pays for socket code it cannot +// use — which is exactly how iios-message-web welded itself to MessageSocket. +describe('transport boundary', () => { + const coreFiles = tsFilesIn(SRC).filter((f) => !f.startsWith(ADAPTERS) && !/\.test\.tsx?$/.test(f)); + + it('has core files to check', () => { + expect(coreFiles.length).toBeGreaterThan(0); + }); + + it('core never imports a transport package', () => { + const offenders: string[] = []; + for (const file of coreFiles) { + const content = readFileSync(file, 'utf8'); + if (/@insignia\/iios-kernel-client|socket\.io|@abe-kap\/appshell-sdk/.test(content)) { + offenders.push(file.replace(SRC, 'src')); + } + } + expect(offenders).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it passes** + +Run: `pnpm --filter @insignia/iios-messaging-ui test` +Expected: PASS. (This test guards a property that already holds — it fails loudly in Plan 3 if someone imports the kernel from core.) + +- [ ] **Step 3: Write the public barrel** + +Replace `packages/iios-messaging-ui/src/index.ts`: + +```ts +// Public API. Components land in Plan 2; adapters/kernel in Plan 3. +// +// `runAdapterConformance` is deliberately NOT exported here — it imports vitest and +// ships from the './conformance' subpath so consumers never pull a test runner into +// their production bundle. +export { MessagingProvider, useAdapter } from './provider'; +export { useConversations } from './hooks/use-conversations'; +export { useMessages } from './hooks/use-messages'; +export { isOwnMessage } from './types'; + +export type { MessagingAdapter } from './adapter'; +export type { ConversationsState } from './hooks/use-conversations'; +export type { MessagesState, UiMessage } from './hooks/use-messages'; +export type { + Attachment, + Conversation, + Membership, + Message, + MessageEvent, + Person, + Reaction, + SendOpts, + Unsubscribe, +} from './types'; +``` + +- [ ] **Step 4: Register the package in the repo's import-boundary law** + +In `scripts/check-import-boundary.mjs`, add this entry to the `ALLOWED` map (after the `'@insignia/iios-message-web'` line): + +```js + // Core is transport-free; only ./adapters/kernel may import the kernel client. + // This map is package-level and cannot express that subpath rule — the finer + // constraint is enforced by packages/iios-messaging-ui/src/boundary.test.ts. + '@insignia/iios-messaging-ui': ['@insignia/iios-kernel-client'], +``` + +- [ ] **Step 5: Verify the boundary law still passes** + +Run: `pnpm boundary` +Expected: `✓ import-boundary: OK` + +- [ ] **Step 6: Verify the full package** + +Run: `pnpm --filter @insignia/iios-messaging-ui test && pnpm --filter @insignia/iios-messaging-ui typecheck && pnpm --filter @insignia/iios-messaging-ui build` +Expected: all tests PASS; no type errors; `dist/index.js`, `dist/index.d.ts`, `dist/adapters/mock.js`, `dist/conformance.js` produced. + +- [ ] **Step 6b: Verify the main entry does not drag in vitest** + +Run: `grep -c vitest packages/iios-messaging-ui/dist/index.js || echo "clean"` +Expected: `clean` (grep finds nothing and exits 1). If this prints a number, `conformance.ts` leaked into the main barrel — remove the re-export from `src/index.ts`. + +Run: `grep -rE "iios-kernel-client|socket\.io" packages/iios-messaging-ui/dist/index.js || echo "transport-free"` +Expected: `transport-free`. + +- [ ] **Step 7: Verify the root test suite is unaffected** + +Run: `pnpm test` +Expected: the root suite passes exactly as before. Its `include` matches only `.ts`, so this package's `.tsx` tests are not picked up there — intentional, since root provisions Postgres. The package's tests run via its own `test` script. + +- [ ] **Step 8: Commit** + +```bash +git add packages/iios-messaging-ui/src/index.ts packages/iios-messaging-ui/src/boundary.test.ts scripts/check-import-boundary.mjs +git commit -m "feat: export messaging-ui public API and enforce transport boundary" +``` + +--- + +## Done criteria + +- `pnpm --filter @insignia/iios-messaging-ui test` — all green (33 tests as shipped). +- `pnpm boundary` — OK. +- `pnpm test` at root — unchanged. +- The package builds to ESM with types and has **zero runtime dependencies**. +- A host can already drive messaging headlessly: `MessagingProvider` + `MockAdapter` + hooks. + +## Post-implementation corrections (2026-07-18) + +Executed via subagent-driven development on branch `feat/inbox-owner-interest`. Code review during Tasks 5 and 8 caught defects in the code blocks **above** — the shipped code diverges from the plan text in these ways, and Plan 3 should treat the committed source (not the Task 8 listing above) as authoritative: + +- **`useMessages` typing expiry** — the plan used `forceTick((n) => n + 1)` to expire stale typing entries. That is a no-op: the `typingUserIds` memo is keyed on `[typing]`, so an unrelated state bump returns the cached array and the indicator sticks forever. Shipped code uses `setTimeout(() => setTyping((p) => ({ ...p })), TYPING_TTL_MS)` (a new `typing` reference forces the memo to recompute with a fresh `now`). `forceTick` state removed entirely. +- **`useMessages` history race** — the plan's `.then((h) => setRaw(h))` clobbers live messages that arrive via `subscribe` while `history()` is still in flight (silent data loss on any async transport; the sync MockAdapter can't show it). Shipped code merges by id: `setRaw((live) => { const histIds = new Set(h.map(m => m.id)); const extras = live.filter(m => !histIds.has(m.id)); return extras.length ? [...h, ...extras] : h; })`. Guarded by the test "keeps a live message that arrives before history resolves" (verified to fail if the merge is reverted). +- **`useMessages` markRead** — re-keyed from `[adapter, threadId, raw]` (fired on every array churn, incl. reactions) to a derived `lastReadableId` (newest non-pending id), so it fires only when the read frontier actually moves. +- **`useMessages` error reset** — `setError(null)` added to the thread-switch reset block (plan left a stale error banner across thread switches). +- **`MockAdapter`** — `history()` and `listConversations().participants` now return copies (plan leaked the live internal arrays); constructor calls `now()` once for both seed messages. +- **`boundary.test.ts`** — carries a `// @vitest-environment node` directive on line 1. jsdom overrides the global `URL` constructor, which breaks `fileURLToPath(new URL('.', import.meta.url))`; a filesystem test must run in node. + +**Known limitation deferred to Plan 3 (issue I2):** `useMessages` reads `adapter.currentActorId()` per-render but nothing makes it reactive. If a real adapter's identity resolves from `null` to a value with no intervening React state change, ownership stays wrong until an incidental re-render — and an optimistic message stamped while identity is `null` renders as not-mine. The MockAdapter never has null identity, so this does not manifest in the foundation. Plan 3 must decide how the real adapter exposes identity reactively (e.g. an `onActorIdChange` subscription on the interface, or the host re-rendering the provider when auth resolves) rather than relying on incidental re-renders. + +## What Plan 2 adds + +Components (`ConversationList`, `ThreadView`, `Composer`, `MessageBubble`, `Messenger`), the SDK's own internal primitives (the CRM's `Avatar`/`Btn`/`Icon` cannot be imported — they live in `lynkeduppro-crm/src/components/dashboard/ui.tsx`), `styles.css` with the `--msg-*` token contract, and the `classNames` slot map. + +## What Plan 3 adds + +`./adapters/kernel` (wrapping `iios-kernel-client`, running the conformance suite), `CrmMessagingAdapter` in the CRM repo (data door + socket + 4s poll fallback, also running conformance), attachments end-to-end, and the CRM migration that deletes `messenger.tsx`. + +Both adapters verify themselves with `import { runAdapterConformance } from '@insignia/iios-messaging-ui/conformance'`. Plan 3 must add `@insignia/iios-kernel-client` to this package's `dependencies` and to the `tsup` `entry` + `external` lists — and `src/boundary.test.ts` will fail if that import reaches core rather than staying inside `src/adapters/`. + +**Strengthen the safety nets before writing the kernel adapter** (from the final foundation review — the current versions are adequate for the mock but too weak to catch a broken real adapter): + +- **Expand `runAdapterConformance`.** Today it only exercises the happy `message` path. It does NOT test the optional methods (`react`/`upload`/`isConnected`), **subscribe thread-isolation** (an adapter that leaks every thread's events to every subscriber would pass yet break the UI), or the `receipt`/`reaction`/`typing` event kinds. Since conformance IS the safety net for the real kernel adapter, add these cases first. +- **Convert `boundary.test.ts` from a denylist to an allowlist.** It currently greps core for three transport strings (`iios-kernel-client|socket.io|@abe-kap/appshell-sdk`); a core file importing a *different* transport (`ws`, `pusher`, raw WebSocket) would slip through, and the root `pnpm boundary` allowlist only governs at package granularity. Once `adapters/kernel` exists, assert core imports only `react` + relative paths. +- **Also address the deferred I2 (reactive identity)** noted under Post-implementation corrections above, and resolve the two Plan-2 notes: emit `dist/styles.css` so the exports entry resolves, and decide whether `react()` failures should populate `error` (today only `send()` does). + +## CI note for whoever wires it + +Root `pnpm test` does not run this package's tests. CI must also run: + +```bash +pnpm --filter @insignia/iios-messaging-ui test +``` + +The alternative — folding `.tsx` + jsdom into the root config — would make every UI test wait on Postgres provisioning. Not worth it. From c5237a237a72052f070dbbfc07a486b77b0864e0 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Tue, 21 Jul 2026 17:37:41 +0530 Subject: [PATCH 11/26] =?UTF-8?q?feat(messaging-ui):=20KernelClientAdapter?= =?UTF-8?q?=20=E2=80=94=20direct-IIOS=20transport=20for=20the=20SDK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements MessagingAdapter over @insignia/iios-kernel-client (browser → IIOS, token-in): listConversations/openThread/history/send/subscribe/typing/markRead /react, mapping kernel Message/ThreadSummary/annotations onto the SDK's neutral types. currentActorId comes from the host's session (senderId space), never inferred from history — the exact bug the conformance suite kills. - lives in adapters/ (transport boundary intact — core stays transport-free) - ships from the ./adapters/kernel-client subpath (kernel-client is an optional peer dep; excluded from the main bundle) - connectKernelAdapter({serviceUrl, token, currentUserId}) convenience - passes the shared adapter conformance suite (12 tests) over the real MessageSocket facade with only the socket.io layer faked - media/attachments deferred (kernel-client has no presign yet) Co-Authored-By: Claude Opus 4.8 --- packages/iios-messaging-ui/package.json | 13 +- .../src/adapters/kernel-client.test.ts | 124 +++++++++ .../src/adapters/kernel-client.ts | 257 ++++++++++++++++++ packages/iios-messaging-ui/tsup.config.ts | 4 +- pnpm-lock.yaml | 25 +- 5 files changed, 409 insertions(+), 14 deletions(-) create mode 100644 packages/iios-messaging-ui/src/adapters/kernel-client.test.ts create mode 100644 packages/iios-messaging-ui/src/adapters/kernel-client.ts diff --git a/packages/iios-messaging-ui/package.json b/packages/iios-messaging-ui/package.json index 652c6e7..4c1bd3f 100644 --- a/packages/iios-messaging-ui/package.json +++ b/packages/iios-messaging-ui/package.json @@ -14,6 +14,10 @@ "types": "./dist/adapters/mock.d.ts", "import": "./dist/adapters/mock.js" }, + "./adapters/kernel-client": { + "types": "./dist/adapters/kernel-client.d.ts", + "import": "./dist/adapters/kernel-client.js" + }, "./conformance": { "types": "./dist/conformance.d.ts", "import": "./dist/conformance.js" @@ -29,9 +33,16 @@ "test": "vitest run" }, "peerDependencies": { - "react": ">=18" + "react": ">=18", + "@insignia/iios-kernel-client": "*" + }, + "peerDependenciesMeta": { + "@insignia/iios-kernel-client": { + "optional": true + } }, "devDependencies": { + "@insignia/iios-kernel-client": "workspace:*", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.1.0", "@types/react": "^19.0.0", diff --git a/packages/iios-messaging-ui/src/adapters/kernel-client.test.ts b/packages/iios-messaging-ui/src/adapters/kernel-client.test.ts new file mode 100644 index 0000000..961be41 --- /dev/null +++ b/packages/iios-messaging-ui/src/adapters/kernel-client.test.ts @@ -0,0 +1,124 @@ +// Runs the shared adapter conformance suite against KernelClientAdapter — proving it satisfies +// the same contract as the mock, over the REAL MessageSocket facade. Only the lowest socket.io +// layer is faked (via kernel-client's own SocketLike seam), so the facade's wire mapping is +// exercised for real. No live IIOS required. + +import { MessageSocket } from '@insignia/iios-kernel-client'; +import type { Message as KernelMessage, SocketLike } from '@insignia/iios-kernel-client'; +import { runAdapterConformance } from '../conformance'; +import { KernelClientAdapter, type RestPort } from './kernel-client'; + +const ME = 'me'; +const SEEDED = 'th_seed'; + +/** An in-memory stand-in for the /message socket.io namespace: stores messages, echoes 'message'. */ +function makeFakeSocket(): SocketLike { + const threads = new Map([ + [ + SEEDED, + [ + { + id: 'seed_1', + threadId: SEEDED, + senderActorId: 'actor_other', + senderId: 'pp_other', + senderName: 'Other', + content: 'seeded message', + createdAt: new Date(0).toISOString(), + }, + ], + ], + ]); + const handlers = new Map void>>(); + let seq = 1; + + const fire = (event: string, payload: unknown): void => handlers.get(event)?.forEach((h) => h(payload)); + + return { + on(event, handler) { + if (!handlers.has(event)) handlers.set(event, new Set()); + handlers.get(event)!.add(handler); + return undefined; + }, + off(event, handler) { + handlers.get(event)?.delete(handler); + return undefined; + }, + emit() { + return undefined; // typing/focus — no echo needed for conformance + }, + async emitWithAck(event, payload) { + const p = (payload ?? {}) as { + threadId: string; + content?: string; + parentInteractionId?: string; + interactionId?: string; + type?: string; + value?: string; + }; + if (event === 'open_thread') { + if (!threads.has(p.threadId)) threads.set(p.threadId, []); + return { threadId: p.threadId, status: 'OPEN', history: [...threads.get(p.threadId)!] }; + } + if (event === 'send_message') { + const msg: KernelMessage = { + id: `m_${seq++}`, + threadId: p.threadId, + senderActorId: `actor_${ME}`, + senderId: ME, + senderName: ME, + content: p.content ?? '', + createdAt: new Date().toISOString(), + ...(p.parentInteractionId ? { parentInteractionId: p.parentInteractionId } : {}), + }; + if (!threads.has(p.threadId)) threads.set(p.threadId, []); + threads.get(p.threadId)!.push(msg); + fire('message', msg); + return msg; + } + if (event === 'read') return { ok: true }; + if (event === 'annotate') { + fire('annotation', { + threadId: p.threadId, + interactionId: p.interactionId, + type: p.type, + value: p.value, + op: 'add', + users: [ME], + userId: ME, + }); + return {}; + } + return {}; + }, + connect() { + return undefined; + }, + disconnect() { + return undefined; + }, + connected: true, + }; +} + +function makeFakeRest(): RestPort { + let seq = 1; + return { + async listThreads() { + return []; + }, + async createThread() { + return { threadId: `th_new_${seq++}` }; + }, + async addParticipant() { + /* governed server-side; a fake always allows */ + }, + }; +} + +function makeAdapter(): KernelClientAdapter { + const socket = new MessageSocket({ serviceUrl: 'http://iios.test', token: 'tok', autoConnect: false }, makeFakeSocket()); + return new KernelClientAdapter({ currentUserId: ME, socket, rest: makeFakeRest() }); +} + +runAdapterConformance({ makeAdapter, seededThreadId: SEEDED, openWith: ['pp_a'] }); diff --git a/packages/iios-messaging-ui/src/adapters/kernel-client.ts b/packages/iios-messaging-ui/src/adapters/kernel-client.ts new file mode 100644 index 0000000..3f3541e --- /dev/null +++ b/packages/iios-messaging-ui/src/adapters/kernel-client.ts @@ -0,0 +1,257 @@ +// Transport adapter: implements MessagingAdapter over @insignia/iios-kernel-client +// (browser → IIOS directly, token-in). This is the "plug into any app with a token" +// path — the same transport chat-web and support-sdk use. +// +// It lives in adapters/ (the ONLY layer allowed to import a transport) and ships from +// its own subpath, so core UI never pulls socket code it can't use. + +import { MessageSocket, RestClient } from '@insignia/iios-kernel-client'; +import type { + AnnotationEvent, + Message as KernelMessage, + MessageEvents, + OpenThreadResult, + ReceiptEvent, + ThreadSummary, + TypingEvent, +} from '@insignia/iios-kernel-client'; +import type { MessagingAdapter } from '../adapter'; +import type { + Conversation, + Membership, + Message, + MessageEvent, + Reaction, + SendOpts, + Unsubscribe, +} from '../types'; + +/** The slice of MessageSocket the adapter needs — the real facade satisfies it; tests inject a fake. */ +export interface SocketPort { + openThread(threadId?: string, opts?: { membership?: string; creatorRole?: string; subject?: string }): Promise; + sendMessage(threadId: string, content: string, opts?: { parentInteractionId?: string; mentions?: string[] }): Promise; + react(threadId: string, interactionId: string, value: string): Promise; + markRead(threadId: string, interactionId: string): Promise<{ ok: boolean }>; + typing(threadId: string): void; + on(event: E, handler: MessageEvents[E]): () => void; +} + +/** The slice of RestClient the adapter needs. */ +export interface RestPort { + listThreads(filter?: { metadata?: Record }): Promise; + createThread(opts: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record }): Promise<{ threadId: string }>; + addParticipant(threadId: string, userId: string): Promise; +} + +export interface KernelClientAdapterConfig { + /** + * The current user's id in IIOS's `senderId` space (email/username), from the host's auth — + * NEVER inferred from message history. This is exactly `currentActorId()`, and it's the bug the + * conformance suite kills: identity comes from the session, not from a message you happened to send. + */ + currentUserId: string; + socket: SocketPort; + rest: RestPort; + /** Optional opaque metadata filter for the conversation list (e.g. { source: 'crm-messenger' }). */ + threadFilter?: Record; +} + +const REACTION = 'reaction'; + +export class KernelClientAdapter implements MessagingAdapter { + private readonly me: string; + private readonly socket: SocketPort; + private readonly rest: RestPort; + private readonly threadFilter?: Record; + + /** Per-thread UI subscribers. The socket fans server events in; these fan them out. */ + private readonly listeners = new Map void>>(); + /** Reaction users per message (messageId → emoji → userSet), so an annotation delta becomes a full set. */ + private readonly reactions = new Map>>(); + private readonly joined = new Set(); + private readonly offs: Array<() => void> = []; + + constructor(cfg: KernelClientAdapterConfig) { + this.me = cfg.currentUserId; + this.socket = cfg.socket; + this.rest = cfg.rest; + this.threadFilter = cfg.threadFilter; + + this.offs.push( + this.socket.on('message', (m: KernelMessage) => { + this.ingestReactions(m); + this.emit(m.threadId, { kind: 'message', message: this.toMessage(m) }); + }), + ); + this.offs.push( + this.socket.on('typing', (e: TypingEvent) => this.emit(e.threadId, { kind: 'typing', userId: e.userId })), + ); + this.offs.push( + // Receipts carry no threadId, so fan to every open thread; the UI filters by messageId. + this.socket.on('receipt', (e: ReceiptEvent) => this.broadcast({ kind: 'receipt', messageId: e.interactionId, actorId: e.actorId })), + ); + this.offs.push( + this.socket.on('annotation', (e: AnnotationEvent) => { + if (e.type !== REACTION) return; + this.setReactionUsers(e.interactionId, e.value, e.users); + this.emit(e.threadId, { kind: 'reaction', messageId: e.interactionId, reactions: this.reactionsOf(e.interactionId) }); + }), + ); + } + + currentActorId(): string { + return this.me; + } + + async listConversations(): Promise { + const threads = await this.rest.listThreads(this.threadFilter ? { metadata: this.threadFilter } : undefined); + return threads.map((t) => this.toConversation(t)); + } + + async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> { + const membership: Membership = p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group'); + const { threadId } = await this.rest.createThread({ + membership, + creatorRole: membership === 'group' ? 'ADMIN' : 'MEMBER', + ...(p.subject ? { subject: p.subject } : {}), + }); + // Governance (DM cap, roles) is enforced server-side by IIOS/OPA; a rejected add surfaces up there. + for (const id of p.participantIds) await this.rest.addParticipant(threadId, id).catch(() => undefined); + return { threadId }; + } + + async history(threadId: string): Promise { + const res = await this.socket.openThread(threadId); // joins the thread, so live events start flowing + this.joined.add(threadId); + return res.history.map((m) => { + this.ingestReactions(m); + return this.toMessage(m); + }); + } + + async send(threadId: string, content: string, opts?: SendOpts): Promise { + // Attachments are intentionally not forwarded here: kernel-client exposes no media/presign yet, + // and the SDK Attachment carries a display `url`, not a storage `contentRef`. Media is a follow-up + // (kernel-client media methods + a contentRef on Attachment). Text + reply threading work today. + const m = await this.socket.sendMessage( + threadId, + content, + opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : undefined, + ); + return this.toMessage(m); + } + + async react(threadId: string, messageId: string, emoji: string): Promise { + await this.socket.react(threadId, messageId, emoji); + } + + subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe { + if (!this.listeners.has(threadId)) this.listeners.set(threadId, new Set()); + this.listeners.get(threadId)!.add(cb); + if (!this.joined.has(threadId)) { + this.joined.add(threadId); + void this.socket.openThread(threadId).catch(() => this.joined.delete(threadId)); + } + return () => { + this.listeners.get(threadId)?.delete(cb); + }; + } + + sendTyping(threadId: string): void { + this.socket.typing(threadId); + } + + async markRead(threadId: string, messageId: string): Promise { + await this.socket.markRead(threadId, messageId); + } + + /** Detach socket handlers. Not part of the contract — call on teardown to avoid leaks. */ + close(): void { + for (const off of this.offs) off(); + this.offs.length = 0; + this.listeners.clear(); + } + + // ── mapping ──────────────────────────────────────────────────── + private toMessage(m: KernelMessage): Message { + return { + id: m.id, + // `senderId` (email/username), NOT the actor id — it matches currentActorId() and is the + // reliable "is this mine?" field. Never inferred from history. + actorId: m.senderId ?? null, + text: m.content ?? '', + at: m.createdAt, + parentInteractionId: m.parentInteractionId ?? null, + reactions: this.reactionsOf(m.id), + }; + } + + private toConversation(t: ThreadSummary): Conversation { + const others = t.participants.filter((p) => p !== this.me); + const membership: Membership | null = t.membership === 'dm' || t.membership === 'group' ? t.membership : null; + return { + threadId: t.threadId, + title: t.subject?.trim() || others.join(', ') || 'Conversation', + subject: t.subject, + membership, + participants: [...t.participants], + unread: t.unread, + ...(t.lastMessage ? { lastMessage: t.lastMessage } : {}), + ...(t.lastAt ? { lastAt: t.lastAt } : {}), + }; + } + + // ── reaction state ───────────────────────────────────────────── + private ingestReactions(m: KernelMessage): void { + for (const a of m.annotations ?? []) { + if (a.type === REACTION) this.setReactionUsers(m.id, a.value, a.users); + } + } + + private setReactionUsers(messageId: string, emoji: string, users: string[]): void { + let byEmoji = this.reactions.get(messageId); + if (!byEmoji) { + byEmoji = new Map(); + this.reactions.set(messageId, byEmoji); + } + if (users.length === 0) byEmoji.delete(emoji); + else byEmoji.set(emoji, new Set(users)); + } + + private reactionsOf(messageId: string): Reaction[] { + const byEmoji = this.reactions.get(messageId); + if (!byEmoji) return []; + const out: Reaction[] = []; + for (const [emoji, users] of byEmoji) { + if (users.size > 0) out.push({ emoji, count: users.size, mine: users.has(this.me) }); + } + return out; + } + + // ── event fan-out ────────────────────────────────────────────── + private emit(threadId: string, e: MessageEvent): void { + this.listeners.get(threadId)?.forEach((cb) => cb(e)); + } + + private broadcast(e: MessageEvent): void { + for (const set of this.listeners.values()) set.forEach((cb) => cb(e)); + } +} + +/** Convenience: build an adapter that talks straight to IIOS with a token. */ +export function connectKernelAdapter(opts: { + serviceUrl: string; + token: string; + currentUserId: string; + threadFilter?: Record; + autoConnect?: boolean; +}): KernelClientAdapter { + const rest = new RestClient({ serviceUrl: opts.serviceUrl, token: opts.token }); + const socket = new MessageSocket({ serviceUrl: opts.serviceUrl, token: opts.token, autoConnect: opts.autoConnect ?? true }); + return new KernelClientAdapter({ + currentUserId: opts.currentUserId, + socket, + rest, + ...(opts.threadFilter ? { threadFilter: opts.threadFilter } : {}), + }); +} diff --git a/packages/iios-messaging-ui/tsup.config.ts b/packages/iios-messaging-ui/tsup.config.ts index 3baff55..aea0692 100644 --- a/packages/iios-messaging-ui/tsup.config.ts +++ b/packages/iios-messaging-ui/tsup.config.ts @@ -6,9 +6,9 @@ export default defineConfig({ // entry, never reachable from `index`, because it imports vitest, and bundling // that into the main barrel would drag a test runner into every consumer's // production build. - entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/conformance.ts'], + entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts'], format: ['esm'], dts: true, clean: true, - external: ['react', 'react-dom', 'vitest'], + external: ['react', 'react-dom', 'vitest', '@insignia/iios-kernel-client'], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cdaaecf..faeced5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -328,6 +328,9 @@ importers: packages/iios-messaging-ui: devDependencies: + '@insignia/iios-kernel-client': + specifier: workspace:* + version: link:../iios-kernel-client '@testing-library/dom': specifier: ^10.4.0 version: 10.4.1 @@ -520,6 +523,9 @@ packages: resolution: {integrity: sha512-/PZmyAlb2NGWPikRRuiWLdfHQd8Wrx6lX4HqvTcaDhlU43M3T0ud4PH2T3QDp7BzHYY92xtD8iPxX2asg67G1A==} engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@aws-sdk/checksums@3.1000.18': resolution: {integrity: sha512-IImkbEyXdV6/uaF5r6Wkk+8718mQw1ll83j0a4a30R3JM/rHVFdWAiT4jtJpFjJiIwM/oJ6SxIxr0z2TaQUGqw==} engines: {node: '>=20.0.0'} @@ -592,9 +598,6 @@ packages: resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} engines: {node: '>=18.0.0'} - '@asamuzakjp/css-color@3.2.0': - resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} - '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -3677,6 +3680,14 @@ snapshots: transitivePeerDependencies: - chokidar + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + '@aws-sdk/checksums@3.1000.18': dependencies: '@aws-sdk/core': 3.975.3 @@ -3842,14 +3853,6 @@ snapshots: '@aws/lambda-invoke-store@0.3.0': {} - '@asamuzakjp/css-color@3.2.0': - dependencies: - '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 - lru-cache: 10.4.3 - '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 From 3f1f89dfbe97a6d22ea252d6fb768dac23849a61 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Tue, 21 Jul 2026 17:47:30 +0530 Subject: [PATCH 12/26] =?UTF-8?q?feat(messaging-ui):=20rendered=20Messenge?= =?UTF-8?q?r=20components=20=E2=80=94=20the=20drop-in=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the SDK from hooks-only into a real UI SDK: (conversation list + open thread + composer), plus and , all driven by the existing useConversations/useMessages hooks over the injected adapter — zero transport imports (boundary intact). - themeable via --miu-* CSS variables; default theme ships as ./styles.css - optimistic send, typing indicator, seen ticks, reactions display, unread badges - render smoke tests (jsdom + MockAdapter): mounts, auto-selects first thread, sends a message, switches threads (3 tests) — 48 total green - tsup: css bundled to dist/styles.css; dts scoped to TS entries Co-Authored-By: Claude Opus 4.8 --- .../src/components/conversation-list.tsx | 52 ++++ .../src/components/messenger.test.tsx | 43 ++++ .../src/components/messenger.tsx | 32 +++ .../src/components/thread.tsx | 75 ++++++ packages/iios-messaging-ui/src/index.ts | 5 + packages/iios-messaging-ui/src/styles.css | 223 ++++++++++++++++++ packages/iios-messaging-ui/tsup.config.ts | 5 +- 7 files changed, 433 insertions(+), 2 deletions(-) create mode 100644 packages/iios-messaging-ui/src/components/conversation-list.tsx create mode 100644 packages/iios-messaging-ui/src/components/messenger.test.tsx create mode 100644 packages/iios-messaging-ui/src/components/messenger.tsx create mode 100644 packages/iios-messaging-ui/src/components/thread.tsx create mode 100644 packages/iios-messaging-ui/src/styles.css diff --git a/packages/iios-messaging-ui/src/components/conversation-list.tsx b/packages/iios-messaging-ui/src/components/conversation-list.tsx new file mode 100644 index 0000000..14e75fb --- /dev/null +++ b/packages/iios-messaging-ui/src/components/conversation-list.tsx @@ -0,0 +1,52 @@ +import type { Conversation } from '../types'; + +/** Initials for the avatar chip — first letters of the first two words. */ +function initials(title: string): string { + const parts = title.trim().split(/\s+/).filter(Boolean); + const chars = (parts[0]?.[0] ?? '') + (parts[1]?.[0] ?? ''); + return (chars || '?').toUpperCase(); +} + +/** + * Presentational conversation list. Owns no data fetching — the parent passes the + * conversations (from useConversations) so a host can also drive it from its own store. + */ +export function ConversationList({ + conversations, + selectedId, + onSelect, +}: { + conversations: Conversation[]; + selectedId?: string | null; + onSelect: (threadId: string) => void; +}) { + if (conversations.length === 0) { + return
No conversations yet.
; + } + return ( +
    + {conversations.map((c) => ( +
  • + +
  • + ))} +
+ ); +} diff --git a/packages/iios-messaging-ui/src/components/messenger.test.tsx b/packages/iios-messaging-ui/src/components/messenger.test.tsx new file mode 100644 index 0000000..1a78968 --- /dev/null +++ b/packages/iios-messaging-ui/src/components/messenger.test.tsx @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MessagingProvider } from '../provider'; +import { Messenger } from './messenger'; +import { MockAdapter } from '../adapters/mock'; + +function mount() { + return render( + + + , + ); +} + +describe(' (rendered UI over an adapter)', () => { + it('renders the conversation list and auto-selects the first thread', async () => { + mount(); + // Seeded group conversation title from the mock. + expect(await screen.findByText('Storm response — East side')).toBeTruthy(); + // Auto-selected thread shows its seeded message. + expect(await screen.findByText('Crew is rolling out at 7.')).toBeTruthy(); + }); + + it('sends a message through the adapter and shows it in the thread', async () => { + mount(); + await screen.findByText('Crew is rolling out at 7.'); + + const input = screen.getByLabelText('Message') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'on our way' } }); + fireEvent.click(screen.getByText('Send')); + + await waitFor(() => expect(screen.getByText('on our way')).toBeTruthy()); + // Composer cleared after send. + expect(input.value).toBe(''); + }); + + it('switches threads when another conversation is clicked', async () => { + mount(); + // Click the DM (its title is the other participant's name from the mock directory). + fireEvent.click(await screen.findByText('Sofia Ramirez')); + expect(await screen.findByText('Can you review the Henderson estimate?')).toBeTruthy(); + }); +}); diff --git a/packages/iios-messaging-ui/src/components/messenger.tsx b/packages/iios-messaging-ui/src/components/messenger.tsx new file mode 100644 index 0000000..da4cc7e --- /dev/null +++ b/packages/iios-messaging-ui/src/components/messenger.tsx @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react'; +import { useConversations } from '../hooks/use-conversations'; +import { ConversationList } from './conversation-list'; +import { Thread } from './thread'; + +/** + * The drop-in messenger: conversation list + open thread. Owns only selection state; + * all data flows through the injected adapter via the hooks. Auto-selects the first + * conversation so the pane is never empty on load. + */ +export function Messenger() { + const { conversations, loading, error } = useConversations(); + const [selected, setSelected] = useState(null); + + useEffect(() => { + if (selected && conversations.some((c) => c.threadId === selected)) return; + setSelected(conversations[0]?.threadId ?? null); + }, [conversations, selected]); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/packages/iios-messaging-ui/src/components/thread.tsx b/packages/iios-messaging-ui/src/components/thread.tsx new file mode 100644 index 0000000..0f44569 --- /dev/null +++ b/packages/iios-messaging-ui/src/components/thread.tsx @@ -0,0 +1,75 @@ +import { useState, type FormEvent } from 'react'; +import { useMessages } from '../hooks/use-messages'; + +/** + * One conversation: message list + composer, driven entirely by useMessages (which + * handles history, live subscribe, optimistic send, typing, and seen state). Renders + * nothing transport-specific — swap the adapter and this is unchanged. + */ +export function Thread({ threadId }: { threadId: string | null }) { + const { messages, loading, error, send, typingUserIds, seenIds, sendTyping } = useMessages(threadId); + const [draft, setDraft] = useState(''); + const [sending, setSending] = useState(false); + + async function submit(e: FormEvent): Promise { + e.preventDefault(); + const text = draft.trim(); + if (!text || sending) return; + setDraft(''); + setSending(true); + try { + await send(text); + } catch { + // The error is surfaced via the hook's `error`; keep the composer usable. + } finally { + setSending(false); + } + } + + if (!threadId) { + return
Select a conversation.
; + } + + return ( +
+
+ {loading && messages.length === 0 ?
Loading…
: null} + {error ?
{error}
: null} + {messages.map((m) => ( +
+
{m.text}
+ {m.reactions && m.reactions.length > 0 ? ( +
+ {m.reactions.map((r) => ( + + {r.emoji} {r.count} + + ))} +
+ ) : null} + {m.mine && seenIds.has(m.id) ? Seen : null} +
+ ))} + {typingUserIds.length > 0 ? ( +
{typingUserIds.length === 1 ? 'typing…' : 'several people are typing…'}
+ ) : null} +
+ +
+ { + setDraft(e.target.value); + sendTyping(); + }} + /> + +
+
+ ); +} diff --git a/packages/iios-messaging-ui/src/index.ts b/packages/iios-messaging-ui/src/index.ts index 802d60d..7558faa 100644 --- a/packages/iios-messaging-ui/src/index.ts +++ b/packages/iios-messaging-ui/src/index.ts @@ -8,6 +8,11 @@ export { useConversations } from './hooks/use-conversations'; export { useMessages } from './hooks/use-messages'; export { isOwnMessage } from './types'; +// Rendered UI. Pair with the './styles.css' export (or override the --miu-* tokens). +export { Messenger } from './components/messenger'; +export { ConversationList } from './components/conversation-list'; +export { Thread } from './components/thread'; + export type { MessagingAdapter } from './adapter'; export type { ConversationsState } from './hooks/use-conversations'; export type { MessagesState, UiMessage } from './hooks/use-messages'; diff --git a/packages/iios-messaging-ui/src/styles.css b/packages/iios-messaging-ui/src/styles.css new file mode 100644 index 0000000..43efa1f --- /dev/null +++ b/packages/iios-messaging-ui/src/styles.css @@ -0,0 +1,223 @@ +/* Default theme for @insignia/iios-messaging-ui. Everything is driven by CSS variables + scoped to .miu-messenger, so a host restyles by overriding the tokens — no component edits. */ +.miu-messenger { + --miu-bg: #0e0e13; + --miu-panel: #16161d; + --miu-panel-2: #1d1d26; + --miu-border: #262631; + --miu-text: #e9e9ef; + --miu-muted: #9a9aa7; + --miu-accent: #fda913; + --miu-accent-text: #1a1206; + --miu-radius: 12px; + + display: flex; + height: 100%; + min-height: 0; + color: var(--miu-text); + background: var(--miu-bg); + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + font-size: 14px; +} + +.miu-sidebar { + width: 300px; + flex-shrink: 0; + border-right: 1px solid var(--miu-border); + overflow-y: auto; + background: var(--miu-panel); +} + +.miu-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +/* conversation list */ +.miu-convlist { + list-style: none; + margin: 0; + padding: 0; +} +.miu-convrow { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 11px 14px; + border: none; + border-bottom: 1px solid var(--miu-border); + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; + font: inherit; +} +.miu-convrow:hover { + background: var(--miu-panel-2); +} +.miu-convrow.is-active { + background: var(--miu-panel-2); +} +.miu-avatar { + width: 34px; + height: 34px; + flex-shrink: 0; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 12px; + font-weight: 700; + color: var(--miu-accent-text); + background: var(--miu-accent); +} +.miu-convrow-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +.miu-convrow-title { + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.miu-convrow-preview { + color: var(--miu-muted); + font-size: 12.5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.miu-badge { + flex-shrink: 0; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 999px; + display: grid; + place-items: center; + font-size: 11px; + font-weight: 700; + color: var(--miu-accent-text); + background: var(--miu-accent); +} + +/* thread */ +.miu-thread { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} +.miu-messages { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 8px; +} +.miu-msg { + display: flex; + flex-direction: column; + align-items: flex-start; + max-width: 78%; +} +.miu-msg.is-mine { + align-self: flex-end; + align-items: flex-end; +} +.miu-bubble { + padding: 8px 12px; + border-radius: 14px; + border: 1px solid var(--miu-border); + background: var(--miu-panel); + white-space: pre-wrap; + word-break: break-word; +} +.miu-msg.is-mine .miu-bubble { + border: none; + color: var(--miu-accent-text); + background: var(--miu-accent); +} +.miu-msg.is-pending { + opacity: 0.6; +} +.miu-reactions { + display: flex; + gap: 4px; + margin-top: 3px; +} +.miu-reaction { + font-size: 12px; + padding: 1px 7px; + border-radius: 999px; + border: 1px solid var(--miu-border); + background: var(--miu-panel-2); +} +.miu-reaction.is-mine { + border-color: var(--miu-accent); +} +.miu-seen { + font-size: 11px; + color: var(--miu-muted); + margin-top: 2px; +} +.miu-typing { + font-size: 12.5px; + color: var(--miu-muted); + font-style: italic; +} + +/* composer */ +.miu-composer { + display: flex; + gap: 8px; + padding: 12px; + border-top: 1px solid var(--miu-border); +} +.miu-input { + flex: 1; + padding: 9px 12px; + border-radius: 10px; + border: 1px solid var(--miu-border); + background: var(--miu-panel); + color: var(--miu-text); + font: inherit; + outline: none; +} +.miu-input:focus { + border-color: var(--miu-accent); +} +.miu-send { + padding: 0 16px; + border-radius: 10px; + border: none; + font-weight: 700; + cursor: pointer; + color: var(--miu-accent-text); + background: var(--miu-accent); +} +.miu-send:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* misc */ +.miu-empty { + padding: 24px; + color: var(--miu-muted); + text-align: center; +} +.miu-thread-empty { + margin: auto; +} +.miu-error { + color: #f0563f; +} diff --git a/packages/iios-messaging-ui/tsup.config.ts b/packages/iios-messaging-ui/tsup.config.ts index aea0692..3920383 100644 --- a/packages/iios-messaging-ui/tsup.config.ts +++ b/packages/iios-messaging-ui/tsup.config.ts @@ -6,9 +6,10 @@ export default defineConfig({ // entry, never reachable from `index`, because it imports vitest, and bundling // that into the main barrel would drag a test runner into every consumer's // production build. - entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts'], + entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts', 'src/styles.css'], format: ['esm'], - dts: true, + // Types only for the TS entries — styles.css has no .d.ts (and tsc chokes on a .css root file). + dts: { entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts'] }, clean: true, external: ['react', 'react-dom', 'vitest', '@insignia/iios-kernel-client'], }); From 00a2cc474a3eabb0e5fa12181a6d243a7e0bd3e6 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Tue, 21 Jul 2026 20:04:46 +0530 Subject: [PATCH 13/26] =?UTF-8?q?feat(messaging-ui):=20channels=20?= =?UTF-8?q?=E2=80=94=20browse,=20join,=20leave,=20create=20(contract=20+?= =?UTF-8?q?=20mock=20+=20UI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a third membership beyond dm/group: a discoverable, joinable room. - contract: Membership += 'channel'; Conversation.topic; ChannelSummary + CreateChannelInput; optional adapter methods browseChannels/createChannel/ joinChannel/leaveChannel (capability-degrading — absent hides the UI) - MockAdapter implements them (seeds a joined public, a joinable public, and a private channel); listConversations now returns only threads I'm in - useChannels hook (browse/create/join/leave + supported flag) - UI: sidebar sections (Channels vs Direct messages), a + that opens a ChannelBrowser (join + create), # / lock glyphs; themeable styles - KernelClientAdapter passes channel membership + topic through - 4 channel tests (mock browse/join/create + render browse→join); 52 total green Co-Authored-By: Claude Opus 4.8 --- .../insignia-iios-messaging-ui-0.1.0.tgz | Bin 0 -> 11689 bytes packages/iios-messaging-ui/src/adapter.ts | 18 +++ .../src/adapters/kernel-client.ts | 5 +- .../iios-messaging-ui/src/adapters/mock.ts | 84 ++++++++++-- .../src/components/channel-browser.tsx | 93 +++++++++++++ .../src/components/channels.test.tsx | 54 ++++++++ .../src/components/conversation-list.tsx | 2 +- .../src/components/messenger.test.tsx | 5 +- .../src/components/messenger.tsx | 65 +++++++-- .../src/hooks/use-channels.ts | 84 ++++++++++++ .../src/hooks/use-conversations.test.tsx | 7 +- packages/iios-messaging-ui/src/index.ts | 6 + packages/iios-messaging-ui/src/styles.css | 126 ++++++++++++++++++ packages/iios-messaging-ui/src/types.ts | 23 +++- 14 files changed, 544 insertions(+), 28 deletions(-) create mode 100644 packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz create mode 100644 packages/iios-messaging-ui/src/components/channel-browser.tsx create mode 100644 packages/iios-messaging-ui/src/components/channels.test.tsx create mode 100644 packages/iios-messaging-ui/src/hooks/use-channels.ts diff --git a/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz b/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..c2ff6802eb5b64fe7c678306fce2664df406fbc7 GIT binary patch literal 11689 zcmV;aEmqPWiwFP!00002|LuM2avR5$uz&L@It3UaARGHl)mhi zmMb=A8bq6O%XEVjmqJy3?E`G(3G*ahozwT30Z7V{;-sWX#l&<^UrwJs_j9JA$pt-U z8z~oZL&)oli6{}`tB?6_JRa{nd`P;UkH_Oj+Ycw?tF7&w@no{IGk!?E8c#MicOH?i z#$SDge{&&e{ndE<5jHsXu;QH6y*_o`Fe9_b7yPf8YFus zWAZioo_)`z?r}bsERD(6-_!3YedM0gBw+=-Fiq+A{*qLK8E!%+n`3YKnx=d%VlvsR z-oew9iz=hnF_~rzAIum(pUId^#^Zm1-#ITv=Ghn?BxP3CF}b95-z|IqEoU^jIIqij zk^0SN=h!$c3ptw7oM+eZ8X<&;Yav-an)4z49MP)E*hoE9k9x=Kykz9{VNd-Qv=Ae~ zYCg3C3jRMVCX=vB6(TF z0Qh-c*fEo`xXieaI4=A#LPU;9Q5Fm*p!GQ~^yn2$Q(m0oFK>Xlyu$Zelhcf!7b7lN zE@A=*#cFtbPAfnS1x>wrgDG~334l?Zi2<-%l?M1(DP@`83UMF0vZjToXw3@g;UO<( ztmgOx5OIVHk4eU+s@J?0(5))D*#jVs_g)B%cm{`zFj;Z3qs>9{#fXa$O(ef$_j#FK zQrW;&4L5V=%t8etSIt6=@=QT%yRAn?_?UVGckER9q=t|G~Xxx3~{K+l=zLf?Qvc5w+u8+VrQ-CL0SI}gcoaem(| z2IO3!hNQJrY~r0ki$~x6i$4jFw44_TpVSC~Wm7!>E7IFBIx^dctspAkIi+ydEGnExh zxV(L#LABP{fbLC{-~F5xxUa4#0V+pGUJ66Vf~H7(=wThdm==UhLmS) z$?q*&HkZ4TRkbC`k3<7t!Ewq3JX8$?I^xn-6crRbOT{ zTsvT<-nd4gP)yBCTi0dni#=aY$J;wwQ|Rz3*Z(Wu{*#o&w5)SlBrN(_d?x$P&SZPs zwEt|4x3|C8e?E)-X9Mj&{>e+R#`CJICAlFf6G_d_*pP5JBtCrtfAj~hTEJl!eze)1_1$mE?F%oN({YWFs3A{a|qN)#pf1?$hnx$xGnC&9ys)EuvHiouV4z-f7}64svu=x$OWNN z*8B{37?CByGMU$47a}x~W&P>lcQq>ou0+nb=-44HxIN>d~eSW40G)Y;;fgpOfom~RF$^zi_OD;8P zHhbjFN%**&PT{r4uAv)zeTEalAW=0JvpyT#2l%inQWhTQ%jt9peZ@JZJOlQnr)ALv zFH!|o4VPZ$S=@&r%nJv1An^a-O9i?Z`>t&rk^@Ra3MDL<5W+z%qbz>98Z=-sHk*kb6=t65!Mb&;X6VuhN87@>h`3E`LV z&Ko_-VM-PrQE~J2Z%9RJ$rE1DLLR0fR#F@E_!BVt#S*S)#=V6znr`>H3puSXjubGI z%<<{DAA;H^#_Ao*TFjEVUq=yP5SiuTCSMmRoAQFC{Us9BD`kyIOD6DMNWhL?!T`1C z9l60fxyA}?T%h94-YIQijdUvjYm42rufF{I_3Qt0MJ_9+jXz}EZ&8U8$XM8qk3LQ zp0nQW+9j=tqYz5m4Cm<=^9-z})~H~${+D`@ee}hsK_Dt3nei;GS+TZIRtCiq$;Jcn zZ*Skee0_BA_APm^0ZZ$LQ%7dRA-N%!G@G;7j%!ENM9wZ}E}VJ7GivDf$estL#&k$B z`IPj3qdN~24JQS=A_rhY?DyR0B%ccue&&Lus6_LEQ1aUrI36n|q<5`{h9$4*!=07o zh1i%2HVSMZfuRQ$O|uM)w0O`5(loO zb$9@r(o8g!P>uQF>*g5n1>GM-mLYj#?>L^bk5x6BGMUWuE!G!8FU(*=zhB_*f_zIR zgCWsd(W|&24V1Qcsu9pkvFN^{#>Kwub`mQ5pPSxo)lKS$YIw%-68|`2Q|~gXj`;Ux z>t>rZf#8nIuveXn7gvR%Kdi48IwYYLB_P2KgU)aE_6VRL`PY9n1_EOyTGOWr|KL7pOSFe70`|OzPk*#fTUqMS*NuF~dd2-DDO!gpX!6#^XkHV@W_o8Vv zy+Yb?M6Z0xv3WlNws{{4w%H`q8o0POk#A1aMTs5EZviuLhiT5wu@PhU^Ym$Bl{#HF&<1dbWJDTSrQdrwA+|pQK(>g*bIy2f*Fp`y!-wx!VA$? zqEi0`kV6j18#B&HOn#)2MFkiy$hQrquowgjUEH?lnk9@^vSZagBfG=0&6V#9C0yVw zu;4pHzzP!E%O(a9P1B&qeOImmi&ma@<3fkWeVCGcg7D2jjL|_@F=3I5$Xbw?YHz)3K6h#2H{Yb^cA42es>Zp;ms!Mt`g zhC%lhd&MKx)Y2VTfA9YM%Pv^ zz6)9(pyU#5&S4E=r)fo-T!oYZQG6Atuw-~hSYH0jms1P!{yi~9CPkc^^dm$i?Bd~4 zYo^_M?Zop|G`7=}%^i93v0Afq>HDp3CHNT?5$3(XFszR`-GF-gR_F#xH-`>1XdJ>;ZxsFb@)gfn zIhW4-V?@o$6B;U%212Q_OS(xiMr$*oY^*`A{h3X}IHZF3tX|Wx)&_8q4r4)1;`2F^k|2 zW%AIvZ=nSfq*m(B0DPt&&KO&ex!2YCsox1Lj$oXaXa{vb3sta&F$wkTE3Ye9UhADX zK5o3UMnW4O#=RG;kYdB;)`tF;ZcE-)x8d&LQp24$k#CC}flWA^5WF>1z<)M}@c&|^ z@5b2y3obd$L?2VkeA^hr1^{WmhDg>t?++qTWnA_*{!4tjVXyfQWtlNr_~xS-L{jaM zK92Ebe6k^c55&vYRmJM3RG{PAbKv-f=%2Wd zeZ73oXuAgZlCeaxR7t~Sag4u>iwUku=u8P|%8h>=Ivi1({a(s1d*F9UGAhJ#nzNYn zYz%wP`JZCadtQd?BiBquJwwLVZkcGFb)4$xr;SP}kCrA*wC(5{* z$iCmvO(=<`cQ%*OUM$0F;a0_sHsUY*)Jr$~<|c_O&qQ(3o*3CDJz{fFdn6{k-s04Z zR~An*o?OJ5Fq@<8yWy-l;S=+V)l@ zk-k?8PcQ0(3zywTCaYI&ASnO8~^1Z#C*zszyH!J36IhRhS zWD#H?E98%CO6Qpsri>W|!x(U7n(6I&f<3gpe@{fHJy?rAZo{mccx&|?3BFlC>26B> zH(>=%5!NC;(e`NH=V8VT%+H#Ibb3mb^Odbz7#L}0PlX&!kz7Ip7IR0ICRQ*{nVF>R6f(4)ckgDKUBt%>Xo zJ#*>ZfqAO)dcV0*N`v9d@VDm6&}346b*uhq8TVcJ@QR(Q?B!s?I@;Z56uD=~Mn%W| zGza|n1CG}Y(wN>2j3lLYkL}F5z)x9A|waCI^&C0q^pc4-E zAbXf|>|sM-B5usw>`P2Io#NKsbF7guD>KY7?!CIMn9CN?@CfiR5V~{*APc&b#nTxr z&RN`ajat5^I4p}KtKN^DAp z(;}Pt;md3fog2d$NLuDTGYpWSP>EG$Ev3aS(H{pb3C%1dRQb_FVMo~CCw66E_-X|< z>b>|Ti>~{kST+kB`l=o1&E-MshunbGyWB#Hd}^FOpXTWnmg8p_fn8E1w3#R(xR z^FJmJx5usgkL}6km--K%Gyh}B$@?+L`l23}$pw=`a>RsuioLAB@Nc{j{Jh|FgY#01 zf}wQk$I-!)r>_oQJSTfzK$2?uBX(CqKCvOTNC8#l6?Cf$oL=!zp(-9C{rMfb72x;A zg9qd#6bVx4kgpGkjiReXL|#GF0UU%(X#rW}H55BU;|?Tp??NuUV9*GE<>=&sqNu9C zBr6ztDY~3&&uFED8Cb%RsW1gmXo6Ib(4ozh&XG_4_P_t9JnZ-vbUkKrFeIWRv>>!7 zN{Q4Gl6xg3XDlgm2FadN2$dGhEX}E{w=C`F5>gCr^AeWRsjzv#D4NxC2mtCQ({p1R zg0%lA2Zv-bJvU)NN+;TN9WH;>9^bm$!moKMw*rxQG~-0`thZ$ZrTlRK~;`TK^U6)t5GUY~aVL zC<$P2pVeFSQ!6)aMXYN@wz~>`gy1|KPPrJ|VfKv;a-5T&{oCES<# znn2dCZpM29w@o?t1Ub8QVI+NHFVKxE*kSr zyKXMa-MikftyHRTPf+eKhRBe#W`vCymRZz-%Kz*IAdEk>5o>Z_IxBm6M{ZLPu%Pzj%2!yTfLT#WvIH+clOXJ) zyFt>FN*Z?%42Z$QFqv@}n4srV_BQD-PL}>E`*JV{_u=KgSK$tFmIGgzaR`#DBJbz^ zKIvCcm@MD26{R^^h@dW7)eC$IotgL!J}PC>_++fD*351ln9!}PkI=#EuN5K{)U2?K zCqF(re2y!5cJSz8;Ph(Nz&%3*+!oXo#zwdxiJS9Cm$rG#57P2lsyLh+B1{KABzx0k`v8qZak<+k3uKydzB1 zm4K0)4Z^rRE+Q)$qP5%e=r&pf^QXNn-KX*Lx%aZl21w2VN27l#LlJ(hN0_=cS61B3KoL;q8J8LH_oazmkMz8I((sQ1J$8U&)eCJd}*l z3!%mRBr74T$q#CE=|ecI7IOxE;)D}^3>#9#np>xUv5I}SnGN!{zy9TKfBg%|X@!1z z_4C*HKTrvM@jH-GY*cR=qrYhAB ztgx<%OlC~9Wg{qOhqcj|o1tbI$MSYPT{R8UP4;_a%2}31Mu{slZ{}l z8{++jH-dnxAWz1={Y2UqpGfo0lTboz6BS#FTVOQPuZN7ZCA2odr35QJI*n1l4S91C zCJ<1iF75^DVz+vTREbcd6VgB@Tv+zke5)bADE;5ce~|HTc71?`roF~RMV=dSFfC8$ zH{4d-5G!p=o_WX2A^bUpW}rYkfGKg&jZj+M*T@2XJ_^eOv+Xd2-n)^b6b!tOB9AsT z8NE8rUjQUcB24W(KO)~e^YG@4e@rKUL@j7LfI!`Kh+R+-_h`AeG3Si&88LAkO%kJe zqU1nDDy@39=nB>k?xZT)IHko1cJQlW?r08ivKI^FzNM1nw5$&($gCi}gYWmuWNyL6 zoyCcD+;PFA6{_3>ctJ&ffQj8J$6t7U^Q0^amPlpnHpL^F1(LN{6wuh2+50zWtL6!N zU1wUOau0??mKWd=#OL@o@k=~dJ1pK*-;{svafev^{HfZA$$xZDfc``J8751S{5R2i>z5PyhJ#<-v=WKf!Z| z6}dq@s#jHSMLFfPHzb9MRXB#9nbUN*+G*b6&iot*S%^zt&{xsu~ zUSOEZ$``AeUwf0jgrDR&<*Q#z=0d)uNzUAxPm-LGqq3ZWZrfMae&7ayH_DvV9pjv5 zHP83w(#UHLQVbusx9-x)=Q0YByDijT?2wph>=7OhECKp}q zQr~N*(lM3c4m%adj>vmXZQM+@S?=93`2(5l=Xo_ozo-R?c51-UNgM1KMe zYz2Qn1znT{VM6kpO12*kjPXGVfWaHNyZS)%F~AN2;Hyy#z-x8|7M8lqGPt%Nr&38C zMevpn*$|$caD-@Hnrj2;dE@6Av@>}x-P^0b&;Kuu7X7O_a`p9k_|P~RwY6s z(aDE;@+`;goW^$vwM)#NzAn^PHB|qYz_F<}7pDz#sF^ zxDSCkJ8_}Ikn9TleA4nhzp%KEzKf20f>4YE!6ibPVrbr7XqYdShY~e$Qa}DM7wG-b z#t&uz^I^f>$&MX++@r(MjTI$Ku^rFkX0{Y^8TJlpAz{&&zMqMu460CIFEv?GIt5A9 zC-W?VK`k8}4`+wnUox!XVd22Pu>_Qvd#F*Zc`NQ(iLZyecDyO;XP}WFd zV7%~71D+06BTrW#P$2lg7|ji|*odlP(Vy~+HQi=p4z{6FAoKCYhUzFbzPW+EsE6)X9Pht#R?D~cMU)7CbnoGs?%V^Z0{SSs5uyt;qUz8C z;iB^KFV6Lw20G~IF&4*6m<3?`&in01f29*j} zk#OeCr7Ew)r1yAl@|_uKWo}GNdJmS5{ESKJM!#hsdqPlKv@9rOO@qMT!Uu4UlwEc& z{R-JlgWJXmblGbpNjqo>5O zwNK0ceX1}@j(ECrCjSGk@~7tikH*`Nn*9Ia_|fDG|Nk8SFfQqtZzWt$^L6r`tfSFy zeRmD>L#8xgkW~lX46UR=QZagpl%At>J^zW)P1n%{ha(#A*E)FH-LSr))~YWyZ`m7A z+lT!})-tFblJ{QBo^Q?dcIbBr&4y|W6RczGx5XS@)rJsfwA~LEqRh75Lw|=h7f=_I z0;@szy0#$rb1758nW~c*j`zeFq|}J0o%`b{RebsR_@kMizV36iR=o}gEAw8^yYaij z3b)@ml(kLl#}-`XB%J+iKlb*%tCQ#-kXK-DDU0lyT+J{cjS*SXLZBMUg;!^Ll$8n1 zFgh1Lp?D3S@<_lOnz8hpr8>OYRw3VCasW_+;|1i%-Q2p%Teh%R(>D;$;NVhy-{i)> zK-!|SzUvoDjm;~&YYXx|d_hIM>;5JW(4Ks=w@$4Khd*461)amFt*;)(GfDZUyx3J> zcWbY#~Mo`1o!3=t<~Nk;uOb$u_%9$fRg z0gY=h@K6tS4@-sI<+6aUrqkR+q`{gZ@`n=E#Pcd+NEV<)Wn{vVYvqY1T*8iFq@Vay z8ekYnq^iqNQs?W!)pz)jO1hyuOYrBAT+MhgBQ%32*F?R^vcr38CoEJQwgI}3ugh6=;__(^9QAsF_iTmV&oU>vMMWt#A-azP3 z5T1i-%2KnV7h2whyuewR!sn3BYnn20Uekm@y7YByQdDO=W$I*Y>~Y1D3sxijfYDB| z;S>?=OrRGAXa=SL;O!-<&W|EEr^+Deb%#82iuf}tui(sJP+OG;BMLF`T#&O;&P+b{ z)1zm^7J0!5DI!Hxv2=vlAXhv+XR0el-H#n^M_HopWMGSH9*@B2)6x9N^;kjsU)qAu@o&{r?A>6tUV zyrJ~Xv-=7hZl=F=ytxPHCW|+-504L z^QUGNqtbuba;3J}+*|Y+)W3=YPGafjAofY?ZlfS}7swRV#UpIyJGnVqMBal84B)_` z&QMV>^BFJTOzXm?)C@^kt1rOusb$XSsnp3Yb`XRcWaI$s0=jNoXNJDxd{2*_tqY{# zS5y%8jwVRBXH>>!M3K;<0NM*tzZ5{@yq`Ivh1B`(<{4I&p!gZ)(RDem1%V1#8AJe? zA!jrtqJntY$b9e=Xi^On5$QHAn9k*FLofkWHM7kPKP1-1igZd%fN)Qc;HXSIkGeT$ z5Aed8W!DysuxbA;=Q#{z)uqR~Y!OcE5fOnHB2YDP&l%6T6si{9jFDL>Hm$169 zD^+xLA6-&0`5ic>+!CA^)x8jvATRY3t2t0Xj`TAtd{RM< z^)p{^qJq4(lUcO~zz{3}JD!D)l>A8rNrr*&0*^TP;_>+>um9Av;(iwY&*ah8qjvoF z%Vqw_m zt4>K1D@!?%m+(}0qA`3Pgkt?>})l^F`%_uid3=ik|^c3N4h! zu4@Q)Ywj;12Plvj_|@}5r4$===n&6U5M{5%$~{mse8wv+dBEn^1OLuC$d?tBGxFm3 zPycB$m{EO(5kYF0VfwOtADn#TjMg>FA~=6B&q2x&^QuA#i}9L()}j_+mi8JsiV}Yc zjPE3x3dN|86N{7eyl`JoH_Nm*xx!l+Y-?AI+BsPNrVG#&+Gb3peBK)HxJZjTkh8{$U0=WrRI?!d1D`tKa3pV9xnJ(+AZ>Hp?r z^Whiz{~7831EBuu%SY;R;os}t=Oa)OeO{*S{&b@X7$sRF z)!xQ(>}`00;$QU89^mBu6M#YY}@$UZd#jBpNKq-d;;50H&Civ2 zY)U!iVt_{znIHu%1@x}z9ESkSb(FH2O_3`zlF@6_i$Tlpq?!IF*1JHm#E&G@mr-o+ zt80i!XyV%SKRV{AdA9uU5_iRv$z&D>HtI#^@>{01_Bw-Yyygbi*gMIJR5TnkEBr#H zmae~f7wT>Y{?JW>#VEF4tX*y%^My6fPEf1Qt@UrUGAwyNsJh=YHrlVg9W(h`)%3V& z=L%0N{0LkC0`Eg_9-O-lxAmo(wiQ1(l+c!+)@3!~g|qpFpWH%AE&0~3vKWCAL?~aE zD(P$sXP}q^)q}v^PNo^$X^8!wpU?32H!nW97(0d4XvV3 z1xpwM?U0~{$m6))x2gTFMk)RRms}zHAR^!7{*F(Iu3I+I8kz7DPoohE8Vkt#)CfFM74B zw|lkQEx{g|8t&Sb$n7j%1^0IS_TY%W*arW3>%ad}h47a6ukD?Ot@`gt)uM0E0eENo=WezSj87q@g!O#4x1^#2mmD#FR4#x!6U+rqJ>i*g)xpvNwA1L^$|M zlXVk#+zpr6ZwcHt-EMZ@WZ9?z+|hcw1HOqFo?LBWTkh^d4}u4737S$nD@<5eDDW?q zdf~&INVftWW{H}|`a@N#Je(_B;GKhZMlX~X4J~64=ryaI@MZDa1FH{Suxp2x>Q+AP zW5uqR91KmR(J&N1yg4`-G}YA>u6rMKXd!w7WudEwuztO6l~LJ1P*toOqx+JwPJe3v z80c#Lp$25ehHM0Nua)Wa6JCSWOP{Mm>c<}*v$CYjcoQd+_Q5Ei_X+KlpzKN4JM(C?VVgWt=vn9!{0u z&tV1^iO0+&o|&JBIhPEB@kHz*0M++0>|%AO#SjBblncVH3d*<4XjL)zK!z-lz;(uC zyl#Y4y3%B^N6B|>nX=ek7UxH@;c8_?|2$_F%k<|HFc3u-2X5E{RP4eH5#_v2SWMQF zI(MH2TPF(_!{AptTXFN8X+xC{>iQCk4eb-l!pf?eUxL{QrbPQht^qmQi&ab?5f=h?_EP{o@7|e##2#w#K@@YIA{XeevdFAGO1_MlIp6v02Wy zbzM!_+OPqV|HCJ?j;WFEa6tu!xaydksUVEP%5@kIA!&9Y0MwM9&m=$tga8#{OjY&I zZr9AxQl{qq`ej9fJRVh1G{}Eh&ROrs#88}aRY-%(jX@_p^9^!c1wWNFY$+kM5qgTX zo8Ual|A(Cb`n4I#R@DFeXmJ3yA%jY0G7FVf=QGa=>UN{U{6sM~|_ zKb2V7Ek<@PI4Gjju)c*tlpoaZ*E|aD*cTtymw#XWefjs#^zZ)zgxLS|0MGycEu|Y$ literal 0 HcmV?d00001 diff --git a/packages/iios-messaging-ui/src/adapter.ts b/packages/iios-messaging-ui/src/adapter.ts index 1a13ff4..6df76e5 100644 --- a/packages/iios-messaging-ui/src/adapter.ts +++ b/packages/iios-messaging-ui/src/adapter.ts @@ -1,6 +1,8 @@ import type { Attachment, + ChannelSummary, Conversation, + CreateChannelInput, Membership, Message, MessageEvent, @@ -53,4 +55,20 @@ export interface MessagingAdapter { /** Absent => treated as always connected (e.g. a pure-REST adapter). */ isConnected?(): boolean; + + // ── Channels (optional capability) ────────────────────────────── + // A channel is just a third membership beyond dm/group: a discoverable, joinable room. + // Implement all four to enable the channels UI; absent => the UI hides channels entirely. + + /** Discoverable channels in the caller's scope, each flagged `joined`. */ + browseChannels?(): Promise; + + /** Create a channel; the creator joins as admin. Returns the new thread id. */ + createChannel?(input: CreateChannelInput): Promise<{ threadId: string }>; + + /** Join a (public) channel by id. */ + joinChannel?(threadId: string): Promise; + + /** Leave a channel by id. */ + leaveChannel?(threadId: string): Promise; } diff --git a/packages/iios-messaging-ui/src/adapters/kernel-client.ts b/packages/iios-messaging-ui/src/adapters/kernel-client.ts index 3f3541e..5c148bc 100644 --- a/packages/iios-messaging-ui/src/adapters/kernel-client.ts +++ b/packages/iios-messaging-ui/src/adapters/kernel-client.ts @@ -188,7 +188,9 @@ export class KernelClientAdapter implements MessagingAdapter { private toConversation(t: ThreadSummary): Conversation { const others = t.participants.filter((p) => p !== this.me); - const membership: Membership | null = t.membership === 'dm' || t.membership === 'group' ? t.membership : null; + const membership: Membership | null = + t.membership === 'dm' || t.membership === 'group' || t.membership === 'channel' ? t.membership : null; + const topic = (t.metadata as { topic?: string } | null)?.topic; return { threadId: t.threadId, title: t.subject?.trim() || others.join(', ') || 'Conversation', @@ -196,6 +198,7 @@ export class KernelClientAdapter implements MessagingAdapter { membership, participants: [...t.participants], unread: t.unread, + ...(topic != null ? { topic } : {}), ...(t.lastMessage ? { lastMessage: t.lastMessage } : {}), ...(t.lastAt ? { lastAt: t.lastAt } : {}), }; diff --git a/packages/iios-messaging-ui/src/adapters/mock.ts b/packages/iios-messaging-ui/src/adapters/mock.ts index fbd31b8..7d81433 100644 --- a/packages/iios-messaging-ui/src/adapters/mock.ts +++ b/packages/iios-messaging-ui/src/adapters/mock.ts @@ -1,7 +1,10 @@ import type { MessagingAdapter } from '../adapter'; import type { Attachment, + ChannelSummary, + ChannelVisibility, Conversation, + CreateChannelInput, Membership, Message, MessageEvent, @@ -26,6 +29,8 @@ interface MockThread { subject: string | null; participants: string[]; messages: Message[]; + topic?: string | null; + visibility?: ChannelVisibility; } const nameById = new Map(MOCK_PEOPLE.map((p) => [p.id, p.name])); @@ -66,6 +71,21 @@ export class MockAdapter implements MessagingAdapter { { id: 'm2', actorId: 'pp_dan', text: 'Crew is rolling out at 7.', at: seededAt, reactions: [] }, ], }); + // Channels: a joined public one, a joinable public one (I'm NOT in it → shows in browse only), + // and a private one I'm a member of. + this.threads.set('th_ch_general', { + threadId: 'th_ch_general', membership: 'channel', subject: 'general', topic: 'Company-wide chatter', + visibility: 'public', participants: [ME, 'pp_dan', 'pp_priya', 'pp_sofia'], + messages: [{ id: 'c1', actorId: 'pp_priya', text: 'Welcome to #general 👋', at: seededAt, reactions: [] }], + }); + this.threads.set('th_ch_random', { + threadId: 'th_ch_random', membership: 'channel', subject: 'random', topic: 'Non-work banter', + visibility: 'public', participants: ['pp_dan', 'pp_sofia'], messages: [], + }); + this.threads.set('th_ch_deals', { + threadId: 'th_ch_deals', membership: 'channel', subject: 'deals', topic: 'Big pipeline moves', + visibility: 'private', participants: [ME, 'pp_sofia'], messages: [], + }); } currentActorId(): string { @@ -73,19 +93,61 @@ export class MockAdapter implements MessagingAdapter { } async listConversations(): Promise { - return [...this.threads.values()].map((t) => { - const last = t.messages[t.messages.length - 1]; - const others = t.participants.filter((p) => p !== ME); - return { + // Only threads I'm a member of — an un-joined public channel appears in browse, not here. + return [...this.threads.values()] + .filter((t) => t.participants.includes(ME)) + .map((t) => { + const last = t.messages[t.messages.length - 1]; + const others = t.participants.filter((p) => p !== ME); + return { + threadId: t.threadId, + title: t.subject || others.map((id) => nameById.get(id) ?? id).join(', ') || 'Conversation', + subject: t.subject, + membership: t.membership, + participants: [...t.participants], + unread: 0, + ...(t.topic != null ? { topic: t.topic } : {}), + ...(last ? { lastMessage: last.text, lastAt: last.at } : {}), + }; + }); + } + + async browseChannels(): Promise { + // Public channels are discoverable; private ones only if I'm already a member. + return [...this.threads.values()] + .filter((t) => t.membership === 'channel' && (t.visibility === 'public' || t.participants.includes(ME))) + .map((t) => ({ threadId: t.threadId, - title: t.subject || others.map((id) => nameById.get(id) ?? id).join(', ') || 'Conversation', - subject: t.subject, - membership: t.membership, - participants: [...t.participants], - unread: 0, - ...(last ? { lastMessage: last.text, lastAt: last.at } : {}), - }; + name: t.subject ?? 'channel', + topic: t.topic ?? null, + visibility: t.visibility ?? 'public', + memberCount: t.participants.length, + joined: t.participants.includes(ME), + })); + } + + async createChannel(input: CreateChannelInput): Promise<{ threadId: string }> { + const threadId = `th_ch_${this.seq++}`; + this.threads.set(threadId, { + threadId, + membership: 'channel', + subject: input.name, + topic: input.topic ?? null, + visibility: input.visibility, + participants: [ME], + messages: [], }); + return { threadId }; + } + + async joinChannel(threadId: string): Promise { + const t = this.threads.get(threadId); + if (t && !t.participants.includes(ME)) t.participants = [...t.participants, ME]; + } + + async leaveChannel(threadId: string): Promise { + const t = this.threads.get(threadId); + if (t) t.participants = t.participants.filter((p) => p !== ME); } async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> { diff --git a/packages/iios-messaging-ui/src/components/channel-browser.tsx b/packages/iios-messaging-ui/src/components/channel-browser.tsx new file mode 100644 index 0000000..876eeba --- /dev/null +++ b/packages/iios-messaging-ui/src/components/channel-browser.tsx @@ -0,0 +1,93 @@ +import { useState, type FormEvent } from 'react'; +import { useChannels } from '../hooks/use-channels'; +import type { ChannelVisibility } from '../types'; + +/** Browse + join discoverable channels, and create a new one. Shown in the main pane. */ +export function ChannelBrowser({ onJoined }: { onJoined?: (threadId: string) => void }) { + const { browsable, loading, error, join, create } = useChannels(); + const [name, setName] = useState(''); + const [topic, setTopic] = useState(''); + const [visibility, setVisibility] = useState('public'); + const [busy, setBusy] = useState(false); + + async function submitCreate(e: FormEvent): Promise { + e.preventDefault(); + const n = name.trim(); + if (!n || busy) return; + setBusy(true); + try { + const threadId = await create({ name: n, ...(topic.trim() ? { topic: topic.trim() } : {}), visibility }); + setName(''); + setTopic(''); + onJoined?.(threadId); + } catch { + // surfaced via the hook's error + } finally { + setBusy(false); + } + } + + async function doJoin(threadId: string): Promise { + await join(threadId); + onJoined?.(threadId); + } + + return ( +
+
Channels
+ +
+ setName(e.target.value)} + placeholder="New channel name" + aria-label="Channel name" + /> + setTopic(e.target.value)} + placeholder="Topic (optional)" + aria-label="Channel topic" + /> +
+ + +
+ +
+ +
+ {loading && browsable.length === 0 ?
Loading…
: null} + {error ?
{error}
: null} + {!loading && browsable.length === 0 ?
No channels yet — create one above.
: null} + {browsable.map((c) => ( +
+ + + {c.name} + {c.topic ? {c.topic} : null} + + {c.memberCount} member{c.memberCount === 1 ? '' : 's'} + + + {c.joined ? ( + Joined + ) : ( + + )} +
+ ))} +
+
+ ); +} diff --git a/packages/iios-messaging-ui/src/components/channels.test.tsx b/packages/iios-messaging-ui/src/components/channels.test.tsx new file mode 100644 index 0000000..05cfe61 --- /dev/null +++ b/packages/iios-messaging-ui/src/components/channels.test.tsx @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MessagingProvider } from '../provider'; +import { Messenger } from './messenger'; +import { MockAdapter } from '../adapters/mock'; + +function mount() { + return render( + + + , + ); +} + +describe('channels (mock adapter)', () => { + it('browse returns public channels + private ones I am in, with a joined flag', async () => { + const a = new MockAdapter(); + const list = await a.browseChannels(); + const byId = new Map(list.map((c) => [c.threadId, c])); + expect(byId.get('th_ch_general')?.joined).toBe(true); // public, I'm in + expect(byId.get('th_ch_random')?.joined).toBe(false); // public, I'm NOT in + expect(byId.get('th_ch_deals')?.joined).toBe(true); // private, I'm in + // A private channel I'm not in must never surface in browse — none seeded, so all private here are mine. + expect(list.every((c) => c.visibility === 'public' || c.joined)).toBe(true); + }); + + it('join adds me and the channel then appears in my conversation list', async () => { + const a = new MockAdapter(); + expect((await a.listConversations()).some((c) => c.threadId === 'th_ch_random')).toBe(false); + await a.joinChannel('th_ch_random'); + expect((await a.listConversations()).some((c) => c.threadId === 'th_ch_random')).toBe(true); + expect((await a.browseChannels()).find((c) => c.threadId === 'th_ch_random')?.joined).toBe(true); + }); + + it('create makes a channel I am a member of', async () => { + const a = new MockAdapter(); + const { threadId } = await a.createChannel({ name: 'design', topic: 'UI stuff', visibility: 'public' }); + const conv = (await a.listConversations()).find((c) => c.threadId === threadId); + expect(conv?.membership).toBe('channel'); + expect(conv?.topic).toBe('UI stuff'); + }); + + it('UI: browse, then join a channel — it moves into the Channels section', async () => { + mount(); + // Open the browser via the Channels section "+". + fireEvent.click(await screen.findByTitle('Browse channels')); + // #random is browse-only (not joined) → has a Join button. + expect(await screen.findByText('random')).toBeTruthy(); + const joinButtons = screen.getAllByText('Join'); + fireEvent.click(joinButtons[0]!); + // After joining, we jump to the thread and the browser closes → composer is shown. + await waitFor(() => expect(screen.getByLabelText('Message')).toBeTruthy()); + }); +}); diff --git a/packages/iios-messaging-ui/src/components/conversation-list.tsx b/packages/iios-messaging-ui/src/components/conversation-list.tsx index 14e75fb..828a910 100644 --- a/packages/iios-messaging-ui/src/components/conversation-list.tsx +++ b/packages/iios-messaging-ui/src/components/conversation-list.tsx @@ -33,7 +33,7 @@ export function ConversationList({ onClick={() => onSelect(c.threadId)} > {c.title} diff --git a/packages/iios-messaging-ui/src/components/messenger.test.tsx b/packages/iios-messaging-ui/src/components/messenger.test.tsx index 1a78968..d58e0b1 100644 --- a/packages/iios-messaging-ui/src/components/messenger.test.tsx +++ b/packages/iios-messaging-ui/src/components/messenger.test.tsx @@ -23,9 +23,8 @@ describe(' (rendered UI over an adapter)', () => { it('sends a message through the adapter and shows it in the thread', async () => { mount(); - await screen.findByText('Crew is rolling out at 7.'); - - const input = screen.getByLabelText('Message') as HTMLInputElement; + // Wait for the auto-selected thread's composer (avoids a race with the auto-select effect). + const input = (await screen.findByLabelText('Message')) as HTMLInputElement; fireEvent.change(input, { target: { value: 'on our way' } }); fireEvent.click(screen.getByText('Send')); diff --git a/packages/iios-messaging-ui/src/components/messenger.tsx b/packages/iios-messaging-ui/src/components/messenger.tsx index da4cc7e..604df07 100644 --- a/packages/iios-messaging-ui/src/components/messenger.tsx +++ b/packages/iios-messaging-ui/src/components/messenger.tsx @@ -1,31 +1,80 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useConversations } from '../hooks/use-conversations'; +import { useAdapter } from '../provider'; import { ConversationList } from './conversation-list'; +import { ChannelBrowser } from './channel-browser'; import { Thread } from './thread'; /** - * The drop-in messenger: conversation list + open thread. Owns only selection state; - * all data flows through the injected adapter via the hooks. Auto-selects the first - * conversation so the pane is never empty on load. + * The drop-in messenger: sectioned conversation list (Channels / Direct messages) + open thread, + * with a channel browser when the adapter supports channels. Owns only selection + browse state; + * all data flows through the injected adapter via the hooks. */ export function Messenger() { - const { conversations, loading, error } = useConversations(); + const { conversations, loading, error, refetch } = useConversations(); + const adapter = useAdapter(); + const channelsSupported = typeof adapter.browseChannels === 'function'; + const [selected, setSelected] = useState(null); + const [browsing, setBrowsing] = useState(false); useEffect(() => { + if (browsing) return; if (selected && conversations.some((c) => c.threadId === selected)) return; setSelected(conversations[0]?.threadId ?? null); - }, [conversations, selected]); + }, [conversations, selected, browsing]); + + const channels = useMemo(() => conversations.filter((c) => c.membership === 'channel'), [conversations]); + const dms = useMemo(() => conversations.filter((c) => c.membership !== 'channel'), [conversations]); + + function pick(threadId: string): void { + setBrowsing(false); + setSelected(threadId); + } return (
+
- + {browsing ? ( + { + refetch(); + pick(threadId); + }} + /> + ) : ( + + )}
); diff --git a/packages/iios-messaging-ui/src/hooks/use-channels.ts b/packages/iios-messaging-ui/src/hooks/use-channels.ts new file mode 100644 index 0000000..de47e3d --- /dev/null +++ b/packages/iios-messaging-ui/src/hooks/use-channels.ts @@ -0,0 +1,84 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useAdapter } from '../provider'; +import type { ChannelSummary, CreateChannelInput } from '../types'; + +export interface ChannelsState { + /** Discoverable channels (public + private-I'm-in), each flagged `joined`. */ + browsable: ChannelSummary[]; + loading: boolean; + error: string | null; + /** Whether the adapter implements channels at all — drives showing/hiding the channels UI. */ + supported: boolean; + refetch: () => void; + create: (input: CreateChannelInput) => Promise; + join: (threadId: string) => Promise; + leave: (threadId: string) => Promise; +} + +export function useChannels(): ChannelsState { + const adapter = useAdapter(); + const supported = typeof adapter.browseChannels === 'function'; + const [browsable, setBrowsable] = useState([]); + const [loading, setLoading] = useState(supported); + const [error, setError] = useState(null); + const [nonce, setNonce] = useState(0); + + useEffect(() => { + if (!adapter.browseChannels) { + setLoading(false); + return; + } + let alive = true; + setLoading(true); + adapter + .browseChannels() + .then((list) => { + if (!alive) return; + setBrowsable(list); + setError(null); + }) + .catch((e: unknown) => { + if (!alive) return; + setError(e instanceof Error ? e.message : String(e)); + setBrowsable([]); + }) + .finally(() => { + if (alive) setLoading(false); + }); + return () => { + alive = false; + }; + }, [adapter, nonce]); + + const refetch = useCallback(() => setNonce((n) => n + 1), []); + + const create = useCallback( + async (input: CreateChannelInput) => { + if (!adapter.createChannel) throw new Error('channels not supported by this adapter'); + const { threadId } = await adapter.createChannel(input); + setNonce((n) => n + 1); + return threadId; + }, + [adapter], + ); + + const join = useCallback( + async (threadId: string) => { + if (!adapter.joinChannel) throw new Error('channels not supported by this adapter'); + await adapter.joinChannel(threadId); + setNonce((n) => n + 1); + }, + [adapter], + ); + + const leave = useCallback( + async (threadId: string) => { + if (!adapter.leaveChannel) throw new Error('channels not supported by this adapter'); + await adapter.leaveChannel(threadId); + setNonce((n) => n + 1); + }, + [adapter], + ); + + return { browsable, loading, error, supported, refetch, create, join, leave }; +} diff --git a/packages/iios-messaging-ui/src/hooks/use-conversations.test.tsx b/packages/iios-messaging-ui/src/hooks/use-conversations.test.tsx index 0970680..8413071 100644 --- a/packages/iios-messaging-ui/src/hooks/use-conversations.test.tsx +++ b/packages/iios-messaging-ui/src/hooks/use-conversations.test.tsx @@ -17,7 +17,8 @@ describe('useConversations', () => { expect(result.current.loading).toBe(true); await waitFor(() => expect(result.current.loading).toBe(false)); - expect(result.current.conversations).toHaveLength(2); + // 2 DMs/groups + 2 channels I'm a member of (the un-joined public channel is browse-only). + expect(result.current.conversations).toHaveLength(4); expect(result.current.conversations[0]!.threadId).toBe('th_mock_1'); expect(result.current.error).toBeNull(); }); @@ -35,7 +36,7 @@ describe('useConversations', () => { it('refetch picks up newly opened threads', async () => { const adapter = new MockAdapter(); const { result } = renderHook(() => useConversations(), { wrapper: wrap(adapter) }); - await waitFor(() => expect(result.current.conversations).toHaveLength(2)); + await waitFor(() => expect(result.current.conversations).toHaveLength(4)); await act(async () => { await adapter.openThread({ participantIds: ['pp_dan'] }); @@ -44,6 +45,6 @@ describe('useConversations', () => { result.current.refetch(); }); - await waitFor(() => expect(result.current.conversations).toHaveLength(3)); + await waitFor(() => expect(result.current.conversations).toHaveLength(5)); }); }); diff --git a/packages/iios-messaging-ui/src/index.ts b/packages/iios-messaging-ui/src/index.ts index 7558faa..dd18ac0 100644 --- a/packages/iios-messaging-ui/src/index.ts +++ b/packages/iios-messaging-ui/src/index.ts @@ -6,19 +6,25 @@ export { MessagingProvider, useAdapter } from './provider'; export { useConversations } from './hooks/use-conversations'; export { useMessages } from './hooks/use-messages'; +export { useChannels } from './hooks/use-channels'; export { isOwnMessage } from './types'; // Rendered UI. Pair with the './styles.css' export (or override the --miu-* tokens). export { Messenger } from './components/messenger'; export { ConversationList } from './components/conversation-list'; +export { ChannelBrowser } from './components/channel-browser'; export { Thread } from './components/thread'; export type { MessagingAdapter } from './adapter'; export type { ConversationsState } from './hooks/use-conversations'; export type { MessagesState, UiMessage } from './hooks/use-messages'; +export type { ChannelsState } from './hooks/use-channels'; export type { Attachment, + ChannelSummary, + ChannelVisibility, Conversation, + CreateChannelInput, Membership, Message, MessageEvent, diff --git a/packages/iios-messaging-ui/src/styles.css b/packages/iios-messaging-ui/src/styles.css index 43efa1f..648761b 100644 --- a/packages/iios-messaging-ui/src/styles.css +++ b/packages/iios-messaging-ui/src/styles.css @@ -209,6 +209,132 @@ cursor: not-allowed; } +/* sidebar sections + channel browser */ +.miu-section { + border-bottom: 1px solid var(--miu-border); +} +.miu-section-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px 4px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--miu-muted); +} +.miu-section-add { + border: none; + background: transparent; + color: var(--miu-muted); + cursor: pointer; + font-size: 16px; + line-height: 1; + padding: 0 4px; +} +.miu-section-add:hover { + color: var(--miu-text); +} +.miu-empty-sm { + padding: 8px 14px 12px; + text-align: left; + font-size: 12.5px; +} + +.miu-browser { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + padding: 16px; + gap: 14px; + overflow-y: auto; +} +.miu-browser-head { + font-size: 16px; + font-weight: 700; +} +.miu-channel-create { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + border: 1px solid var(--miu-border); + border-radius: var(--miu-radius); + background: var(--miu-panel); +} +.miu-channel-vis { + display: flex; + gap: 16px; + font-size: 13px; + color: var(--miu-muted); +} +.miu-channel-vis label { + display: inline-flex; + align-items: center; + gap: 5px; + cursor: pointer; +} +.miu-browser-list { + display: flex; + flex-direction: column; + gap: 6px; +} +.miu-browser-row { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--miu-border); + border-radius: var(--miu-radius); + background: var(--miu-panel); +} +.miu-channel-glyph { + width: 30px; + height: 30px; + flex-shrink: 0; + border-radius: 8px; + display: grid; + place-items: center; + font-weight: 700; + color: var(--miu-muted); + background: var(--miu-panel-2); +} +.miu-browser-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} +.miu-browser-name { + font-weight: 600; +} +.miu-browser-topic { + font-size: 12.5px; + color: var(--miu-muted); +} +.miu-browser-meta { + font-size: 11.5px; + color: var(--miu-muted); +} +.miu-join { + flex-shrink: 0; + padding: 5px 14px; + border-radius: 8px; + border: none; + font-weight: 700; + cursor: pointer; + color: var(--miu-accent-text); + background: var(--miu-accent); +} +.miu-browser-joined { + flex-shrink: 0; + font-size: 12px; + color: var(--miu-muted); +} + /* misc */ .miu-empty { padding: 24px; diff --git a/packages/iios-messaging-ui/src/types.ts b/packages/iios-messaging-ui/src/types.ts index ae99f9b..ff667da 100644 --- a/packages/iios-messaging-ui/src/types.ts +++ b/packages/iios-messaging-ui/src/types.ts @@ -1,7 +1,9 @@ // Domain types for the messaging UI. Zero imports on purpose: this file must never // reach for a transport package. Adapters map their own DTOs onto these. -export type Membership = 'dm' | 'group'; +export type Membership = 'dm' | 'group' | 'channel'; + +export type ChannelVisibility = 'public' | 'private'; export interface Person { id: string; @@ -18,6 +20,25 @@ export interface Conversation { unread: number; lastMessage?: string; lastAt?: string; + /** Channel description (channels only). */ + topic?: string | null; +} + +/** A discoverable channel (from browseChannels) — includes ones the caller has NOT joined. */ +export interface ChannelSummary { + threadId: string; + name: string; + topic: string | null; + visibility: ChannelVisibility; + memberCount: number; + /** True if the current user is already a member. */ + joined: boolean; +} + +export interface CreateChannelInput { + name: string; + topic?: string; + visibility: ChannelVisibility; } export interface Reaction { From cb9a0b925000b74dc316aa40919e54384038fdaf Mon Sep 17 00:00:00 2001 From: maaz519 Date: Tue, 21 Jul 2026 20:10:38 +0530 Subject: [PATCH 14/26] =?UTF-8?q?feat(messaging-ui):=20@mentions=20?= =?UTF-8?q?=E2=80=94=20autocomplete,=20resolve=20to=20ids,=20highlight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - contract: SendOpts.mentions (opaque userId notify-list) + optional listMembers(threadId) for autocomplete/highlight (capability-degrading) - mock: listMembers; KernelClientAdapter.send forwards mentions to the socket (which already carries them → inbox MENTION items) - composer: type @ → member/@channel/@here autocomplete; Enter picks the first; on send, mentions resolve to ids; message bodies highlight @mentions - useMembers hook; mentions.tsx helpers (trailingMentionQuery/insertMention/ resolveMentions/highlightMentions) - 4 mention tests (helpers + render autocomplete→send carries id); 56 total green Co-Authored-By: Claude Opus 4.8 --- packages/iios-messaging-ui/src/adapter.ts | 5 ++ .../src/adapters/kernel-client.ts | 10 +-- .../iios-messaging-ui/src/adapters/mock.ts | 9 +++ .../src/components/thread.tsx | 69 ++++++++++++++++--- .../src/hooks/use-members.ts | 31 +++++++++ packages/iios-messaging-ui/src/index.ts | 1 + .../iios-messaging-ui/src/mentions.test.tsx | 55 +++++++++++++++ packages/iios-messaging-ui/src/mentions.tsx | 51 ++++++++++++++ packages/iios-messaging-ui/src/styles.css | 42 +++++++++++ packages/iios-messaging-ui/src/types.ts | 2 + 10 files changed, 260 insertions(+), 15 deletions(-) create mode 100644 packages/iios-messaging-ui/src/hooks/use-members.ts create mode 100644 packages/iios-messaging-ui/src/mentions.test.tsx create mode 100644 packages/iios-messaging-ui/src/mentions.tsx diff --git a/packages/iios-messaging-ui/src/adapter.ts b/packages/iios-messaging-ui/src/adapter.ts index 6df76e5..1670e96 100644 --- a/packages/iios-messaging-ui/src/adapter.ts +++ b/packages/iios-messaging-ui/src/adapter.ts @@ -6,6 +6,7 @@ import type { Membership, Message, MessageEvent, + Person, SendOpts, Unsubscribe, } from './types'; @@ -56,6 +57,10 @@ export interface MessagingAdapter { /** Absent => treated as always connected (e.g. a pure-REST adapter). */ isConnected?(): boolean; + /** A thread's members (id + display name), for @mention autocomplete + highlighting. + * Absent => the composer offers no autocomplete (you can still type @text). */ + listMembers?(threadId: string): Promise; + // ── Channels (optional capability) ────────────────────────────── // A channel is just a third membership beyond dm/group: a discoverable, joinable room. // Implement all four to enable the channels UI; absent => the UI hides channels entirely. diff --git a/packages/iios-messaging-ui/src/adapters/kernel-client.ts b/packages/iios-messaging-ui/src/adapters/kernel-client.ts index 5c148bc..148fdbb 100644 --- a/packages/iios-messaging-ui/src/adapters/kernel-client.ts +++ b/packages/iios-messaging-ui/src/adapters/kernel-client.ts @@ -133,11 +133,11 @@ export class KernelClientAdapter implements MessagingAdapter { // Attachments are intentionally not forwarded here: kernel-client exposes no media/presign yet, // and the SDK Attachment carries a display `url`, not a storage `contentRef`. Media is a follow-up // (kernel-client media methods + a contentRef on Attachment). Text + reply threading work today. - const m = await this.socket.sendMessage( - threadId, - content, - opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : undefined, - ); + const sendOpts = { + ...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}), + ...(opts?.mentions && opts.mentions.length ? { mentions: opts.mentions } : {}), + }; + const m = await this.socket.sendMessage(threadId, content, Object.keys(sendOpts).length ? sendOpts : undefined); return this.toMessage(m); } diff --git a/packages/iios-messaging-ui/src/adapters/mock.ts b/packages/iios-messaging-ui/src/adapters/mock.ts index 7d81433..d3c8ad7 100644 --- a/packages/iios-messaging-ui/src/adapters/mock.ts +++ b/packages/iios-messaging-ui/src/adapters/mock.ts @@ -150,6 +150,15 @@ export class MockAdapter implements MessagingAdapter { if (t) t.participants = t.participants.filter((p) => p !== ME); } + async listMembers(threadId: string): Promise { + const t = this.threads.get(threadId); + if (!t) return []; + return t.participants.map((id) => { + if (id === ME) return { id: ME, name: 'You', kind: 'staff' }; + return MOCK_PEOPLE.find((p) => p.id === id) ?? { id, name: id, kind: 'staff' }; + }); + } + async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> { const membership = p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group'); const threadId = `th_mock_${this.seq++}`; diff --git a/packages/iios-messaging-ui/src/components/thread.tsx b/packages/iios-messaging-ui/src/components/thread.tsx index 0f44569..a67911a 100644 --- a/packages/iios-messaging-ui/src/components/thread.tsx +++ b/packages/iios-messaging-ui/src/components/thread.tsx @@ -1,31 +1,68 @@ -import { useState, type FormEvent } from 'react'; +import { useMemo, useState, type FormEvent, type KeyboardEvent } from 'react'; import { useMessages } from '../hooks/use-messages'; +import { useMembers } from '../hooks/use-members'; +import { + SPECIAL_MENTIONS, + highlightMentions, + insertMention, + resolveMentions, + trailingMentionQuery, +} from '../mentions'; + +interface Suggestion { + key: string; + label: string; + insert: string; +} /** - * One conversation: message list + composer, driven entirely by useMessages (which - * handles history, live subscribe, optimistic send, typing, and seen state). Renders - * nothing transport-specific — swap the adapter and this is unchanged. + * One conversation: message list + composer, driven by useMessages. Adds @mention autocomplete + * (from the thread's members) and highlights mentions in message bodies. Transport-agnostic. */ export function Thread({ threadId }: { threadId: string | null }) { const { messages, loading, error, send, typingUserIds, seenIds, sendTyping } = useMessages(threadId); + const members = useMembers(threadId); const [draft, setDraft] = useState(''); const [sending, setSending] = useState(false); - async function submit(e: FormEvent): Promise { - e.preventDefault(); + const memberNames = useMemo(() => members.map((m) => m.name), [members]); + const query = trailingMentionQuery(draft); + const suggestions = useMemo(() => { + if (query === null) return []; + const q = query.toLowerCase(); + const specials = SPECIAL_MENTIONS.filter((s) => s.startsWith(q)).map((s) => ({ key: `@${s}`, label: `@${s}`, insert: s })); + const people = members.filter((m) => m.name.toLowerCase().includes(q)).map((m) => ({ key: m.id, label: m.name, insert: m.name })); + return [...specials, ...people].slice(0, 6); + }, [query, members]); + const showSuggest = query !== null && suggestions.length > 0; + + function pick(insert: string): void { + setDraft((d) => insertMention(d, insert)); + } + + async function submit(e?: FormEvent): Promise { + e?.preventDefault(); const text = draft.trim(); if (!text || sending) return; + const mentions = resolveMentions(text, members); setDraft(''); setSending(true); try { - await send(text); + await send(text, mentions.length ? { mentions } : undefined); } catch { - // The error is surfaced via the hook's `error`; keep the composer usable. + // surfaced via the hook's error } finally { setSending(false); } } + function onKeyDown(e: KeyboardEvent): void { + if (showSuggest && e.key === 'Enter') { + e.preventDefault(); + pick(suggestions[0]!.insert); + } + } + if (!threadId) { return
Select a conversation.
; } @@ -37,7 +74,7 @@ export function Thread({ threadId }: { threadId: string | null }) { {error ?
{error}
: null} {messages.map((m) => (
-
{m.text}
+
{highlightMentions(m.text, memberNames)}
{m.reactions && m.reactions.length > 0 ? (
{m.reactions.map((r) => ( @@ -56,15 +93,27 @@ export function Thread({ threadId }: { threadId: string | null }) {
+ {showSuggest ? ( +
    + {suggestions.map((s) => ( +
  • + +
  • + ))} +
+ ) : null} { setDraft(e.target.value); sendTyping(); }} + onKeyDown={onKeyDown} /> + {pickerFor === m.id ? ( +
+ {REACTION_EMOJIS.map((e) => ( + + ))} +
+ ) : null} +
+ ) : null} + {m.reactions && m.reactions.length > 0 ? (
{m.reactions.map((r) => ( - + ))}
) : null} @@ -104,20 +176,38 @@ export function Thread({ threadId }: { threadId: string | null }) { ))} ) : null} - { - setDraft(e.target.value); - sendTyping(); - }} - onKeyDown={onKeyDown} - /> - + {staged ? ( +
+ 📎 {staged.name} + +
+ ) : null} +
+ {canUpload ? ( + <> + + + + ) : null} + { + setDraft(e.target.value); + sendTyping(); + }} + onKeyDown={onKeyDown} + /> + +
); diff --git a/packages/iios-messaging-ui/src/hooks/use-messages.ts b/packages/iios-messaging-ui/src/hooks/use-messages.ts index d50954d..ccb49a6 100644 --- a/packages/iios-messaging-ui/src/hooks/use-messages.ts +++ b/packages/iios-messaging-ui/src/hooks/use-messages.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useAdapter } from '../provider'; import { isOwnMessage } from '../types'; -import type { Message, SendOpts } from '../types'; +import type { Attachment, Message, SendOpts } from '../types'; const TYPING_TTL_MS = 3500; @@ -15,6 +15,7 @@ export interface MessagesState { error: string | null; send: (content: string, opts?: SendOpts) => Promise; react: (messageId: string, emoji: string) => Promise; + upload: (file: File) => Promise; typingUserIds: string[]; seenIds: Set; sendTyping: () => void; @@ -147,6 +148,14 @@ export function useMessages(threadId: string | null): MessagesState { [adapter, threadId], ); + const upload = useCallback( + async (file: File): Promise => { + if (!adapter.upload) throw new Error('uploads are not supported by this adapter'); + return adapter.upload(file); + }, + [adapter], + ); + const sendTyping = useCallback(() => { if (threadId) adapter.sendTyping(threadId); }, [adapter, threadId]); @@ -196,6 +205,7 @@ export function useMessages(threadId: string | null): MessagesState { error, send, react, + upload, typingUserIds, seenIds: seenMine, sendTyping, diff --git a/packages/iios-messaging-ui/src/styles.css b/packages/iios-messaging-ui/src/styles.css index 3017cde..2d0d0e5 100644 --- a/packages/iios-messaging-ui/src/styles.css +++ b/packages/iios-messaging-ui/src/styles.css @@ -149,6 +149,57 @@ .miu-msg.is-pending { opacity: 0.6; } +.miu-bubble-row { + display: flex; + align-items: center; + gap: 4px; +} +.miu-msg.is-mine .miu-bubble-row { + flex-direction: row-reverse; +} +.miu-react-wrap { + position: relative; + flex-shrink: 0; +} +.miu-react-btn { + border: none; + background: none; + cursor: pointer; + opacity: 0; + font-size: 14px; + padding: 2px; + transition: opacity 0.12s; +} +.miu-msg:hover .miu-react-btn { + opacity: 0.6; +} +.miu-react-btn:hover { + opacity: 1; +} +.miu-react-picker { + position: absolute; + bottom: 100%; + margin-bottom: 4px; + display: flex; + gap: 2px; + padding: 4px; + border-radius: 999px; + border: 1px solid var(--miu-border); + background: var(--miu-panel); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4); + z-index: 5; +} +.miu-react-emoji { + border: none; + background: none; + cursor: pointer; + font-size: 16px; + padding: 2px 4px; + border-radius: 6px; +} +.miu-react-emoji:hover { + background: var(--miu-panel-2); +} .miu-reactions { display: flex; gap: 4px; @@ -160,10 +211,35 @@ border-radius: 999px; border: 1px solid var(--miu-border); background: var(--miu-panel-2); + color: var(--miu-text); + cursor: pointer; } .miu-reaction.is-mine { border-color: var(--miu-accent); } + +/* attachments */ +.miu-att-img-link { + display: inline-block; + margin-top: 4px; +} +.miu-att-img { + max-width: 260px; + max-height: 220px; + border-radius: 8px; + border: 1px solid var(--miu-border); +} +.miu-att-file { + display: inline-block; + margin-top: 4px; + padding: 6px 10px; + border-radius: 8px; + border: 1px solid var(--miu-border); + background: var(--miu-panel-2); + color: var(--miu-text); + text-decoration: none; + font-size: 13px; +} .miu-seen { font-size: 11px; color: var(--miu-muted); @@ -189,10 +265,47 @@ .miu-composer { position: relative; display: flex; + flex-direction: column; gap: 8px; padding: 12px; border-top: 1px solid var(--miu-border); } +.miu-composer-row { + display: flex; + gap: 8px; +} +.miu-file-input { + display: none; +} +.miu-attach-btn { + flex-shrink: 0; + width: 38px; + border-radius: 10px; + border: 1px solid var(--miu-border); + background: var(--miu-panel); + color: var(--miu-text); + cursor: pointer; + font-size: 16px; +} +.miu-staged { + display: inline-flex; + align-items: center; + gap: 8px; + align-self: flex-start; + padding: 5px 10px; + border-radius: 999px; + border: 1px solid var(--miu-border); + background: var(--miu-panel-2); + font-size: 13px; +} +.miu-staged-x { + border: none; + background: none; + color: var(--miu-muted); + cursor: pointer; + padding: 0; + line-height: 1; +} .miu-suggest { position: absolute; left: 12px; From fa93173b1fcb85d1033cfdc5d4c0984aeba773af Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 22 Jul 2026 00:29:54 +0530 Subject: [PATCH 18/26] feat(messaging-ui): Slack-style threaded-replies side panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replies (messages with parentInteractionId) now open in a right-hand ThreadPane instead of inline quoting: - extracted a shared Composer (draft + @mention autocomplete + attach) and MessageItem (bubble + mentions + attachment + reactions + reply affordance) - Thread shows top-level messages only; a message with replies gets a '💬 N replies' link, and hovering shows 💬 'Reply in thread' - ThreadPane renders the root + its replies + a composer that posts back with parentInteractionId; Messenger becomes 3-column (list | thread | pane), pane closes on conversation switch - no contract/adapter/backend change (parentInteractionId was already wired) - thread-pane render test (open → reply → parent shows the count); 58 green Co-Authored-By: Claude Opus 4.8 --- .../src/components/composer.tsx | 147 ++++++++++++ .../src/components/message-item.tsx | 107 +++++++++ .../src/components/messenger.tsx | 11 +- .../src/components/thread-pane.test.tsx | 36 +++ .../src/components/thread-pane.tsx | 60 +++++ .../src/components/thread.tsx | 221 +++--------------- packages/iios-messaging-ui/src/index.ts | 1 + packages/iios-messaging-ui/src/styles.css | 57 +++++ 8 files changed, 452 insertions(+), 188 deletions(-) create mode 100644 packages/iios-messaging-ui/src/components/composer.tsx create mode 100644 packages/iios-messaging-ui/src/components/message-item.tsx create mode 100644 packages/iios-messaging-ui/src/components/thread-pane.test.tsx create mode 100644 packages/iios-messaging-ui/src/components/thread-pane.tsx diff --git a/packages/iios-messaging-ui/src/components/composer.tsx b/packages/iios-messaging-ui/src/components/composer.tsx new file mode 100644 index 0000000..56c50ec --- /dev/null +++ b/packages/iios-messaging-ui/src/components/composer.tsx @@ -0,0 +1,147 @@ +import { useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from 'react'; +import { + SPECIAL_MENTIONS, + insertMention, + resolveMentions, + trailingMentionQuery, +} from '../mentions'; +import type { Attachment, Person, SendOpts } from '../types'; + +interface Suggestion { + key: string; + label: string; + insert: string; +} + +/** + * The message input: draft, @mention autocomplete, and attachment staging. Shared by the main + * Thread and the ThreadPane (which passes a parentInteractionId so a reply lands in the thread). + */ +export function Composer({ + members, + canUpload, + upload, + onSend, + onTyping, + parentInteractionId, + placeholder = 'Type a message… @ to mention', +}: { + members: Person[]; + canUpload: boolean; + upload: (file: File) => Promise; + onSend: (text: string, opts?: SendOpts) => Promise; + onTyping?: () => void; + parentInteractionId?: string; + placeholder?: string; +}) { + const [draft, setDraft] = useState(''); + const [sending, setSending] = useState(false); + const [staged, setStaged] = useState(null); + const [uploading, setUploading] = useState(false); + const fileRef = useRef(null); + + const query = trailingMentionQuery(draft); + const suggestions = useMemo(() => { + if (query === null) return []; + const q = query.toLowerCase(); + const specials = SPECIAL_MENTIONS.filter((s) => s.startsWith(q)).map((s) => ({ key: `@${s}`, label: `@${s}`, insert: s })); + const people = members.filter((m) => m.name.toLowerCase().includes(q)).map((m) => ({ key: m.id, label: m.name, insert: m.name })); + return [...specials, ...people].slice(0, 6); + }, [query, members]); + const showSuggest = query !== null && suggestions.length > 0; + + function pick(insert: string): void { + setDraft((d) => insertMention(d, insert)); + } + + async function onPickFile(e: ChangeEvent): Promise { + const file = e.target.files?.[0]; + e.target.value = ''; + if (!file) return; + setUploading(true); + try { + setStaged(await upload(file)); + } catch { + /* host surfaces upload errors */ + } finally { + setUploading(false); + } + } + + async function submit(e?: FormEvent): Promise { + e?.preventDefault(); + const text = draft.trim(); + if ((!text && !staged) || sending) return; + const mentions = resolveMentions(text, members); + const att = staged; + setDraft(''); + setStaged(null); + setSending(true); + try { + await onSend(text, { + ...(mentions.length ? { mentions } : {}), + ...(att ? { attachment: att } : {}), + ...(parentInteractionId ? { parentInteractionId } : {}), + }); + } catch { + setStaged(att); + } finally { + setSending(false); + } + } + + function onKeyDown(e: KeyboardEvent): void { + if (showSuggest && e.key === 'Enter') { + e.preventDefault(); + pick(suggestions[0]!.insert); + } + } + + return ( +
+ {showSuggest ? ( +
    + {suggestions.map((s) => ( +
  • + +
  • + ))} +
+ ) : null} + {staged ? ( +
+ 📎 {staged.name} + +
+ ) : null} +
+ {canUpload ? ( + <> + + + + ) : null} + { + setDraft(e.target.value); + onTyping?.(); + }} + onKeyDown={onKeyDown} + /> + +
+
+ ); +} diff --git a/packages/iios-messaging-ui/src/components/message-item.tsx b/packages/iios-messaging-ui/src/components/message-item.tsx new file mode 100644 index 0000000..34c0f9f --- /dev/null +++ b/packages/iios-messaging-ui/src/components/message-item.tsx @@ -0,0 +1,107 @@ +import { useState } from 'react'; +import { highlightMentions } from '../mentions'; +import type { Attachment } from '../types'; +import type { UiMessage } from '../hooks/use-messages'; + +const REACTION_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀']; + +const isImage = (mime: string): boolean => mime.startsWith('image/'); + +function AttachmentView({ att }: { att: Attachment }) { + if (isImage(att.mime)) { + return ( + + {att.name} + + ); + } + return ( + + 📎 {att.name} + + ); +} + +/** One rendered message: bubble (with @mention highlighting), attachment, reactions, seen tick, + * and — in the main thread only — a thread/reply affordance. */ +export function MessageItem({ + message, + memberNames, + canReact, + onReact, + seen, + replyCount, + onOpenThread, +}: { + message: UiMessage; + memberNames: string[]; + canReact: boolean; + onReact: (messageId: string, emoji: string) => void; + seen: boolean; + /** Present only in the main thread (not inside the pane). undefined => no thread affordance. */ + replyCount?: number; + onOpenThread?: (messageId: string) => void; +}) { + const [pickerOpen, setPickerOpen] = useState(false); + const m = message; + + return ( +
+
+
+ {m.text ? highlightMentions(m.text, memberNames) : null} + {m.attachment ? : null} +
+
+ {canReact ? ( +
+ + {pickerOpen ? ( +
+ {REACTION_EMOJIS.map((e) => ( + + ))} +
+ ) : null} +
+ ) : null} + {onOpenThread ? ( + + ) : null} +
+
+ + {m.reactions && m.reactions.length > 0 ? ( +
+ {m.reactions.map((r) => ( + + ))} +
+ ) : null} + + {replyCount !== undefined && replyCount > 0 && onOpenThread ? ( + + ) : null} + + {message.mine && seen ? Seen : null} +
+ ); +} diff --git a/packages/iios-messaging-ui/src/components/messenger.tsx b/packages/iios-messaging-ui/src/components/messenger.tsx index 604df07..9a5b198 100644 --- a/packages/iios-messaging-ui/src/components/messenger.tsx +++ b/packages/iios-messaging-ui/src/components/messenger.tsx @@ -4,6 +4,7 @@ import { useAdapter } from '../provider'; import { ConversationList } from './conversation-list'; import { ChannelBrowser } from './channel-browser'; import { Thread } from './thread'; +import { ThreadPane } from './thread-pane'; /** * The drop-in messenger: sectioned conversation list (Channels / Direct messages) + open thread, @@ -17,6 +18,7 @@ export function Messenger() { const [selected, setSelected] = useState(null); const [browsing, setBrowsing] = useState(false); + const [activeRoot, setActiveRoot] = useState(null); // open thread pane's root message useEffect(() => { if (browsing) return; @@ -24,6 +26,9 @@ export function Messenger() { setSelected(conversations[0]?.threadId ?? null); }, [conversations, selected, browsing]); + // Switching conversations (or into browse) closes any open thread pane. + useEffect(() => setActiveRoot(null), [selected, browsing]); + const channels = useMemo(() => conversations.filter((c) => c.membership === 'channel'), [conversations]); const dms = useMemo(() => conversations.filter((c) => c.membership !== 'channel'), [conversations]); @@ -73,9 +78,13 @@ export function Messenger() { }} /> ) : ( - + )} + + {!browsing && selected && activeRoot ? ( + setActiveRoot(null)} /> + ) : null} ); } diff --git a/packages/iios-messaging-ui/src/components/thread-pane.test.tsx b/packages/iios-messaging-ui/src/components/thread-pane.test.tsx new file mode 100644 index 0000000..6cea6d3 --- /dev/null +++ b/packages/iios-messaging-ui/src/components/thread-pane.test.tsx @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MessagingProvider } from '../provider'; +import { Messenger } from './messenger'; +import { MockAdapter } from '../adapters/mock'; + +function mount() { + return render( + + + , + ); +} + +describe('Slack-style thread pane', () => { + it('opens a thread on 💬, posts a reply into it, and shows the reply count on the parent', async () => { + mount(); + // Wait for the auto-selected thread's message to render WITH its actions (not just the sidebar + // preview), then open the thread pane via the message's reply affordance (💬). + const replyButtons = await screen.findAllByTitle('Reply in thread'); + fireEvent.click(replyButtons[0]!); + expect(await screen.findByText('Thread')).toBeTruthy(); // pane header + + // The pane's composer (placeholder "Reply…") — send a threaded reply. + const replyInput = screen.getByPlaceholderText('Reply…') as HTMLInputElement; + fireEvent.change(replyInput, { target: { value: 'on it' } }); + // The pane has its own Send; grab the last one (pane is rendered after the main composer). + const sends = screen.getAllByText('Send'); + fireEvent.click(sends[sends.length - 1]!); + + // The reply shows in the pane (replies are hidden from the main thread, so this is unique)... + await waitFor(() => expect(screen.getByText('on it')).toBeTruthy()); + // ...and the parent now advertises the reply count as a thread-link button in the main thread. + await waitFor(() => expect(screen.getByRole('button', { name: /1 reply/ })).toBeTruthy()); + }); +}); diff --git a/packages/iios-messaging-ui/src/components/thread-pane.tsx b/packages/iios-messaging-ui/src/components/thread-pane.tsx new file mode 100644 index 0000000..7252dca --- /dev/null +++ b/packages/iios-messaging-ui/src/components/thread-pane.tsx @@ -0,0 +1,60 @@ +import { useMemo } from 'react'; +import { useMessages } from '../hooks/use-messages'; +import { useMembers } from '../hooks/use-members'; +import { Composer } from './composer'; +import { MessageItem } from './message-item'; + +/** + * The Slack-style thread side panel: the root message + its replies + a composer that posts back + * into the thread (parentInteractionId = root). Uses its own useMessages on the same thread; the + * shared adapter subscription keeps it and the main view in sync. + */ +export function ThreadPane({ + threadId, + rootId, + onClose, +}: { + threadId: string; + rootId: string; + onClose: () => void; +}) { + const { messages, send, react, upload, seenIds, sendTyping, canReact, canUpload } = useMessages(threadId); + const members = useMembers(threadId); + const memberNames = useMemo(() => members.map((m) => m.name), [members]); + + const root = useMemo(() => messages.find((m) => m.id === rootId), [messages, rootId]); + const replies = useMemo(() => messages.filter((m) => m.parentInteractionId === rootId), [messages, rootId]); + + return ( + + ); +} diff --git a/packages/iios-messaging-ui/src/components/thread.tsx b/packages/iios-messaging-ui/src/components/thread.tsx index fc94b17..0f21efa 100644 --- a/packages/iios-messaging-ui/src/components/thread.tsx +++ b/packages/iios-messaging-ui/src/components/thread.tsx @@ -1,214 +1,61 @@ -import { useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from 'react'; +import { useMemo } from 'react'; import { useMessages } from '../hooks/use-messages'; import { useMembers } from '../hooks/use-members'; -import { - SPECIAL_MENTIONS, - highlightMentions, - insertMention, - resolveMentions, - trailingMentionQuery, -} from '../mentions'; -import type { Attachment } from '../types'; - -const REACTION_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀']; - -interface Suggestion { - key: string; - label: string; - insert: string; -} - -const isImage = (mime: string): boolean => mime.startsWith('image/'); - -function AttachmentView({ att }: { att: Attachment }) { - if (isImage(att.mime)) { - return ( - - {att.name} - - ); - } - return ( - - 📎 {att.name} - - ); -} +import { Composer } from './composer'; +import { MessageItem } from './message-item'; /** - * One conversation: message list + composer, driven by useMessages. Includes @mention autocomplete, - * emoji reactions, and attachments — each degrading if the adapter doesn't support it. Transport-agnostic. + * The main conversation view: top-level messages + composer. Replies (messages with a + * parentInteractionId) are hidden here and live in the ThreadPane — a message with replies shows a + * "N replies" link that opens it. @mentions, reactions, and attachments all work. */ -export function Thread({ threadId }: { threadId: string | null }) { +export function Thread({ + threadId, + activeRootId, + onOpenThread, +}: { + threadId: string | null; + activeRootId?: string | null; + onOpenThread?: (rootId: string) => void; +}) { const { messages, loading, error, send, react, upload, typingUserIds, seenIds, sendTyping, canReact, canUpload } = useMessages(threadId); const members = useMembers(threadId); - const [draft, setDraft] = useState(''); - const [sending, setSending] = useState(false); - const [pickerFor, setPickerFor] = useState(null); - const [staged, setStaged] = useState(null); - const [uploading, setUploading] = useState(false); - const fileRef = useRef(null); - const memberNames = useMemo(() => members.map((m) => m.name), [members]); - const query = trailingMentionQuery(draft); - const suggestions = useMemo(() => { - if (query === null) return []; - const q = query.toLowerCase(); - const specials = SPECIAL_MENTIONS.filter((s) => s.startsWith(q)).map((s) => ({ key: `@${s}`, label: `@${s}`, insert: s })); - const people = members.filter((m) => m.name.toLowerCase().includes(q)).map((m) => ({ key: m.id, label: m.name, insert: m.name })); - return [...specials, ...people].slice(0, 6); - }, [query, members]); - const showSuggest = query !== null && suggestions.length > 0; - function pick(insert: string): void { - setDraft((d) => insertMention(d, insert)); - } - - async function onPickFile(e: ChangeEvent): Promise { - const file = e.target.files?.[0]; - e.target.value = ''; - if (!file) return; - setUploading(true); - try { - setStaged(await upload(file)); - } catch { - // swallow — a real host surfaces upload errors; keep the composer usable - } finally { - setUploading(false); - } - } - - async function submit(e?: FormEvent): Promise { - e?.preventDefault(); - const text = draft.trim(); - if ((!text && !staged) || sending) return; - const mentions = resolveMentions(text, members); - const att = staged; - setDraft(''); - setStaged(null); - setSending(true); - try { - await send(text, { - ...(mentions.length ? { mentions } : {}), - ...(att ? { attachment: att } : {}), - }); - } catch { - setStaged(att); - } finally { - setSending(false); - } - } - - function onKeyDown(e: KeyboardEvent): void { - if (showSuggest && e.key === 'Enter') { - e.preventDefault(); - pick(suggestions[0]!.insert); - } - } + const topLevel = useMemo(() => messages.filter((m) => !m.parentInteractionId), [messages]); + const replyCount = useMemo(() => { + const counts = new Map(); + for (const m of messages) if (m.parentInteractionId) counts.set(m.parentInteractionId, (counts.get(m.parentInteractionId) ?? 0) + 1); + return counts; + }, [messages]); if (!threadId) { return
Select a conversation.
; } return ( -
+
{loading && messages.length === 0 ?
Loading…
: null} {error ?
{error}
: null} - {messages.map((m) => ( -
-
-
- {m.text ? highlightMentions(m.text, memberNames) : null} - {m.attachment ? : null} -
- {canReact ? ( -
- - {pickerFor === m.id ? ( -
- {REACTION_EMOJIS.map((e) => ( - - ))} -
- ) : null} -
- ) : null} -
- {m.reactions && m.reactions.length > 0 ? ( -
- {m.reactions.map((r) => ( - - ))} -
- ) : null} - {m.mine && seenIds.has(m.id) ? Seen : null} -
+ {topLevel.map((m) => ( + void react(id, emoji)} + seen={seenIds.has(m.id)} + replyCount={replyCount.get(m.id) ?? 0} + {...(onOpenThread ? { onOpenThread } : {})} + /> ))} {typingUserIds.length > 0 ? (
{typingUserIds.length === 1 ? 'typing…' : 'several people are typing…'}
) : null}
-
- {showSuggest ? ( -
    - {suggestions.map((s) => ( -
  • - -
  • - ))} -
- ) : null} - {staged ? ( -
- 📎 {staged.name} - -
- ) : null} -
- {canUpload ? ( - <> - - - - ) : null} - { - setDraft(e.target.value); - sendTyping(); - }} - onKeyDown={onKeyDown} - /> - -
-
+
); } diff --git a/packages/iios-messaging-ui/src/index.ts b/packages/iios-messaging-ui/src/index.ts index 80aca86..1d160cd 100644 --- a/packages/iios-messaging-ui/src/index.ts +++ b/packages/iios-messaging-ui/src/index.ts @@ -15,6 +15,7 @@ export { Messenger } from './components/messenger'; export { ConversationList } from './components/conversation-list'; export { ChannelBrowser } from './components/channel-browser'; export { Thread } from './components/thread'; +export { ThreadPane } from './components/thread-pane'; export type { MessagingAdapter } from './adapter'; export type { ConversationsState } from './hooks/use-conversations'; diff --git a/packages/iios-messaging-ui/src/styles.css b/packages/iios-messaging-ui/src/styles.css index 2d0d0e5..97c58ac 100644 --- a/packages/iios-messaging-ui/src/styles.css +++ b/packages/iios-messaging-ui/src/styles.css @@ -157,6 +157,26 @@ .miu-msg.is-mine .miu-bubble-row { flex-direction: row-reverse; } +.miu-msg-actions { + display: flex; + gap: 2px; + flex-shrink: 0; +} +.miu-thread-link { + align-self: flex-start; + margin-top: 3px; + padding: 3px 9px; + border-radius: 999px; + border: 1px solid var(--miu-border); + background: var(--miu-panel); + color: var(--miu-accent); + font-size: 12px; + font-weight: 600; + cursor: pointer; +} +.miu-msg.is-mine .miu-thread-link { + align-self: flex-end; +} .miu-react-wrap { position: relative; flex-shrink: 0; @@ -490,6 +510,43 @@ color: var(--miu-muted); } +/* thread pane (Slack-style) */ +.miu-pane { + width: 340px; + flex-shrink: 0; + display: flex; + flex-direction: column; + min-height: 0; + border-left: 1px solid var(--miu-border); + background: var(--miu-panel); +} +.miu-pane-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--miu-border); + font-weight: 700; +} +.miu-pane-close { + border: none; + background: none; + color: var(--miu-muted); + cursor: pointer; + font-size: 15px; +} +.miu-pane-messages { + background: var(--miu-bg); +} +.miu-pane-divider { + font-size: 11.5px; + color: var(--miu-muted); + text-align: center; + margin: 4px 0; + padding-bottom: 4px; + border-bottom: 1px solid var(--miu-border); +} + /* misc */ .miu-empty { padding: 24px; From d02cd152dea51901deb02e25e0955d0ea3c5475d Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 22 Jul 2026 00:37:47 +0530 Subject: [PATCH 19/26] fix(messaging-ui): reaction picker opens downward, no longer clips The emoji picker used bottom:100% and was clipped by the message list's overflow when reacting to a message near the top (and could spill past the right edge). Open it downward + edge-anchored (right for others' messages, left for my own) with width:max-content so all emojis stay visible. Co-Authored-By: Claude Opus 4.8 --- packages/iios-messaging-ui/src/styles.css | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/iios-messaging-ui/src/styles.css b/packages/iios-messaging-ui/src/styles.css index 97c58ac..9ae498d 100644 --- a/packages/iios-messaging-ui/src/styles.css +++ b/packages/iios-messaging-ui/src/styles.css @@ -198,8 +198,11 @@ } .miu-react-picker { position: absolute; - bottom: 100%; - margin-bottom: 4px; + /* Open downward + right-aligned so it never clips against the top of the scroll area + (the reported bug) or spills past the right edge. */ + top: 100%; + right: 0; + margin-top: 4px; display: flex; gap: 2px; padding: 4px; @@ -208,6 +211,12 @@ background: var(--miu-panel); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4); z-index: 5; + width: max-content; +} +/* On my own (right-aligned) messages, anchor the picker to the left instead so it stays in view. */ +.miu-msg.is-mine .miu-react-picker { + right: auto; + left: 0; } .miu-react-emoji { border: none; From 2f24a95ef44beb93c3e3d0c52d576393ecc60939 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 22 Jul 2026 00:48:41 +0530 Subject: [PATCH 20/26] =?UTF-8?q?feat(messaging-ui):=20Inbox=20domain=20?= =?UTF-8?q?=E2=80=94=20unified=20work=20items=20+=20mail=20(contract=20+?= =?UTF-8?q?=20UI=20+=20mock)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second domain in the SDK, mirroring messaging's adapter/provider/hooks/components: - InboxAdapter (listInbox unified feed + transition + mailHistory/mailReply + optional directory/composeInternal/composeExternal), InboxProvider/useInboxAdapter - hooks: useInbox (filter + transition), useMailThread (history + reply), useCompose - (filter tabs, unified list, detail pane), (HTML in a sandboxed iframe + reply), compose modal (in-app / email); themeable styles - MockInboxAdapter (seeded mentions/needs-reply/alert + folded mail threads + directory) shipped from ./adapters/mock-inbox - 6 inbox tests (mock unify/transition/reply + render open/reply/compose); 64 green Co-Authored-By: Claude Opus 4.8 --- packages/iios-messaging-ui/package.json | 4 + .../src/adapters/mock-inbox.ts | 104 +++++++ .../iios-messaging-ui/src/inbox/adapter.ts | 28 ++ packages/iios-messaging-ui/src/inbox/hooks.ts | 156 ++++++++++ .../src/inbox/inbox.test.tsx | 68 ++++ .../iios-messaging-ui/src/inbox/inbox.tsx | 241 +++++++++++++++ .../iios-messaging-ui/src/inbox/provider.tsx | 16 + packages/iios-messaging-ui/src/inbox/types.ts | 42 +++ packages/iios-messaging-ui/src/index.ts | 7 + packages/iios-messaging-ui/src/styles.css | 290 ++++++++++++++++++ packages/iios-messaging-ui/tsup.config.ts | 4 +- 11 files changed, 958 insertions(+), 2 deletions(-) create mode 100644 packages/iios-messaging-ui/src/adapters/mock-inbox.ts create mode 100644 packages/iios-messaging-ui/src/inbox/adapter.ts create mode 100644 packages/iios-messaging-ui/src/inbox/hooks.ts create mode 100644 packages/iios-messaging-ui/src/inbox/inbox.test.tsx create mode 100644 packages/iios-messaging-ui/src/inbox/inbox.tsx create mode 100644 packages/iios-messaging-ui/src/inbox/provider.tsx create mode 100644 packages/iios-messaging-ui/src/inbox/types.ts diff --git a/packages/iios-messaging-ui/package.json b/packages/iios-messaging-ui/package.json index 4c1bd3f..802d21d 100644 --- a/packages/iios-messaging-ui/package.json +++ b/packages/iios-messaging-ui/package.json @@ -18,6 +18,10 @@ "types": "./dist/adapters/kernel-client.d.ts", "import": "./dist/adapters/kernel-client.js" }, + "./adapters/mock-inbox": { + "types": "./dist/adapters/mock-inbox.d.ts", + "import": "./dist/adapters/mock-inbox.js" + }, "./conformance": { "types": "./dist/conformance.d.ts", "import": "./dist/conformance.js" diff --git a/packages/iios-messaging-ui/src/adapters/mock-inbox.ts b/packages/iios-messaging-ui/src/adapters/mock-inbox.ts new file mode 100644 index 0000000..fcee29b --- /dev/null +++ b/packages/iios-messaging-ui/src/adapters/mock-inbox.ts @@ -0,0 +1,104 @@ +import type { InboxAdapter } from '../inbox/adapter'; +import type { InboxItem, InboxState, MailMessage, MailPerson } from '../inbox/types'; + +const PEOPLE: MailPerson[] = [ + { id: 'pp_sofia', name: 'Sofia Ramirez', kind: 'staff' }, + { id: 'pp_dan', name: 'Dan Whitaker', kind: 'staff' }, + { id: 'cust_acme', name: 'Acme Roofing (Client)', kind: 'customer' }, +]; + +interface MockMail { + subject: string; + messages: MailMessage[]; +} + +/** + * In-memory inbox for demos/tests. State is PER INSTANCE. Seeds a few work items (mention, + * needs-reply, alert) plus mail threads that fold into the Open view as MAIL rows. + */ +export class MockInboxAdapter implements InboxAdapter { + private seq = 100; + private readonly items: InboxItem[]; + private readonly threads = new Map(); + /** threadIds that surface as standalone MAIL rows (in addition to work items). */ + private readonly mailFold = ['mt_welcome', 'mt_invoice']; + + constructor(private readonly now: () => string = () => new Date().toISOString()) { + const at = this.now(); + this.items = [ + { id: 'in_1', kind: 'MENTION', state: 'OPEN', title: 'Sofia mentioned you', summary: '@you — can you confirm the Henderson scope?', priority: 'HIGH', threadId: 'mt_mention', createdAt: at }, + { id: 'in_2', kind: 'NEEDS_REPLY', state: 'OPEN', title: 'Reply needed — Storm response', summary: 'Dan: crew rolling out at 7', priority: 'MEDIUM', threadId: 'mt_storm', createdAt: at }, + { id: 'in_3', kind: 'SYSTEM_ALERT', state: 'OPEN', title: 'Ticket TK-204 updated', summary: 'Customer replied on the roof-leak case.', priority: 'LOW', createdAt: at }, + ]; + this.threads.set('mt_mention', { + subject: 'Henderson scope', + messages: [{ id: 'm1', actorId: 'pp_sofia', kind: 'EMAIL', at, html: '

Can you confirm the Henderson scope by EOD?

', text: 'Can you confirm the Henderson scope by EOD?', attachment: null }], + }); + this.threads.set('mt_storm', { + subject: 'Storm response — East side', + messages: [{ id: 'm2', actorId: 'pp_dan', kind: 'EMAIL', at, html: '

Crew is rolling out at 7. Confirm the Henderson job?

', text: 'Crew rolling out at 7. Confirm the Henderson job?', attachment: null }], + }); + this.threads.set('mt_welcome', { + subject: 'Welcome to the Founders Club', + messages: [{ id: 'm3', actorId: 'system', kind: 'EMAIL', at, html: '

Thanks for joining the Founders Club. Set up your account to get started.

', text: 'Thanks for joining the Founders Club.', attachment: null }], + }); + this.threads.set('mt_invoice', { + subject: 'Invoice #1042 — Acme Roofing', + messages: [{ id: 'm4', actorId: 'cust_acme', kind: 'EMAIL', at, html: '

Attached is invoice #1042 for the East-side job.

', text: 'Attached is invoice #1042 for the East-side job.', attachment: null }], + }); + } + + async listInbox(state?: InboxState): Promise { + const showMail = !state || state === 'OPEN'; + const work = this.items.filter((i) => (state ? i.state === state : true)); + const mail: InboxItem[] = showMail + ? this.mailFold.map((tid) => { + const t = this.threads.get(tid)!; + const last = t.messages[t.messages.length - 1]; + return { + id: `mail:${tid}`, + kind: 'MAIL', + state: 'OPEN' as InboxState, + title: t.subject, + ...(last?.text ? { summary: last.text } : {}), + priority: 'LOW', + threadId: tid, + createdAt: last?.at ?? this.now(), + }; + }) + : []; + return [...mail, ...work]; + } + + async transition(id: string, state: InboxState): Promise { + const item = this.items.find((i) => i.id === id); + if (item) item.state = state; + } + + async mailHistory(threadId: string): Promise { + return [...(this.threads.get(threadId)?.messages ?? [])]; + } + + async mailReply(threadId: string, content: string): Promise { + const t = this.threads.get(threadId); + if (t) t.messages = [...t.messages, { id: `r_${this.seq++}`, actorId: 'me', kind: 'MESSAGE', at: this.now(), html: null, text: content, attachment: null }]; + } + + async directory(): Promise { + return [...PEOPLE]; + } + + async composeInternal(recipientUserId: string, subject: string, text: string): Promise { + const threadId = `mt_${this.seq++}`; + this.threads.set(threadId, { subject, messages: [{ id: `m_${this.seq++}`, actorId: 'me', kind: 'EMAIL', at: this.now(), html: `

${text}

`, text, attachment: null }] }); + this.mailFold.unshift(threadId); + void recipientUserId; + } + + async composeExternal(target: string, subject: string, text: string): Promise { + // A mock external send has no in-app thread — no-op beyond acknowledging. + void target; + void subject; + void text; + } +} diff --git a/packages/iios-messaging-ui/src/inbox/adapter.ts b/packages/iios-messaging-ui/src/inbox/adapter.ts new file mode 100644 index 0000000..ec976f2 --- /dev/null +++ b/packages/iios-messaging-ui/src/inbox/adapter.ts @@ -0,0 +1,28 @@ +import type { InboxItem, InboxState, MailMessage, MailPerson } from './types'; + +/** + * The inbox seam. A host implements this; the SDK renders it. Mirrors MessagingAdapter's philosophy: + * `listInbox` returns the UNIFIED feed (work items + mail folded in) so the folding logic lives in + * one place (the adapter, which knows both sources). Compose methods are optional and degrade. + */ +export interface InboxAdapter { + /** The unified inbox: work items + mail threads as rows, filtered by state (mail shows in OPEN). */ + listInbox(state?: InboxState): Promise; + + /** Transition a work item's state (Done/Snooze/Archive…). Mail rows are not transitioned. */ + transition(id: string, state: InboxState): Promise; + + /** Messages of one mail thread (HTML + text parts) for the reader. */ + mailHistory(threadId: string): Promise; + + /** Reply into an existing mail thread. */ + mailReply(threadId: string, content: string): Promise; + + // ── Compose (optional) — absent hides the "New message" affordance ── + /** People you can compose an in-app message to. */ + directory?(): Promise; + /** App-to-app mail (no SMTP) to a registered user's in-app inbox. */ + composeInternal?(recipientUserId: string, subject: string, text: string): Promise; + /** External email (SMTP) to an address. */ + composeExternal?(target: string, subject: string, text: string): Promise; +} diff --git a/packages/iios-messaging-ui/src/inbox/hooks.ts b/packages/iios-messaging-ui/src/inbox/hooks.ts new file mode 100644 index 0000000..8a84464 --- /dev/null +++ b/packages/iios-messaging-ui/src/inbox/hooks.ts @@ -0,0 +1,156 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useInboxAdapter } from './provider'; +import type { InboxItem, InboxState, MailMessage, MailPerson } from './types'; + +export interface InboxData { + items: InboxItem[]; + loading: boolean; + error: string | null; + transition: (id: string, state: InboxState) => Promise; + refetch: () => void; +} + +/** The unified inbox for a given filter state. Refetches when the filter changes. */ +export function useInbox(state?: InboxState): InboxData { + const adapter = useInboxAdapter(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [nonce, setNonce] = useState(0); + + useEffect(() => { + let alive = true; + setLoading(true); + adapter + .listInbox(state) + .then((list) => { + if (!alive) return; + setItems(list); + setError(null); + }) + .catch((e: unknown) => { + if (!alive) return; + setError(e instanceof Error ? e.message : String(e)); + setItems([]); + }) + .finally(() => { + if (alive) setLoading(false); + }); + return () => { + alive = false; + }; + }, [adapter, state, nonce]); + + const refetch = useCallback(() => setNonce((n) => n + 1), []); + const transition = useCallback( + async (id: string, next: InboxState) => { + await adapter.transition(id, next); + setNonce((n) => n + 1); + }, + [adapter], + ); + + return { items, loading, error, transition, refetch }; +} + +export interface MailThreadState { + messages: MailMessage[]; + loading: boolean; + error: string | null; + reply: (content: string) => Promise; + refetch: () => void; +} + +/** One mail thread: history + reply. */ +export function useMailThread(threadId: string | null): MailThreadState { + const adapter = useInboxAdapter(); + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [nonce, setNonce] = useState(0); + + useEffect(() => { + if (!threadId) { + setMessages([]); + setLoading(false); + return; + } + let alive = true; + setLoading(true); + adapter + .mailHistory(threadId) + .then((m) => { + if (!alive) return; + setMessages(m); + setError(null); + }) + .catch((e: unknown) => { + if (alive) setError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => { + if (alive) setLoading(false); + }); + return () => { + alive = false; + }; + }, [adapter, threadId, nonce]); + + const refetch = useCallback(() => setNonce((n) => n + 1), []); + const reply = useCallback( + async (content: string) => { + if (!threadId) return; + await adapter.mailReply(threadId, content); + setNonce((n) => n + 1); + }, + [adapter, threadId], + ); + + return { messages, loading, error, reply, refetch }; +} + +export interface ComposeState { + supported: boolean; + directory: MailPerson[]; + sendInternal: (recipientUserId: string, subject: string, text: string) => Promise; + sendExternal: (target: string, subject: string, text: string) => Promise; +} + +/** Compose a new message — in-app (to a person) or external (to an email). */ +export function useCompose(): ComposeState { + const adapter = useInboxAdapter(); + const supported = typeof adapter.composeInternal === 'function'; + const [directory, setDirectory] = useState([]); + + useEffect(() => { + if (!adapter.directory) return; + let alive = true; + adapter + .directory() + .then((d) => { + if (alive) setDirectory(d); + }) + .catch(() => { + if (alive) setDirectory([]); + }); + return () => { + alive = false; + }; + }, [adapter]); + + const sendInternal = useCallback( + async (recipientUserId: string, subject: string, text: string) => { + if (!adapter.composeInternal) throw new Error('compose is not supported by this adapter'); + await adapter.composeInternal(recipientUserId, subject, text); + }, + [adapter], + ); + const sendExternal = useCallback( + async (target: string, subject: string, text: string) => { + if (!adapter.composeExternal) throw new Error('compose is not supported by this adapter'); + await adapter.composeExternal(target, subject, text); + }, + [adapter], + ); + + return { supported, directory, sendInternal, sendExternal }; +} diff --git a/packages/iios-messaging-ui/src/inbox/inbox.test.tsx b/packages/iios-messaging-ui/src/inbox/inbox.test.tsx new file mode 100644 index 0000000..88318d8 --- /dev/null +++ b/packages/iios-messaging-ui/src/inbox/inbox.test.tsx @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { InboxProvider } from './provider'; +import { Inbox } from './inbox'; +import { MockInboxAdapter } from '../adapters/mock-inbox'; + +function mount() { + return render( + + + , + ); +} + +describe('mock inbox adapter', () => { + it('unifies work items + mail in the Open view; other states drop mail', async () => { + const a = new MockInboxAdapter(); + const open = await a.listInbox('OPEN'); + expect(open.some((i) => i.kind === 'MAIL')).toBe(true); + expect(open.some((i) => i.kind === 'MENTION')).toBe(true); + const done = await a.listInbox('DONE'); + expect(done.some((i) => i.kind === 'MAIL')).toBe(false); + }); + + it('transition moves a work item out of Open', async () => { + const a = new MockInboxAdapter(); + await a.transition('in_3', 'DONE'); + expect((await a.listInbox('OPEN')).some((i) => i.id === 'in_3')).toBe(false); + expect((await a.listInbox('DONE')).some((i) => i.id === 'in_3')).toBe(true); + }); + + it('mail reply appends to the thread', async () => { + const a = new MockInboxAdapter(); + await a.mailReply('mt_welcome', 'thanks!'); + expect((await a.mailHistory('mt_welcome')).some((m) => m.text === 'thanks!')).toBe(true); + }); +}); + +describe(' (rendered)', () => { + it('lists items and opens a mail thread on click', async () => { + mount(); + // A folded mail row is present. + const welcome = await screen.findByText('Welcome to the Founders Club'); + fireEvent.click(welcome); + // The reader opens with a reply box. + expect(await screen.findByLabelText('Reply')).toBeTruthy(); + }); + + it('replies into a mail thread', async () => { + mount(); + fireEvent.click(await screen.findByText('Welcome to the Founders Club')); + const input = (await screen.findByLabelText('Reply')) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'got it' } }); + fireEvent.click(screen.getByText('Reply')); + await waitFor(() => expect(screen.getByText('got it')).toBeTruthy()); + }); + + it('composes an in-app message and it shows in the inbox', async () => { + mount(); + fireEvent.click(await screen.findByText('New message')); + // pick a recipient + fireEvent.click(await screen.findByText('Sofia Ramirez')); + fireEvent.change(screen.getByLabelText('Subject'), { target: { value: 'Quick q' } }); + fireEvent.change(screen.getByLabelText('Message body'), { target: { value: 'ping' } }); + fireEvent.click(screen.getByText('Send')); + await waitFor(() => expect(screen.getByText('Quick q')).toBeTruthy()); + }); +}); diff --git a/packages/iios-messaging-ui/src/inbox/inbox.tsx b/packages/iios-messaging-ui/src/inbox/inbox.tsx new file mode 100644 index 0000000..4f78892 --- /dev/null +++ b/packages/iios-messaging-ui/src/inbox/inbox.tsx @@ -0,0 +1,241 @@ +import { useEffect, useState, type FormEvent } from 'react'; +import { useCompose, useInbox, useMailThread } from './hooks'; +import type { InboxItem, InboxState, MailPerson } from './types'; + +const FILTERS: { value: InboxState; label: string }[] = [ + { value: 'OPEN', label: 'Open' }, + { value: 'SNOOZED', label: 'Snoozed' }, + { value: 'DONE', label: 'Done' }, + { value: 'ARCHIVED', label: 'Archived' }, +]; + +const KIND_LABEL: Record = { + MAIL: 'Mail', + MENTION: 'Mention', + NEEDS_REPLY: 'Needs reply', + SYSTEM_ALERT: 'Alert', + SUPPORT_UPDATE: 'Support', +}; + +const timeOf = (iso?: string): string => { + if (!iso) return ''; + const d = new Date(iso); + return Number.isNaN(+d) ? '' : d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); +}; + +/** The unified inbox: work items + mail in one list; click a threaded row to read + reply. */ +export function Inbox() { + const [filter, setFilter] = useState('OPEN'); + const { items, loading, error, transition, refetch } = useInbox(filter); + const compose = useCompose(); + const [selectedId, setSelectedId] = useState(null); + const [composing, setComposing] = useState(false); + + useEffect(() => { + if (selectedId && items.some((i) => i.id === selectedId)) return; + setSelectedId(items[0]?.id ?? null); + }, [items, selectedId]); + + const selected = items.find((i) => i.id === selectedId) ?? null; + + return ( +
+
+
+ {FILTERS.map((f) => ( + + ))} +
+ {compose.supported ? ( + + ) : null} +
+ +
+ + +
+ {selected ? void transition(selected.id, s)} /> :
Select an item to read.
} +
+
+ + {composing ? setComposing(false)} onSent={() => { setComposing(false); refetch(); }} /> : null} +
+ ); +} + +function Detail({ item, onTransition }: { item: InboxItem; onTransition: (state: InboxState) => void }) { + return ( +
+ {item.state === 'OPEN' && item.kind !== 'MAIL' ? ( +
+ + + +
+ ) : null} + {item.threadId ? ( + + ) : ( +
+
{item.title}
+ {item.summary ?
{item.summary}
: null} +
+ )} +
+ ); +} + +/** Read a mail thread (HTML in a sandboxed iframe) + reply. */ +export function MailReader({ threadId, subject }: { threadId: string; subject: string }) { + const { messages, loading, error, reply } = useMailThread(threadId); + const [draft, setDraft] = useState(''); + const [sending, setSending] = useState(false); + + async function submit(e: FormEvent): Promise { + e.preventDefault(); + const text = draft.trim(); + if (!text || sending) return; + setDraft(''); + setSending(true); + try { + await reply(text); + } catch { + setDraft(text); + } finally { + setSending(false); + } + } + + return ( +
+
{subject || '(no subject)'}
+
+ {loading && messages.length === 0 ?
Loading…
: null} + {error ?
{error}
: null} + {messages.map((m) => ( +
+
+ {m.kind === 'EMAIL' ? 'Email' : 'Reply'}{m.actorId ? ` · ${m.actorId}` : ''} + {timeOf(m.at)} +
+ {m.html ? ( +