@q9labsai/chalk-react-native 4.1.5 → 4.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/android.js +12 -2
  2. package/dist/chunk-4L5GXJ36.js +58 -0
  3. package/dist/{chunk-HXYIJ75K.js → chunk-LZ67SXGB.js} +148 -1
  4. package/dist/{chunk-5HETUU73.js → chunk-MMBE2BVK.js} +11 -2
  5. package/dist/client.js +1 -1
  6. package/dist/diagnostics.d.ts +4 -4
  7. package/dist/diagnostics.d.ts.map +1 -1
  8. package/dist/diagnostics.js +7 -7
  9. package/dist/index.d.ts +3 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +12 -2
  12. package/dist/invites.d.ts +6 -1
  13. package/dist/invites.d.ts.map +1 -1
  14. package/dist/invites.js +8 -39
  15. package/dist/ios-pad.js +12 -2
  16. package/dist/ios-phone.js +12 -2
  17. package/dist/ios.js +12 -2
  18. package/dist/macos.js +12 -2
  19. package/dist/space-client/cloudflare-rtk-native.d.ts +3 -0
  20. package/dist/space-client/cloudflare-rtk-native.d.ts.map +1 -0
  21. package/dist/space-client/create-native-space-client.d.ts.map +1 -1
  22. package/dist/utils/build-chalk-invite-link.d.ts +1 -1
  23. package/dist/utils/build-chalk-invite-link.d.ts.map +1 -1
  24. package/package.json +7 -6
  25. package/src/diagnostics.test.ts +3 -3
  26. package/src/diagnostics.ts +8 -8
  27. package/src/index.ts +3 -0
  28. package/src/invites.test.ts +24 -29
  29. package/src/invites.ts +34 -10
  30. package/src/space-client/cloudflare-rtk-native.test.ts +118 -0
  31. package/src/space-client/cloudflare-rtk-native.ts +104 -0
  32. package/src/space-client/create-native-space-client.test.ts +54 -1
  33. package/src/space-client/create-native-space-client.ts +55 -2
  34. package/src/utils/build-chalk-invite-link.test.ts +3 -3
  35. package/src/utils/build-chalk-invite-link.ts +4 -2
package/dist/macos.js CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  Chalk,
3
3
  ChalkProvider,
4
4
  Entrance,
5
+ buildChalkInviteLink,
5
6
  useCan,
6
7
  useChat,
7
8
  useConnection,
@@ -11,7 +12,7 @@ import {
11
12
  useSelf,
12
13
  useSpaceClient,
13
14
  useWhiteboard
14
- } from "./chunk-5HETUU73.js";
15
+ } from "./chunk-MMBE2BVK.js";
15
16
  import {
16
17
  THEME_PALETTES,
17
18
  THEME_TEXTURES,
@@ -23,7 +24,12 @@ import {
23
24
  Theme,
24
25
  createNativeTheme
25
26
  } from "./chunk-JB4LNWVP.js";
26
- import "./chunk-HXYIJ75K.js";
27
+ import "./chunk-LZ67SXGB.js";
28
+ import {
29
+ extractSpaceInviteFromLink,
30
+ getClipboardInviteSuggestion,
31
+ parseSpaceInviteLink
32
+ } from "./chunk-4L5GXJ36.js";
27
33
  export {
28
34
  Chalk,
29
35
  ChalkProvider,
@@ -31,9 +37,13 @@ export {
31
37
  THEME_PALETTES,
32
38
  THEME_TEXTURES,
33
39
  Theme,
40
+ buildChalkInviteLink,
34
41
  createNativeTheme,
42
+ extractSpaceInviteFromLink,
43
+ getClipboardInviteSuggestion,
35
44
  getThemeMode,
36
45
  isDarkThemePalette,
46
+ parseSpaceInviteLink,
37
47
  resolveNativeAppearance,
38
48
  useCan,
39
49
  useChat,
@@ -0,0 +1,3 @@
1
+ import type { CloudflareRTKClientFactory } from "@q9labsai/chalk-client";
2
+ export declare const createNativeRealtimeKitClient: CloudflareRTKClientFactory;
3
+ //# sourceMappingURL=cloudflare-rtk-native.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cloudflare-rtk-native.d.ts","sourceRoot":"","sources":["../../src/space-client/cloudflare-rtk-native.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,0BAA0B,EAAqD,MAAM,wBAAwB,CAAC;AAK5H,eAAO,MAAM,6BAA6B,EAAE,0BAQ3C,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"create-native-space-client.d.ts","sourceRoot":"","sources":["../../src/space-client/create-native-space-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAWL,KAAK,SAAS,EAGd,KAAK,uBAAuB,EAC7B,MAAM,wBAAwB,CAAC;AAEhC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AAI1D,OAAO,EAAwK,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAQ3N,MAAM,MAAM,wBAAwB,GAAG;IACrC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;IAC9B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvC,QAAQ,CAAC,UAAU,CAAC,EAAE,OAAO,CAAC;IAC9B,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,oBAAoB,CAAC,EAAE,MAAM,CAAC;IACvC,QAAQ,CAAC,OAAO,CAAC,EAAE,uBAAuB,CAAC;IAC3C,QAAQ,CAAC,SAAS,CAAC,EAAE,gBAAgB,CAAC;CACvC,CAAC;AAEF,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,wBAAwB,GAAG,WAAW,CAgDtF"}
1
+ {"version":3,"file":"create-native-space-client.d.ts","sourceRoot":"","sources":["../../src/space-client/create-native-space-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAYL,KAAK,SAAS,EAGd,KAAK,uBAAuB,EAC7B,MAAM,wBAAwB,CAAC;AAEhC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AAI1D,OAAO,EAAwK,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAU3N,MAAM,MAAM,wBAAwB,GAAG;IACrC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;IAC9B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvC,QAAQ,CAAC,UAAU,CAAC,EAAE,OAAO,CAAC;IAC9B,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,oBAAoB,CAAC,EAAE,MAAM,CAAC;IACvC,QAAQ,CAAC,OAAO,CAAC,EAAE,uBAAuB,CAAC;IAC3C,QAAQ,CAAC,SAAS,CAAC,EAAE,gBAAgB,CAAC;CACvC,CAAC;AAEF,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,wBAAwB,GAAG,WAAW,CAgDtF"}
@@ -1,2 +1,2 @@
1
- export declare function buildChalkInviteLink(joinToken: string, origin?: string): string;
1
+ export declare function buildChalkInviteLink(spaceSlug: string, spaceInviteToken: string, origin?: string): string;
2
2
  //# sourceMappingURL=build-chalk-invite-link.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"build-chalk-invite-link.d.ts","sourceRoot":"","sources":["../../src/utils/build-chalk-invite-link.ts"],"names":[],"mappings":"AAEA,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,SAAuB,GAAG,MAAM,CAE7F"}
1
+ {"version":3,"file":"build-chalk-invite-link.d.ts","sourceRoot":"","sources":["../../src/utils/build-chalk-invite-link.ts"],"names":[],"mappings":"AAEA,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,EAAE,MAAM,SAAuB,GAAG,MAAM,CAIvH"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@q9labsai/chalk-react-native",
3
- "version": "4.1.5",
3
+ "version": "4.1.8",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -150,9 +150,9 @@
150
150
  "registry": "https://registry.npmjs.org/"
151
151
  },
152
152
  "dependencies": {
153
- "@q9labsai/chalk-client": "^4.1.5",
154
- "@q9labsai/chalk-whiteboard": "^4.1.5",
155
- "@q9labsai/facehash": "^4.1.5"
153
+ "@q9labsai/chalk-client": "^4.1.8",
154
+ "@q9labsai/chalk-whiteboard": "^4.1.8",
155
+ "@q9labsai/facehash": "^4.1.8"
156
156
  },
157
157
  "devDependencies": {
158
158
  "@types/node": "^26.2.0",
@@ -162,6 +162,7 @@
162
162
  },
163
163
  "peerDependencies": {
164
164
  "@cloudflare/react-native-webrtc": "~125.0.1 || ~125.0.3",
165
+ "@cloudflare/realtimekit-react-native": "1.1.0",
165
166
  "@hugeicons/core-free-icons": "^4.0.0",
166
167
  "@hugeicons/react-native": "^1.0.13",
167
168
  "react": "^18.0.0 || ^19.0.0",
@@ -170,9 +171,9 @@
170
171
  "react-native-webview": "^13.16.1"
171
172
  },
172
173
  "scripts": {
173
- "build": "rm -rf ./dist && tsup ./src/index.ts ./src/client.ts ./src/android.ts ./src/ios.ts ./src/ios-phone.ts ./src/ios-pad.ts ./src/macos.ts ./src/runtime.ts ./src/clipboard.ts ./src/diagnostics.ts ./src/invites.ts ./src/ui/theme.ts ./src/ui/appearance.ts --format esm --out-dir ./dist --platform browser --external react --external react-native --external react-native-webview --external @q9labsai/chalk-whiteboard --external @cloudflare/react-native-webrtc && pnpm run build:types && pnpm run sync:whiteboard-assets",
174
+ "build": "rm -rf ./dist && tsup ./src/index.ts ./src/client.ts ./src/android.ts ./src/ios.ts ./src/ios-phone.ts ./src/ios-pad.ts ./src/macos.ts ./src/runtime.ts ./src/clipboard.ts ./src/diagnostics.ts ./src/invites.ts ./src/ui/theme.ts ./src/ui/appearance.ts --format esm --out-dir ./dist --platform browser --external react --external react-native --external react-native-webview --external @q9labsai/chalk-whiteboard --external @cloudflare/react-native-webrtc --external @cloudflare/realtimekit-react-native && pnpm run build:types && pnpm run sync:whiteboard-assets",
174
175
  "build:types": "rm -f tsconfig.tsbuildinfo && tsc --build tsconfig.json",
175
- "dev": "tsup ./src/index.ts ./src/client.ts ./src/android.ts ./src/ios.ts ./src/ios-phone.ts ./src/ios-pad.ts ./src/macos.ts ./src/runtime.ts ./src/clipboard.ts ./src/diagnostics.ts ./src/invites.ts ./src/ui/theme.ts ./src/ui/appearance.ts --format esm --out-dir ./dist --platform browser --external react --external react-native --external react-native-webview --external @q9labsai/chalk-whiteboard --external @cloudflare/react-native-webrtc --watch",
176
+ "dev": "tsup ./src/index.ts ./src/client.ts ./src/android.ts ./src/ios.ts ./src/ios-phone.ts ./src/ios-pad.ts ./src/macos.ts ./src/runtime.ts ./src/clipboard.ts ./src/diagnostics.ts ./src/invites.ts ./src/ui/theme.ts ./src/ui/appearance.ts --format esm --out-dir ./dist --platform browser --external react --external react-native --external react-native-webview --external @q9labsai/chalk-whiteboard --external @cloudflare/react-native-webrtc --external @cloudflare/realtimekit-react-native --watch",
176
177
  "sync:whiteboard-assets": "node ./scripts/sync-whiteboard-assets.mjs",
177
178
  "test": "pnpm exec vitest run",
178
179
  "check-types": "tsc --project tsconfig.check-types.json --noEmit",
@@ -9,12 +9,12 @@ describe("native diagnostics", () => {
9
9
  expect(buildDevDiagnosticsCopyText()).not.toContain("inviteToken");
10
10
  });
11
11
 
12
- it("uses the broker as the single environment boundary", () => {
12
+ it("uses the public-invite API as the single environment boundary", () => {
13
13
  setDevDiagnosticsEnvironment({
14
- brokerUrl: "http://localhost:8787/local-chalk",
14
+ apiBaseURL: "http://localhost:8787",
15
15
  });
16
16
  expect(getDevDiagnosticsState().environment.target).toBe("local");
17
- expect(classifyTarget("https://chalkmeet.com/local-chalk")).toBe("production");
17
+ expect(classifyTarget("https://api.chalkmeet.com")).toBe("production");
18
18
  });
19
19
 
20
20
  it("records failures without legacy auth or transport state", () => {
@@ -17,8 +17,8 @@ export interface DevDiagnosticsTimelineEntry {
17
17
  export interface DevDiagnosticsState {
18
18
  readonly enabled: boolean;
19
19
  readonly environment: {
20
+ readonly apiBaseURL: string | null;
20
21
  readonly buildProfile: string | null;
21
- readonly brokerUrl: string | null;
22
22
  readonly target: "custom" | "local" | "production" | "unknown";
23
23
  readonly routeKind: string | null;
24
24
  readonly routeSpaceId: string | null;
@@ -40,10 +40,10 @@ const maximumTimelineItems = 120;
40
40
  const listeners = new Set<() => void>();
41
41
  let state = initialState();
42
42
 
43
- export function classifyTarget(brokerUrl: string | null | undefined): DevDiagnosticsState["environment"]["target"] {
44
- if (!brokerUrl) return "unknown";
43
+ export function classifyTarget(apiBaseURL: string | null | undefined): DevDiagnosticsState["environment"]["target"] {
44
+ if (!apiBaseURL) return "unknown";
45
45
  try {
46
- const hostname = new URL(brokerUrl).hostname;
46
+ const hostname = new URL(apiBaseURL).hostname;
47
47
  if (["localhost", "127.0.0.1", "0.0.0.0"].includes(hostname)) return "local";
48
48
  if (hostname === "chalkmeet.com" || hostname.endsWith(".chalkmeet.com")) return "production";
49
49
  return "custom";
@@ -52,13 +52,13 @@ export function classifyTarget(brokerUrl: string | null | undefined): DevDiagnos
52
52
  }
53
53
  }
54
54
 
55
- export function resolveDevDiagnosticsMode({ isDevRuntime, brokerUrl }: { readonly isDevRuntime: boolean; readonly brokerUrl: string | null | undefined }): {
55
+ export function resolveDevDiagnosticsMode({ isDevRuntime, apiBaseURL }: { readonly isDevRuntime: boolean; readonly apiBaseURL: string | null | undefined }): {
56
56
  readonly enabled: boolean;
57
57
  readonly buildProfile: "development" | "production";
58
58
  } {
59
59
  return {
60
60
  enabled: isDevRuntime,
61
- buildProfile: isDevRuntime || classifyTarget(brokerUrl) === "local" ? "development" : "production",
61
+ buildProfile: isDevRuntime || classifyTarget(apiBaseURL) === "local" ? "development" : "production",
62
62
  };
63
63
  }
64
64
 
@@ -77,7 +77,7 @@ export function setDevDiagnosticsEnvironment(next: Partial<DevDiagnosticsState["
77
77
  environment: {
78
78
  ...current.environment,
79
79
  ...next,
80
- target: next.brokerUrl ? classifyTarget(next.brokerUrl) : current.environment.target,
80
+ target: next.apiBaseURL ? classifyTarget(next.apiBaseURL) : current.environment.target,
81
81
  },
82
82
  }));
83
83
  }
@@ -145,8 +145,8 @@ function initialState(): DevDiagnosticsState {
145
145
  return {
146
146
  enabled: isDevelopmentRuntime,
147
147
  environment: {
148
+ apiBaseURL: null,
148
149
  buildProfile: null,
149
- brokerUrl: null,
150
150
  target: "unknown",
151
151
  routeKind: null,
152
152
  routeSpaceId: null,
package/src/index.ts CHANGED
@@ -6,5 +6,8 @@ export { ChalkProvider, useSpaceClient } from "./context/space-client-context";
6
6
  export type { ChalkProviderProps } from "./context/space-client-context";
7
7
  export { useCan, useChat, useConnection, useMedia, useParticipants, useReactions, useSelf, useWhiteboard } from "./hooks/space-hooks";
8
8
  export { Theme, createNativeTheme } from "./ui/theme";
9
+ export { extractSpaceInviteFromLink, getClipboardInviteSuggestion, parseSpaceInviteLink } from "./invites";
10
+ export type { SpaceInviteLink } from "./invites";
11
+ export { buildChalkInviteLink } from "./utils/build-chalk-invite-link";
9
12
  export { getThemeMode, isDarkThemePalette, resolveNativeAppearance, THEME_PALETTES, THEME_TEXTURES } from "./ui/appearance";
10
13
  export type { NativeAppearance, NativeAppearanceTokens, NativeShadowToken, NativeTextureBlendMode, NativeTextureDescriptor, NativeTextureKind, ThemeAppearance, ThemeMode, ThemePalette, ThemeTexture } from "./ui/appearance";
@@ -1,53 +1,48 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { extractJoinTokenFromInviteLink, getClipboardInviteSuggestion } from "./invites";
2
+ import { getClipboardInviteSuggestion, parseSpaceInviteLink } from "./invites";
3
3
 
4
- describe("extractJoinTokenFromInviteLink", () => {
5
- const inviteToken = "i".repeat(43);
4
+ describe("parseSpaceInviteLink", () => {
5
+ const inviteToken = "cspi1.key.payload.signature";
6
6
 
7
7
  it("accepts Chalk https invite links", () => {
8
- expect(extractJoinTokenFromInviteLink(`https://chalk.q9labs.ai/j/${inviteToken}`)).toBe(inviteToken);
8
+ expect(parseSpaceInviteLink(`https://chalk.q9labs.ai/space/design-lab#spaceInviteToken=${inviteToken}`)).toEqual({ slug: "design-lab", spaceInviteToken: inviteToken });
9
9
  });
10
10
 
11
- it("accepts the new Chalk meet host", () => {
12
- expect(extractJoinTokenFromInviteLink(`https://chalkmeet.com/j/${inviteToken}`)).toBe(inviteToken);
11
+ it("accepts bare Chalk hostnames", () => {
12
+ expect(parseSpaceInviteLink(`chalkmeet.com/space/design-lab#spaceInviteToken=${inviteToken}`)).toEqual({ slug: "design-lab", spaceInviteToken: inviteToken });
13
13
  });
14
14
 
15
- it("accepts the canonical access hash", () => {
16
- expect(extractJoinTokenFromInviteLink(`https://chalkmeet.com/#access=${inviteToken}`)).toBe(inviteToken);
15
+ it("accepts native Chalk deep links with the canonical path", () => {
16
+ expect(parseSpaceInviteLink(`chalk://space/design-lab#spaceInviteToken=${inviteToken}`)).toEqual({ slug: "design-lab", spaceInviteToken: inviteToken });
17
+ expect(parseSpaceInviteLink(`ai.q9labs.chalk.mobile://space/design-lab#spaceInviteToken=${inviteToken}`)).toEqual({ slug: "design-lab", spaceInviteToken: inviteToken });
17
18
  });
18
19
 
19
- it("accepts bare Chalk invite hostnames", () => {
20
- expect(extractJoinTokenFromInviteLink(`chalk.q9labs.ai/j/${inviteToken}`)).toBe(inviteToken);
20
+ it("accepts loopback HTTP invite links for local development", () => {
21
+ expect(parseSpaceInviteLink(`http://127.0.0.1:3070/space/design-lab#spaceInviteToken=${inviteToken}`)).toEqual({ slug: "design-lab", spaceInviteToken: inviteToken });
22
+ expect(parseSpaceInviteLink(`http://localhost:3070/space/design-lab#spaceInviteToken=${inviteToken}`)).toEqual({ slug: "design-lab", spaceInviteToken: inviteToken });
21
23
  });
22
24
 
23
- it("accepts bare Chalk meet hostnames", () => {
24
- expect(extractJoinTokenFromInviteLink(`chalkmeet.com/j/${inviteToken}`)).toBe(inviteToken);
25
- });
26
-
27
- it("accepts native Chalk deep links", () => {
28
- expect(extractJoinTokenFromInviteLink(`chalk://j/${inviteToken}`)).toBe(inviteToken);
29
- });
30
-
31
- it("accepts bundle-scheme Chalk deep links", () => {
32
- expect(extractJoinTokenFromInviteLink(`ai.q9labs.chalk.mobile://j/${inviteToken}`)).toBe(inviteToken);
33
- });
34
-
35
- it("rejects direct Space links and raw codes", () => {
36
- expect(extractJoinTokenFromInviteLink("https://chalk.q9labs.ai/space/2f0b302b-2449-43f5-ae3b-de57decb9f09")).toBeNull();
37
- expect(extractJoinTokenFromInviteLink(`https://attacker.test/j/${inviteToken}`)).toBeNull();
38
- expect(extractJoinTokenFromInviteLink("ABC123")).toBeNull();
25
+ it("rejects legacy paths, missing slugs, and invalid tokens", () => {
26
+ expect(parseSpaceInviteLink(`https://chalk.q9labs.ai/legacy/${inviteToken}`)).toBeNull();
27
+ expect(parseSpaceInviteLink(`https://chalk.q9labs.ai/space#spaceInviteToken=${inviteToken}`)).toBeNull();
28
+ expect(parseSpaceInviteLink("https://chalk.q9labs.ai/space/design-lab#invite=legacy")).toBeNull();
29
+ expect(parseSpaceInviteLink("https://attacker.test/space/design-lab#spaceInviteToken=cspi1.key.payload.signature")).toBeNull();
30
+ expect(parseSpaceInviteLink(`http://chalkmeet.com/space/design-lab#spaceInviteToken=${inviteToken}`)).toBeNull();
31
+ expect(parseSpaceInviteLink(`http://192.168.1.10/space/design-lab#spaceInviteToken=${inviteToken}`)).toBeNull();
32
+ expect(parseSpaceInviteLink("https://chalk.q9labs.ai/space/design-lab#spaceInviteToken=ABC123")).toBeNull();
33
+ expect(parseSpaceInviteLink(`https://chalk.q9labs.ai/space/design-lab#spaceInviteToken=${`cspi1.${"k".repeat(170)}.${"p".repeat(170)}.${"s".repeat(170)}`}`)).toBeNull();
39
34
  });
40
35
  });
41
36
 
42
37
  describe("getClipboardInviteSuggestion", () => {
43
- const inviteToken = "i".repeat(43);
38
+ const inviteLink = "https://chalkmeet.com/space/design-lab#spaceInviteToken=cspi1.key.payload.signature";
44
39
 
45
40
  it("returns a copied Chalk invite link when it differs from the current input", () => {
46
- expect(getClipboardInviteSuggestion(`https://chalkmeet.com/j/${inviteToken}`, "")).toBe(`https://chalkmeet.com/j/${inviteToken}`);
41
+ expect(getClipboardInviteSuggestion(inviteLink, "")).toBe(inviteLink);
47
42
  });
48
43
 
49
44
  it("hides the suggestion when the input already matches the clipboard", () => {
50
- expect(getClipboardInviteSuggestion(`https://chalkmeet.com/j/${inviteToken}`, `https://chalkmeet.com/j/${inviteToken}`)).toBeNull();
45
+ expect(getClipboardInviteSuggestion(inviteLink, inviteLink)).toBeNull();
51
46
  });
52
47
 
53
48
  it("ignores non-invite clipboard values", () => {
package/src/invites.ts CHANGED
@@ -1,8 +1,14 @@
1
- const CHALK_INVITE_HOSTS = ["chalkmeet.com", "chalk.q9labs.ai"] as const;
1
+ const CHALK_INVITE_HOSTS: readonly string[] = ["chalkmeet.com", "chalk.q9labs.ai"];
2
2
  const CHALK_CUSTOM_PROTOCOLS = new Set(["chalk:", "ai.q9labs.chalk.mobile:"]);
3
- const capabilityPattern = /^[A-Za-z0-9_-]{43}$/u;
3
+ const SPACE_SLUG_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u;
4
+ const SPACE_INVITE_TOKEN_PATTERN = /^cspi1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/u;
4
5
 
5
- export function extractJoinTokenFromInviteLink(input: string): string | null {
6
+ export type SpaceInviteLink = {
7
+ readonly slug: string;
8
+ readonly spaceInviteToken: string;
9
+ };
10
+
11
+ export function parseSpaceInviteLink(input: string): SpaceInviteLink | null {
6
12
  const trimmed = input.trim();
7
13
  if (!trimmed) {
8
14
  return null;
@@ -15,20 +21,38 @@ export function extractJoinTokenFromInviteLink(input: string): string | null {
15
21
 
16
22
  try {
17
23
  const parsed = new URL(normalized);
18
- if (!CHALK_CUSTOM_PROTOCOLS.has(parsed.protocol) && (parsed.protocol !== "https:" || !CHALK_INVITE_HOSTS.includes(parsed.hostname as (typeof CHALK_INVITE_HOSTS)[number]))) {
24
+ if (!CHALK_CUSTOM_PROTOCOLS.has(parsed.protocol) && !isAllowedWebInviteOrigin(parsed)) {
19
25
  return null;
20
26
  }
21
- const pathSegments = CHALK_CUSTOM_PROTOCOLS.has(parsed.protocol) ? [parsed.hostname, ...parsed.pathname.split("/").filter(Boolean)] : parsed.pathname.split("/").filter(Boolean);
22
- const [head, tail] = pathSegments;
23
- const hashInvite = new URLSearchParams(parsed.hash.replace(/^#/u, "")).get("access");
24
- const token = head === "j" && tail ? tail : hashInvite?.trim();
27
+ const pathSegments = parsed.pathname.split("/").filter(Boolean);
28
+ if (CHALK_CUSTOM_PROTOCOLS.has(parsed.protocol)) pathSegments.unshift(parsed.hostname);
29
+ if (pathSegments.length !== 2 || pathSegments[0] !== "space") return null;
25
30
 
26
- return token && capabilityPattern.test(token) ? token : null;
31
+ const slug = pathSegments[1];
32
+ const token = new URLSearchParams(parsed.hash.replace(/^#/u, "")).get("spaceInviteToken")?.trim();
33
+ return slug && token && token.length <= 512 && SPACE_SLUG_PATTERN.test(slug) && SPACE_INVITE_TOKEN_PATTERN.test(token) ? { slug, spaceInviteToken: token } : null;
27
34
  } catch {
28
35
  return null;
29
36
  }
30
37
  }
31
38
 
39
+ function isChalkInviteHost(hostname: string): boolean {
40
+ return CHALK_INVITE_HOSTS.some((host) => host === hostname);
41
+ }
42
+
43
+ function isAllowedWebInviteOrigin(url: URL): boolean {
44
+ if (url.protocol === "https:") return isChalkInviteHost(url.hostname);
45
+ return url.protocol === "http:" && isLoopbackHost(url.hostname);
46
+ }
47
+
48
+ function isLoopbackHost(hostname: string): boolean {
49
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
50
+ }
51
+
52
+ export function extractSpaceInviteFromLink(input: string): SpaceInviteLink | null {
53
+ return parseSpaceInviteLink(input);
54
+ }
55
+
32
56
  export function getClipboardInviteSuggestion(clipboardText: string | null | undefined, currentInput = ""): string | null {
33
57
  const normalizedClipboardText = clipboardText?.trim();
34
58
  if (!normalizedClipboardText) {
@@ -39,5 +63,5 @@ export function getClipboardInviteSuggestion(clipboardText: string | null | unde
39
63
  return null;
40
64
  }
41
65
 
42
- return extractJoinTokenFromInviteLink(normalizedClipboardText) ? normalizedClipboardText : null;
66
+ return parseSpaceInviteLink(normalizedClipboardText) ? normalizedClipboardText : null;
43
67
  }
@@ -0,0 +1,118 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const initialize = vi.hoisted(() => vi.fn());
4
+
5
+ vi.mock("@cloudflare/realtimekit-react-native", () => ({ default: { init: initialize } }));
6
+
7
+ import { createNativeRealtimeKitClient } from "./cloudflare-rtk-native";
8
+
9
+ describe("native RealtimeKit adapter", () => {
10
+ beforeEach(() => {
11
+ vi.clearAllMocks();
12
+ });
13
+
14
+ it("initializes, joins, leaves, and projects native participants", async () => {
15
+ const selfOn = vi.fn();
16
+ const selfOff = vi.fn();
17
+ const joinedOn = vi.fn();
18
+ const joinedOff = vi.fn();
19
+ const joinRoom = vi.fn().mockResolvedValue(undefined);
20
+ const leaveRoom = vi.fn().mockResolvedValue(undefined);
21
+ const audioTrack = { kind: "audio" };
22
+ const videoTrack = { kind: "video" };
23
+ const participant = {
24
+ id: "native-participant",
25
+ userId: "user",
26
+ customParticipantId: "custom-participant",
27
+ audioEnabled: true,
28
+ videoEnabled: false,
29
+ screenShareEnabled: false,
30
+ audioTrack,
31
+ videoTrack: null,
32
+ screenShareTracks: {},
33
+ };
34
+ const nativeClient = {
35
+ joinRoom,
36
+ leaveRoom,
37
+ self: {
38
+ peerId: "peer",
39
+ audioEnabled: true,
40
+ videoEnabled: false,
41
+ screenShareEnabled: false,
42
+ audioTrack,
43
+ videoTrack,
44
+ screenShareTracks: {},
45
+ enableAudio: vi.fn().mockResolvedValue(undefined),
46
+ enableVideo: vi.fn().mockResolvedValue(undefined),
47
+ enableScreenShare: vi.fn().mockResolvedValue(undefined),
48
+ disableAudio: vi.fn().mockResolvedValue(undefined),
49
+ disableVideo: vi.fn().mockResolvedValue(undefined),
50
+ disableScreenShare: vi.fn().mockResolvedValue(undefined),
51
+ on: selfOn,
52
+ off: selfOff,
53
+ },
54
+ participants: {
55
+ joined: {
56
+ toArray: vi.fn(() => [participant]),
57
+ on: joinedOn,
58
+ off: joinedOff,
59
+ },
60
+ },
61
+ };
62
+ initialize.mockResolvedValue(nativeClient);
63
+
64
+ const onError = vi.fn();
65
+ const connection = await createNativeRealtimeKitClient({ authToken: "rtk-token", onError });
66
+ await connection.join();
67
+ await connection.leave();
68
+
69
+ expect(initialize).toHaveBeenCalledWith({ authToken: "rtk-token", defaults: { audio: false, video: false }, onError });
70
+ expect(joinRoom).toHaveBeenCalledOnce();
71
+ expect(leaveRoom).toHaveBeenCalledOnce();
72
+ expect(connection.self.peerId).toBe("peer");
73
+ expect(connection.self.audioTrack).toBe(audioTrack);
74
+ expect(connection.participants.joined.list()).toEqual([expect.objectContaining({ id: "native-participant", customParticipantId: "custom-participant", audioTrack })]);
75
+ });
76
+
77
+ it("bridges lifecycle event subscriptions and removes them", async () => {
78
+ const selfOn = vi.fn();
79
+ const selfOff = vi.fn();
80
+ const joinedOn = vi.fn();
81
+ const joinedOff = vi.fn();
82
+ initialize.mockResolvedValue({
83
+ joinRoom: vi.fn(),
84
+ leaveRoom: vi.fn(),
85
+ self: {
86
+ peerId: "peer",
87
+ audioEnabled: false,
88
+ videoEnabled: false,
89
+ screenShareEnabled: false,
90
+ audioTrack: null,
91
+ videoTrack: null,
92
+ screenShareTracks: {},
93
+ enableAudio: vi.fn(),
94
+ enableVideo: vi.fn(),
95
+ enableScreenShare: vi.fn(),
96
+ disableAudio: vi.fn(),
97
+ disableVideo: vi.fn(),
98
+ disableScreenShare: vi.fn(),
99
+ on: selfOn,
100
+ off: selfOff,
101
+ },
102
+ participants: { joined: { toArray: vi.fn(() => []), on: joinedOn, off: joinedOff } },
103
+ });
104
+
105
+ const connection = await createNativeRealtimeKitClient({ authToken: "rtk-token", onError: vi.fn() });
106
+ const listener = vi.fn();
107
+ const unsubscribeAudio = connection.self.onAudioUpdate(listener);
108
+ const unsubscribeJoined = connection.participants.joined.onJoined(listener);
109
+
110
+ unsubscribeAudio();
111
+ unsubscribeJoined();
112
+
113
+ expect(selfOn).toHaveBeenCalledWith("audioUpdate", listener);
114
+ expect(selfOff).toHaveBeenCalledWith("audioUpdate", listener);
115
+ expect(joinedOn).toHaveBeenCalledWith("participantJoined", expect.any(Function));
116
+ expect(joinedOff).toHaveBeenCalledWith("participantJoined", expect.any(Function));
117
+ });
118
+ });
@@ -0,0 +1,104 @@
1
+ import type RealtimeKitClient from "@cloudflare/realtimekit-react-native";
2
+ import type { CloudflareRTKClientFactory, CloudflareRTKConnection, CloudflareRTKParticipant } from "@q9labsai/chalk-client";
3
+
4
+ type NativeRealtimeKitClient = Awaited<ReturnType<typeof RealtimeKitClient.init>>;
5
+ type NativeRealtimeKitParticipant = ReturnType<NativeRealtimeKitClient["participants"]["joined"]["toArray"]>[number];
6
+
7
+ export const createNativeRealtimeKitClient: CloudflareRTKClientFactory = async ({ authToken, onError }) => {
8
+ const { default: NativeRealtimeKitClient } = await import("@cloudflare/realtimekit-react-native");
9
+ const client = await NativeRealtimeKitClient.init({
10
+ authToken,
11
+ defaults: { audio: false, video: false },
12
+ onError,
13
+ });
14
+ return adaptNativeRealtimeKitClient(client);
15
+ };
16
+
17
+ function adaptNativeRealtimeKitClient(client: NativeRealtimeKitClient): CloudflareRTKConnection {
18
+ const self = client.self;
19
+ return {
20
+ join: () => client.joinRoom(),
21
+ leave: () => client.leaveRoom(),
22
+ self: {
23
+ get peerId() {
24
+ return self.peerId;
25
+ },
26
+ get audioEnabled() {
27
+ return self.audioEnabled;
28
+ },
29
+ get videoEnabled() {
30
+ return self.videoEnabled;
31
+ },
32
+ get screenShareEnabled() {
33
+ return self.screenShareEnabled;
34
+ },
35
+ get audioTrack() {
36
+ return self.audioTrack ?? null;
37
+ },
38
+ get videoTrack() {
39
+ return self.videoTrack ?? null;
40
+ },
41
+ get screenShareTracks() {
42
+ return {
43
+ ...(self.screenShareTracks.audio ? { audio: self.screenShareTracks.audio } : {}),
44
+ ...(self.screenShareTracks.video ? { video: self.screenShareTracks.video } : {}),
45
+ };
46
+ },
47
+ enableAudio: () => self.enableAudio(),
48
+ enableVideo: () => self.enableVideo(),
49
+ enableScreenShare: () => self.enableScreenShare(),
50
+ disableAudio: () => self.disableAudio(),
51
+ disableVideo: () => self.disableVideo(),
52
+ disableScreenShare: () => self.disableScreenShare(),
53
+ onAudioUpdate: (listener) => {
54
+ self.on("audioUpdate", listener);
55
+ return () => self.off("audioUpdate", listener);
56
+ },
57
+ onVideoUpdate: (listener) => {
58
+ self.on("videoUpdate", listener);
59
+ return () => self.off("videoUpdate", listener);
60
+ },
61
+ onScreenShareUpdate: (listener) => {
62
+ self.on("screenShareUpdate", listener);
63
+ return () => self.off("screenShareUpdate", listener);
64
+ },
65
+ onLeft: (listener) => {
66
+ const callback = () => listener();
67
+ self.on("roomLeft", callback);
68
+ return () => self.off("roomLeft", callback);
69
+ },
70
+ },
71
+ participants: {
72
+ joined: {
73
+ list: () => client.participants.joined.toArray().map(toParticipant),
74
+ onJoined: (listener) => {
75
+ const callback = (participant: NativeRealtimeKitParticipant) => listener(toParticipant(participant));
76
+ client.participants.joined.on("participantJoined", callback);
77
+ return () => client.participants.joined.off("participantJoined", callback);
78
+ },
79
+ onLeft: (listener) => {
80
+ const callback = (participant: NativeRealtimeKitParticipant) => listener(toParticipant(participant));
81
+ client.participants.joined.on("participantLeft", callback);
82
+ return () => client.participants.joined.off("participantLeft", callback);
83
+ },
84
+ },
85
+ },
86
+ };
87
+ }
88
+
89
+ function toParticipant(participant: NativeRealtimeKitParticipant): CloudflareRTKParticipant {
90
+ return {
91
+ id: participant.id,
92
+ userId: participant.userId,
93
+ ...(participant.customParticipantId === undefined ? {} : { customParticipantId: participant.customParticipantId }),
94
+ audioEnabled: participant.audioEnabled,
95
+ videoEnabled: participant.videoEnabled,
96
+ screenShareEnabled: participant.screenShareEnabled,
97
+ audioTrack: participant.audioTrack ?? null,
98
+ videoTrack: participant.videoTrack ?? null,
99
+ screenShareTracks: {
100
+ ...(participant.screenShareTracks.audio ? { audio: participant.screenShareTracks.audio } : {}),
101
+ ...(participant.screenShareTracks.video ? { video: participant.screenShareTracks.video } : {}),
102
+ },
103
+ };
104
+ }
@@ -4,10 +4,43 @@ const createSpaceClientForPlatform = vi.hoisted(() => vi.fn());
4
4
  const addAppStateListener = vi.hoisted(() => vi.fn());
5
5
  const createV1SyncClient = vi.hoisted(() => vi.fn());
6
6
  const createCloudflareSFUHTTPTransport = vi.hoisted(() => vi.fn(() => ({})));
7
+ const CloudflareRTKClient = vi.hoisted(() =>
8
+ vi.fn(
9
+ class MockCloudflareRTKClient {
10
+ setLocalPublicationTarget = vi.fn();
11
+ observeLocalPublications = vi.fn();
12
+ observeRemotePublications = vi.fn();
13
+ start = vi.fn();
14
+ stop = vi.fn();
15
+ restart = vi.fn();
16
+ prepareLocalTrack = vi.fn();
17
+ clearPreparedLocalTrack = vi.fn();
18
+ getSnapshot = vi.fn();
19
+ subscribe = vi.fn();
20
+ },
21
+ ),
22
+ );
23
+ const CloudflareSFUClient = vi.hoisted(() =>
24
+ vi.fn(
25
+ class MockCloudflareSFUClient {
26
+ setLocalPublicationTarget = vi.fn();
27
+ observeLocalPublications = vi.fn();
28
+ observeRemotePublications = vi.fn();
29
+ start = vi.fn();
30
+ stop = vi.fn();
31
+ restart = vi.fn();
32
+ prepareLocalTrack = vi.fn();
33
+ clearPreparedLocalTrack = vi.fn();
34
+ getSnapshot = vi.fn();
35
+ subscribe = vi.fn();
36
+ },
37
+ ),
38
+ );
7
39
 
8
40
  vi.mock("@q9labsai/chalk-client", () => ({
9
41
  AsyncStorageV1PendingTargetStore: vi.fn(),
10
- CloudflareSFUClient: vi.fn(),
42
+ CloudflareRTKClient,
43
+ CloudflareSFUClient,
11
44
  createChalkChatFileHttpTransport: vi.fn(),
12
45
  createChalkWhiteboardV1Client: vi.fn(),
13
46
  createChalkWhiteboardV1FileHttpTransport: vi.fn(),
@@ -58,6 +91,26 @@ describe("native client creation", () => {
58
91
  expect(createSpaceClientForPlatform.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ apiBaseUrl: "https://api.chalkmeet.com", syncUrl: "wss://sync.chalkmeet.com/v1/sync" }));
59
92
  });
60
93
 
94
+ it("selects the native RealtimeKit adapter for RTK access grants", () => {
95
+ createNativeSpaceClient(options());
96
+ const platform = createSpaceClientForPlatform.mock.calls[0]?.[1] as {
97
+ readonly dependencies: { readonly createMediaClient: (input: unknown) => unknown };
98
+ };
99
+
100
+ platform.dependencies.createMediaClient({
101
+ access: {
102
+ subject: { tenantId: "tenant", spaceId: "space", episodeId: "episode", participantId: "participant", participantGeneration: 1 },
103
+ media: { provider: "cloudflare_rtk", clientPayload: { providerSubject: "provider-subject", token: "rtk-token" } },
104
+ },
105
+ credential: vi.fn(),
106
+ onFailure: vi.fn(),
107
+ onScreenEnded: vi.fn(),
108
+ });
109
+
110
+ expect(CloudflareRTKClient).toHaveBeenCalledWith(expect.objectContaining({ authToken: "rtk-token", participantId: "participant", clientFactory: expect.any(Function) }));
111
+ expect(createCloudflareSFUHTTPTransport).not.toHaveBeenCalled();
112
+ });
113
+
61
114
  it("passes the closed access callback to the client", () => {
62
115
  const getAccess = vi.fn();
63
116
  createNativeSpaceClient({ ...options(), getAccess });