diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2e10bece5..e26a770ef6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ permissions: jobs: lint: runs-on: ubuntu-latest - name: ESLint & Prettier + name: ESLint, Prettier & Types steps: - uses: actions/checkout@v6 @@ -28,6 +28,9 @@ jobs: - run: yarn lint + - name: Typecheck the library + run: yarn types + # scripts/ is outside the src tsconfigs and is not linted; Node 24 strips the types in # the .mts build scripts without checking them, so check them here. - name: Typecheck build scripts diff --git a/.github/workflows/size.yml b/.github/workflows/size.yml index 80482282b2..34b41ca69a 100644 --- a/.github/workflows/size.yml +++ b/.github/workflows/size.yml @@ -4,9 +4,6 @@ on: pull_request: branches: - master - # The v15 release branch. Without it this workflow does not run on any PR stacked onto it, so the - # i18n consolidation's central size claim -- that moving the runtime into `stream-chat/i18n` - # shrinks the root bundle -- goes unmeasured for the whole release. - release-v15 paths-ignore: - '**.test.*' diff --git a/AGENTS.md b/AGENTS.md index ada19dcaa1..7ead8230fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,7 @@ Respect any repo-specific rules. Do not suppress rules broadly; justify and scop 1. Install dependencies (root + all workspaces): yarn install 2. Build: yarn build -3. Typecheck: yarn types +3. Typecheck: yarn types (the library, via tsconfig.lib.json) 4. Lint: yarn lint 5. Fix lint issues: yarn lint-fix 6. Unit tests: yarn test diff --git a/CLAUDE.md b/CLAUDE.md index 316ec32b07..ceac2e89d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,8 +25,8 @@ yarn test # Run specific test (e.g., yarn test Channel) yarn lint-fix # Fix all lint/format issues (prettier + eslint) # Type checking -tsc -p tsconfig.lib.json --noEmit # The library. THIS is the real check. -yarn types:scripts # scripts/*.mts (Node strips types, it does not check them) +yarn types # Typecheck the library (tsc -p tsconfig.lib.json --noEmit) +yarn types:scripts # scripts/*.mts (Node strips types, it does not check them) # i18n (see the i18n System section) yarn build-translations # Regenerate src/i18n/keys.ts from the t() call sites @@ -41,9 +41,14 @@ yarn examples:build # Build all examples yarn lint-fix # ALWAYS run this first ``` -> **`yarn types` checks nothing — do not rely on it.** It runs `tsc` with no `--project`, so it -> picks up the root `tsconfig.json`, which is a solution file with `"files": []`. It exits 0 even -> with a deliberate type error in `src/`. Use `tsc -p tsconfig.lib.json --noEmit`. +> **`yarn types` is `tsc -p tsconfig.lib.json --noEmit`** — it typechecks the library and nothing +> else. It used to run `tsc` with no `--project`, which picked up the root `tsconfig.json` (a +> solution file with `"files": []`) and exited 0 even with a deliberate type error in `src/`; that +> is fixed. `tsconfig.lib.json` excludes `__tests__`, `stories` and `mock-builders`, so those are +> not covered here. +> +> **CI runs it** in the `ESLint, Prettier & Types` job, alongside `yarn lint` and +> `yarn types:scripts` — the cheapest job to fail in, since it needs no build. > > `yarn types:tests` (`tsconfig.test.json`) reports ~1200 pre-existing errors and is not wired into > CI. Treat it as unenforced. @@ -283,7 +288,7 @@ Closes #123 - [ ] `yarn lint-fix` passed - [ ] `yarn test` passed -- [ ] `tsc -p tsconfig.lib.json --noEmit` passed (NOT `yarn types` — see Essential Commands) +- [ ] `yarn types` passed (typechecks the library) - [ ] `yarn validate-translations` passed, if any `t()` call changed - [ ] Tests added for changes - [ ] No new warnings (zero tolerance) @@ -326,6 +331,26 @@ See `examples/vite/src/index.scss` for reference implementation. Layers eliminat ## i18n System +**The runtime is `@stream-io/i18n`**, shared with the React Native SDK — one `Streami18n`, one set +of formatters, one date layer. This package owns only what is genuinely its own: the generated key +catalog, `runtimeDefaults.ts`, the React context/hook binding, and the notification translation +topic. `src/i18n/Streami18n.ts` is a thin subclass injecting this SDK's bundled data. + +It is a **regular dependency**, not a peer: an integrator never imports `@stream-io/i18n` — they +import `Streami18n` from this package, which subclasses it. Peer dependencies are for things the +app itself constructs and hands back (`react`, `stream-chat`), which is not the case here. + +Two resolved copies of `i18next` would be harmless anyway — `Streami18n` calls +`i18next.createInstance()` and registers dictionaries on that instance, never on a global. `dayjs` +is the one that genuinely wants a single copy, because locale registration _is_ global: an app +adding a language does `import 'dayjs/locale/de.js'`, and that has to land in the same registry the +SDK reads. Keeping the range identical (`^1.11.23`) is what makes them dedupe. Do not add +`i18next` as a direct dependency here. + +The `language.*` names (`languageNameDefaults`, `LanguageNameCatalog`) still come from +`stream-chat`, not from `@stream-io/i18n` — they enumerate the languages the Chat API can +auto-translate a message into, so they are Chat API metadata. + **English only.** Every other language is supplied by the integrator via `Streami18n.registerTranslation()`. @@ -365,8 +390,11 @@ renders English — and it keeps the copy visible at the call site. - **Runtime keys:** the ~10 keys resolved from a runtime value (a `stream-chat` `notification.message`, slash-command metadata, a language code, an integrator prop) go through `asDynamicKey()`. That brand is required, so every escape is deliberate and greppable. - `src/i18n/externalStrings.ts` maps the `stream-chat` messages we recognise onto stable keys. -- **`yarn build-translations`** parses the `t()` call sites (`scripts/i18n-call-sites.mts`), joins + A `stream-chat` notification is resolved by its stable `type` identifier + (`CORE_NOTIFICATION_TYPE`) through `src/i18n/TranslationBuilder/notifications/`, not by matching + its English prose — the `externalStrings.ts` table that used to do the latter is gone. +- **`yarn build-translations`** parses the `t()` call sites (via `@stream-io/i18n/codegen`, driven by + `scripts/generate-i18n-keys.mts`), joins them with `runtimeDefaults.ts`, and regenerates `keys.ts`. It hard-fails on three things: a key used with two different inline copies; a key called with no inline default and no `runtimeDefaults` entry (it would render as the raw dotted key); and a key present in _both_ diff --git a/ai-docs/i18n-v15-migration.md b/ai-docs/i18n-v15-migration.md index 58c09f5180..f2cb8fa6bb 100644 --- a/ai-docs/i18n-v15-migration.md +++ b/ai-docs/i18n-v15-migration.md @@ -320,7 +320,7 @@ with no dictionary behind it. Also now internal, none of them documented before: `translations`, `dayjsLocales`, `isCustomDateTimeParser`, `localeExists()`, `addOrUpdateLocale()`, `validateCurrentLanguage()`. To -register a dayjs locale directly, `stream-chat/i18n` exports `addOrUpdateDayjsLocale()`. +register a dayjs locale directly, `@stream-io/i18n` exports `addOrUpdateDayjsLocale()`. ### `useChat` no longer returns `translators` diff --git a/examples/tutorial/package.json b/examples/tutorial/package.json index de37262715..1f3438a5b0 100644 --- a/examples/tutorial/package.json +++ b/examples/tutorial/package.json @@ -16,7 +16,7 @@ "emoji-mart": "^5.6.0", "react": "^19.2.6", "react-dom": "^19.2.6", - "stream-chat": "10.0.0-rc.9", + "stream-chat": "^10.0.0-rc.10", "stream-chat-react": "workspace:^" }, "devDependencies": { diff --git a/examples/vite/package.json b/examples/vite/package.json index a755391dbb..2b35d46c10 100644 --- a/examples/vite/package.json +++ b/examples/vite/package.json @@ -10,14 +10,15 @@ }, "dependencies": { "@emoji-mart/data": "^1.2.1", + "@stream-io/state-store": "^1.1.6", "clsx": "^2.1.1", - "dayjs": "^1.11.20", + "dayjs": "^1.11.23", "emoji-mart": "^5.6.0", "human-id": "^4.1.3", "modern-normalize": "^3.0.1", "react": "^19.2.6", "react-dom": "^19.2.6", - "stream-chat": "10.0.0-rc.9", + "stream-chat": "^10.0.0-rc.10", "stream-chat-react": "workspace:^" }, "devDependencies": { diff --git a/examples/vite/src/AppSettings/state.ts b/examples/vite/src/AppSettings/state.ts index d8e992e0f1..812dac611e 100644 --- a/examples/vite/src/AppSettings/state.ts +++ b/examples/vite/src/AppSettings/state.ts @@ -1,4 +1,4 @@ -import { StateStore } from 'stream-chat'; +import { StateStore } from '@stream-io/state-store'; import { useStateStore } from 'stream-chat-react'; import { DEFAULT_LANGUAGE, streamI18n } from '../i18n'; diff --git a/package.json b/package.json index 1bd41ed560..9eac7e6957 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,8 @@ "@braintree/sanitize-url": "^7.1.2", "@floating-ui/react": "^0.27.19", "@react-aria/focus": "^3.22.0", + "@stream-io/i18n": "^1.0.2", + "@stream-io/state-store": "^1.1.6", "clsx": "^2.1.1", "emoji-regex": "^9.2.2", "fix-webm-duration": "^1.0.6", @@ -116,7 +118,7 @@ "unified": "^11.0.5", "unist-builder": "^4.0.0", "unist-util-visit": "^5.1.0", - "use-sync-external-store": "^1.6.0" + "use-sync-external-store": "^1.7.0" }, "optionalDependencies": { "@stream-io/transliterate": "^1.5.5" @@ -129,7 +131,7 @@ "modern-normalize": "^3.0.1", "react": "^19.0.0 || ^18.0.0 || ^17.0.0", "react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0", - "stream-chat": "10.0.0-rc.9" + "stream-chat": "^10.0.0-rc.10" }, "peerDependenciesMeta": { "@breezystack/lamejs": { @@ -183,7 +185,7 @@ "@vitest/eslint-plugin": "^1.6.20", "concurrently": "^9.2.1", "conventional-changelog-conventionalcommits": "^9.3.1", - "dayjs": "^1.11.13", + "dayjs": "^1.11.23", "emoji-mart": "^5.6.0", "eslint": "^9.39.4", "eslint-plugin-import": "^2.32.0", @@ -199,7 +201,7 @@ "react-dom": "^19.2.6", "sass": "^1.100.0", "semantic-release": "^25.0.3", - "stream-chat": "10.0.0-rc.9", + "stream-chat": "^10.0.0-rc.10", "typescript": "^6.0.3", "typescript-eslint": "^8.59.4", "vite": "^8.1.3", @@ -227,7 +229,7 @@ "postinstall": "node -e \"require('fs').existsSync('scripts/install-husky.mjs') && import('./scripts/install-husky.mjs')\"", "test": "vitest run", "test:watch": "vitest", - "types": "tsc --emitDeclarationOnly false --noEmit", + "types": "tsc -p tsconfig.lib.json --noEmit", "types:tests": "tsc --project tsconfig.test.json --noEmit", "validate-translations": "yarn build-translations && git diff --exit-code -- src/i18n/keys.ts", "validate-cjs": "concurrently 'node scripts/validate-cjs-node-bundle.cjs' 'node scripts/validate-cjs-browser-bundle.cjs'", diff --git a/scripts/generate-i18n-keys.mts b/scripts/generate-i18n-keys.mts index d3564e4ecb..341fac7a53 100644 --- a/scripts/generate-i18n-keys.mts +++ b/scripts/generate-i18n-keys.mts @@ -2,14 +2,14 @@ // copy. The i18n types derive `TranslationKey` / `StreamTFunction` from it, so a typo'd key is a compile // error rather than a string that silently stops rendering. // -// The generator itself lives in `stream-chat/i18n/codegen`, shared with the React Native SDK. Only this +// The generator itself lives in `@stream-io/i18n/codegen`, shared with the React Native SDK. Only this // package's paths and prefixes are configured here; the call-site reader, the four hard-fail guards and // the emitter are all core's. // // Run by `yarn build-translations`, from the package root — every path below is relative to it. // `yarn validate-translations` runs it and fails on any diff, which is the drift gate. import ts from 'typescript'; -import { generateI18nKeys } from 'stream-chat/i18n/codegen'; +import { generateI18nKeys } from '@stream-io/i18n/codegen'; const jsonFlag = process.argv.indexOf('--json'); const jsonOut = jsonFlag === -1 ? undefined : process.argv[jsonFlag + 1]; @@ -22,7 +22,7 @@ if (jsonFlag !== -1 && (!jsonOut || jsonOut.startsWith('--'))) { try { generateI18nKeys({ fixtureOut: 'src/i18n/__tests__/catalog.fixture.json', - // `language.*` names come from `stream-chat/i18n` rather than from this package's + // `language.*` names come from `@stream-io/i18n` rather than from this package's // runtimeDefaults, so they are excluded from the translator export alongside the formatter // expressions — a TMS should not be asked to translate the SDK's own language list. extraFormatterPrefixes: ['translationBuilderTopic.', 'language.'], diff --git a/src/components/AudioPlayback/AudioPlayer.ts b/src/components/AudioPlayback/AudioPlayer.ts index 8e29b675bc..103ef9bf3a 100644 --- a/src/components/AudioPlayback/AudioPlayer.ts +++ b/src/components/AudioPlayback/AudioPlayer.ts @@ -1,4 +1,4 @@ -import { StateStore } from 'stream-chat'; +import { StateStore } from '@stream-io/state-store'; import throttle from 'lodash.throttle'; import type { AudioPlayerPlugin } from './plugins'; import type { AudioPlayerPool } from './AudioPlayerPool'; diff --git a/src/components/AudioPlayback/AudioPlayerPool.ts b/src/components/AudioPlayback/AudioPlayerPool.ts index f5936daa48..aa9ce6e536 100644 --- a/src/components/AudioPlayback/AudioPlayerPool.ts +++ b/src/components/AudioPlayback/AudioPlayerPool.ts @@ -1,5 +1,5 @@ import { AudioPlayer, type AudioPlayerOptions } from './AudioPlayer'; -import { StateStore } from 'stream-chat'; +import { StateStore } from '@stream-io/state-store'; export type AudioPlayerPoolState = { activeAudioPlayer: AudioPlayer | null; diff --git a/src/components/Dialog/service/DialogManager.ts b/src/components/Dialog/service/DialogManager.ts index 8157d6725c..a4814e49f1 100644 --- a/src/components/Dialog/service/DialogManager.ts +++ b/src/components/Dialog/service/DialogManager.ts @@ -1,5 +1,5 @@ import { nanoid } from 'nanoid'; -import { StateStore } from 'stream-chat'; +import { StateStore } from '@stream-io/state-store'; export type GetDialogParams = { id: DialogId; diff --git a/src/components/InfiniteScrollPaginator/InfiniteScrollWithComponents.tsx b/src/components/InfiniteScrollPaginator/InfiniteScrollWithComponents.tsx index 15f12aa683..191e9e3bad 100644 --- a/src/components/InfiniteScrollPaginator/InfiniteScrollWithComponents.tsx +++ b/src/components/InfiniteScrollPaginator/InfiniteScrollWithComponents.tsx @@ -1,6 +1,7 @@ import type { ComponentType } from 'react'; import React, { forwardRef } from 'react'; -import type { PaginatorState, StateStore } from 'stream-chat'; +import type { StateStore } from '@stream-io/state-store'; +import type { PaginatorState } from 'stream-chat'; import { useStateStore } from '../../store'; import type { InfiniteScrollPaginatorProps } from './InfiniteScrollPaginator'; diff --git a/src/components/InfiniteScrollPaginator/hooks/useCursorPaginator.ts b/src/components/InfiniteScrollPaginator/hooks/useCursorPaginator.ts index 6a2ec94bde..d109c59f43 100644 --- a/src/components/InfiniteScrollPaginator/hooks/useCursorPaginator.ts +++ b/src/components/InfiniteScrollPaginator/hooks/useCursorPaginator.ts @@ -1,6 +1,6 @@ import uniqBy from 'lodash.uniqby'; import { useCallback, useEffect, useMemo } from 'react'; -import { StateStore } from 'stream-chat'; +import { StateStore } from '@stream-io/state-store'; export type CursorPaginatorState = { hasNextPage: boolean; diff --git a/src/components/MessageComposer/hooks/__tests__/useMessageComposerCommands.test.tsx b/src/components/MessageComposer/hooks/__tests__/useMessageComposerCommands.test.tsx index db770df32f..6a15c112dd 100644 --- a/src/components/MessageComposer/hooks/__tests__/useMessageComposerCommands.test.tsx +++ b/src/components/MessageComposer/hooks/__tests__/useMessageComposerCommands.test.tsx @@ -1,7 +1,7 @@ import { act, renderHook } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; import type { ChannelConfig, Command, MessageComposerState } from 'stream-chat'; -import { StateStore } from 'stream-chat'; +import { StateStore } from '@stream-io/state-store'; import { useMessageComposerCommands } from '../useMessageComposerCommands'; diff --git a/src/components/MessageList/__tests__/MessageList.test.tsx b/src/components/MessageList/__tests__/MessageList.test.tsx index 8d4cec0745..7c37a44d42 100644 --- a/src/components/MessageList/__tests__/MessageList.test.tsx +++ b/src/components/MessageList/__tests__/MessageList.test.tsx @@ -24,7 +24,8 @@ import { useChannel, useMessageContext, WithComponents } from '../../../context' import { EmptyStateIndicator as EmptyStateIndicatorMock } from '../../EmptyStateIndicator'; import { mockedApiResponse } from '../../../mock-builders/api/utils'; import { nanoid } from 'nanoid'; -import { msToNs, StateStore } from 'stream-chat'; +import { StateStore } from '@stream-io/state-store'; +import { msToNs } from 'stream-chat'; import type { Channel as ChannelType, Event, diff --git a/src/components/MessageList/__tests__/ScrollToLatestMessageButton.test.tsx b/src/components/MessageList/__tests__/ScrollToLatestMessageButton.test.tsx index 764c1aea67..11eec46ce4 100644 --- a/src/components/MessageList/__tests__/ScrollToLatestMessageButton.test.tsx +++ b/src/components/MessageList/__tests__/ScrollToLatestMessageButton.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { StateStore } from 'stream-chat'; +import { StateStore } from '@stream-io/state-store'; import type { Channel, StreamChat, Thread, UserResponse } from 'stream-chat'; import { ScrollToLatestMessageButton } from '../ScrollToLatestMessageButton'; diff --git a/src/components/Notifications/hooks/__tests__/useNotificationApi.test.ts b/src/components/Notifications/hooks/__tests__/useNotificationApi.test.ts index 88b56f2011..93f8c33d90 100644 --- a/src/components/Notifications/hooks/__tests__/useNotificationApi.test.ts +++ b/src/components/Notifications/hooks/__tests__/useNotificationApi.test.ts @@ -1,6 +1,6 @@ import { renderHook } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; -import { StateStore } from 'stream-chat'; +import { StateStore } from '@stream-io/state-store'; import { useChatContext, useModalDialogManager } from '../../../../context'; import { modalDialogId } from '../../../Dialog'; diff --git a/src/components/Notifications/hooks/__tests__/useNotificationApi.test.tsx b/src/components/Notifications/hooks/__tests__/useNotificationApi.test.tsx index b137c40b6f..67ebf64ef9 100644 --- a/src/components/Notifications/hooks/__tests__/useNotificationApi.test.tsx +++ b/src/components/Notifications/hooks/__tests__/useNotificationApi.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { renderHook, waitFor } from '@testing-library/react'; -import { StateStore } from 'stream-chat'; +import { StateStore } from '@stream-io/state-store'; import { GlobalModal } from '../../../Modal'; import { useNotificationApi } from '../useNotificationApi'; diff --git a/src/components/Notifications/hooks/__tests__/useNotifications.test.tsx b/src/components/Notifications/hooks/__tests__/useNotifications.test.tsx index 1c658f6935..c606b76116 100644 --- a/src/components/Notifications/hooks/__tests__/useNotifications.test.tsx +++ b/src/components/Notifications/hooks/__tests__/useNotifications.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { renderHook } from '@testing-library/react'; -import { StateStore } from 'stream-chat'; +import { StateStore } from '@stream-io/state-store'; import { useNotifications } from '../useNotifications'; import { NotificationConfigurationProvider } from '../../NotificationConfigurationContext'; diff --git a/src/components/Thread/__tests__/Thread.test.tsx b/src/components/Thread/__tests__/Thread.test.tsx index 5843dc9338..736bf993d7 100644 --- a/src/components/Thread/__tests__/Thread.test.tsx +++ b/src/components/Thread/__tests__/Thread.test.tsx @@ -1,7 +1,7 @@ import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'; import React from 'react'; import { fromPartial } from '@total-typescript/shoehorn'; -import { StateStore } from 'stream-chat'; +import { StateStore } from '@stream-io/state-store'; import type { ChannelConfig, LocalMessage, diff --git a/src/components/Thread/__tests__/ThreadStart.test.tsx b/src/components/Thread/__tests__/ThreadStart.test.tsx index 86e3b5e003..518a0f86d1 100644 --- a/src/components/Thread/__tests__/ThreadStart.test.tsx +++ b/src/components/Thread/__tests__/ThreadStart.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { cleanup, render, screen } from '@testing-library/react'; -import { StateStore } from 'stream-chat'; +import { StateStore } from '@stream-io/state-store'; import { fromPartial } from '@total-typescript/shoehorn'; import { ThreadStart } from '../ThreadStart'; diff --git a/src/context/DialogManagerContext.tsx b/src/context/DialogManagerContext.tsx index 71edcea19b..bae1b76a9b 100644 --- a/src/context/DialogManagerContext.tsx +++ b/src/context/DialogManagerContext.tsx @@ -5,7 +5,7 @@ import React, { useMemo, useState, } from 'react'; -import { StateStore } from 'stream-chat'; +import { StateStore } from '@stream-io/state-store'; import { DialogManager } from '../components/Dialog/service/DialogManager'; import { diff --git a/src/context/TranslationContext.tsx b/src/context/TranslationContext.tsx index 4e19501c64..c4d9640186 100644 --- a/src/context/TranslationContext.tsx +++ b/src/context/TranslationContext.tsx @@ -1,19 +1,15 @@ -import type { PropsWithChildren } from 'react'; -import React, { useContext } from 'react'; +import { createTranslationContext } from '@stream-io/i18n/react'; import { defaultDateTimeParser, defaultTranslatorFunction } from '../i18n/utils'; import type { StreamTFunction, TDateTimeParser } from '../i18n/types'; /** - * The `Dayjs.extend(calendar)` / `extend(localizedFormat)` calls that used to sit here are gone. + * The `Dayjs.extend(calendar)` / `extend(localizedFormat)` calls that used to sit here are gone: + * `defaultDateTimeParser` from `@stream-io/i18n` registers the plugins itself on first use, which + * is what lets the package stay side-effect-free. * - * They existed so that the context *default* — used by a component rendered outside `` — could - * still call `.calendar()`. `defaultDateTimeParser` now comes from `stream-chat/i18n` and registers the - * plugins itself on first use, so the same guarantee holds without a module-scope side effect. That is - * what lets the package be marked side-effect-free. - * - * Worth knowing if this ever regresses: extending dayjs is not optional here, and forgetting it fails - * *silently* — `.calendar()` is simply absent, so timestamps render malformed rather than throwing. + * If that ever regresses it fails *silently* — `.calendar()` is simply absent, so timestamps render + * malformed rather than throwing. */ export type TranslationContextValue = { @@ -22,21 +18,21 @@ export type TranslationContextValue = { userLanguage: string; }; -export const TranslationContext = React.createContext({ - t: defaultTranslatorFunction, - tDateTimeParser: defaultDateTimeParser, - userLanguage: 'en', -}); - -export const TranslationProvider = ({ - children, - value, -}: PropsWithChildren<{ value: TranslationContextValue }>) => ( - {children} -); - /** - * Works outside ``: the context default's `defaultTranslatorFunction` renders the inline - * English `defaultValue` every `t()` call site passes, so SDK primitives render standalone. + * Built from the shared factory in `@stream-io/i18n/react`. What stays this SDK's own: the + * catalog-typed `t`, and supplying a **default** rather than throwing, so primitives render outside + * `` (React Native throws instead — hence the option). */ -export const useTranslationContext = () => useContext(TranslationContext); +const { TranslationContext, TranslationProvider, useTranslationContext } = + createTranslationContext({ + defaultValue: { + t: defaultTranslatorFunction, + tDateTimeParser: defaultDateTimeParser, + userLanguage: 'en', + }, + }); + +export { TranslationContext, TranslationProvider }; + +/** Works outside `` — the default translator renders each call site's inline English. */ +export { useTranslationContext }; diff --git a/src/i18n/Streami18n.ts b/src/i18n/Streami18n.ts index 9b3c49d154..b2bafa8b97 100644 --- a/src/i18n/Streami18n.ts +++ b/src/i18n/Streami18n.ts @@ -1,5 +1,6 @@ -import { Streami18n as CoreStreami18n, languageNameDefaults } from 'stream-chat/i18n'; -import type { Streami18nOptions as CoreStreami18nOptions } from 'stream-chat/i18n'; +import { Streami18n as CoreStreami18n } from '@stream-io/i18n'; +import type { Streami18nOptions as CoreStreami18nOptions } from '@stream-io/i18n'; +import { languageNameDefaults } from 'stream-chat'; import { NotificationTranslationTopic } from './TranslationBuilder'; import { runtimeDefaults } from './runtimeDefaults'; @@ -17,7 +18,7 @@ export type Streami18nOptions = CoreStreami18nOptions; * Wrapper around [i18next](https://www.i18next.com/) for this SDK's translations. Pass an instance to * `` to control language and copy. * - * The implementation lives in `stream-chat/i18n`, shared with the React Native SDK. What is added here + * The implementation lives in `@stream-io/i18n`, shared with the React Native SDK. What is added here * is the two things that are this SDK's own: its bundled translation data, and its notification * translation topic. Core cannot import either — the key catalog is generated from *this* package's * `t()` call sites. diff --git a/src/i18n/TranslationBuilder/index.ts b/src/i18n/TranslationBuilder/index.ts index 67bcd727de..465b7690c4 100644 --- a/src/i18n/TranslationBuilder/index.ts +++ b/src/i18n/TranslationBuilder/index.ts @@ -1,6 +1,6 @@ /** * The `TranslationBuilder` / `TranslationTopic` / `Translator` plumbing now lives in - * `stream-chat/i18n`, shared with the React Native SDK. Only the *topics* are this SDK's own, since + * `@stream-io/i18n`, shared with the React Native SDK. Only the *topics* are this SDK's own, since * they reference its key names. */ export { @@ -9,5 +9,5 @@ export { type TranslationTopicConstructor, type TranslationTopicOptions, type Translator, -} from 'stream-chat/i18n'; +} from '@stream-io/i18n'; export * from './notifications'; diff --git a/src/i18n/__tests__/NotificationTranslationBuilder.test.ts b/src/i18n/__tests__/NotificationTranslationBuilder.test.ts index c0c07f84e3..410807fb3e 100644 --- a/src/i18n/__tests__/NotificationTranslationBuilder.test.ts +++ b/src/i18n/__tests__/NotificationTranslationBuilder.test.ts @@ -1,7 +1,7 @@ import { NotificationTranslationTopic } from '../TranslationBuilder'; import { defaultNotificationTranslators } from '../TranslationBuilder/notifications/NotificationTranslationTopic'; import { fromPartial } from '@total-typescript/shoehorn'; -import type { I18nInstance } from 'stream-chat/i18n'; +import type { I18nInstance } from '@stream-io/i18n'; import type { Notification } from 'stream-chat'; const mockI18Next = fromPartial({ use: vi.fn() }); diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 863c304d75..17d71af1fb 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -1,17 +1,19 @@ import type { Streami18nState as CoreStreami18nState, - LanguageNameCatalog, LooseTranslationDictionaryOf, RelativeTimeCatalog, StreamTFunctionFor, TranslationDictionaryOf, TranslationKeyOf, -} from 'stream-chat/i18n'; +} from '@stream-io/i18n'; +// The names of the languages the API can auto-translate a message into. Chat API metadata, so it +// stays in `stream-chat` rather than moving to the generic i18n package. +import type { LanguageNameCatalog } from 'stream-chat'; import type { TranslationCatalog as GeneratedCatalog } from './keys'; /** - * The SDK's i18n types, instantiated from the generic helpers in `stream-chat/i18n`. + * The SDK's i18n types, instantiated from the generic helpers in `@stream-io/i18n`. * * The derivations live in core so both UI SDKs share one implementation; the *catalog* stays here, * because it is generated from this SDK's own `t()` call sites. That split is why core's helpers are @@ -65,4 +67,4 @@ export type { TDateTimeParserInput, TDateTimeParserOutput, TimestampFormatterOptions, -} from 'stream-chat/i18n'; +} from '@stream-io/i18n'; diff --git a/src/i18n/useStreami18n.ts b/src/i18n/useStreami18n.ts index 81fba9d704..4646af4980 100644 --- a/src/i18n/useStreami18n.ts +++ b/src/i18n/useStreami18n.ts @@ -1,15 +1,11 @@ import { useEffect, useMemo, useState } from 'react'; +import { useStreami18nState } from '@stream-io/i18n/react'; import { Streami18n } from './Streami18n'; -import type { Streami18nState } from './types'; -import { useStateStore } from '../store'; import type { StreamChat } from 'stream-chat'; import type { TranslationContextValue } from '../context/TranslationContext'; -/** Module scope, so the subscription is not torn down and rebuilt on every render. */ -const selector = ({ t, tDateTimeParser }: Streami18nState) => ({ t, tDateTimeParser }); - export type UseStreami18nParams = { client: StreamChat; /** An instance the integrator configured. One is created when absent. */ @@ -17,15 +13,13 @@ export type UseStreami18nParams = { }; /** - * Resolves the translation context value. - * - * Two independent languages come out of here, and keeping them apart is the point: + * Resolves the translation context value. Two independent languages come out of here, and keeping + * them apart is the point: * - * - **UI copy** comes from the `Streami18n` instance, through `state`. Set it with the `language` - * option or `setLanguage()`. - * - **`userLanguage`** is the language the API auto-translates *messages* into, which is - * `client.user.language` and nothing else. Every consumer uses it to read - * `message.i18n[`${userLanguage}_text`]`. + * - **UI copy** — from the `Streami18n` instance via `useStreami18nState` (shared with the React + * Native SDK; it owns `init()` and the store subscription). + * - **`userLanguage`** — `client.user.language`, the language the API auto-translates *messages* + * into, used to read ``message.i18n[`${userLanguage}_text`]``. Chat-client state, so it stays here. */ export const useStreami18n = ({ client, @@ -33,6 +27,7 @@ export const useStreami18n = ({ }: UseStreami18nParams): TranslationContextValue => { const i18n = useMemo(() => i18nInstance ?? new Streami18n(), [i18nInstance]); const [userLanguage, setUserLanguage] = useState(() => client.user?.language ?? 'en'); + useEffect(() => { const sync = () => setUserLanguage(client.user?.language ?? 'en'); sync(); @@ -42,13 +37,9 @@ export const useStreami18n = ({ return unsubscribe; }, [client]); - useEffect(() => { - i18n.init().catch((error: unknown) => { - console.warn(`Streami18n failed to initialize`, error); - }); - }, [i18n]); - - const { t, tDateTimeParser } = useStateStore(i18n.state, selector); + const { t, tDateTimeParser } = useStreami18nState(i18n, (error) => { + console.warn(`Streami18n failed to initialize`, error); + }); return useMemo( () => ({ t, tDateTimeParser, userLanguage }), diff --git a/src/i18n/utils.ts b/src/i18n/utils.ts index df8aaf5075..f840ea998b 100644 --- a/src/i18n/utils.ts +++ b/src/i18n/utils.ts @@ -1,4 +1,4 @@ -import { createDefaultTranslatorFunction } from 'stream-chat/i18n'; +import { createDefaultTranslatorFunction } from '@stream-io/i18n'; import type { StreamTFunction } from './types'; @@ -13,7 +13,7 @@ export const defaultTranslatorFunction: StreamTFunction = createDefaultTranslatorFunction() as StreamTFunction; /** - * The date/time and key helpers now live in `stream-chat/i18n`, shared with the React Native SDK. + * The date/time and key helpers now live in `@stream-io/i18n`, shared with the React Native SDK. * * Re-exported from here rather than rewritten at ~15 call sites, so the internal module path stays * stable. `getDateString` and the type guards behave identically; `predefinedFormatters` gains @@ -29,4 +29,4 @@ export { isDayOrMoment, isNumberOrString, predefinedFormatters, -} from 'stream-chat/i18n'; +} from '@stream-io/i18n'; diff --git a/src/mock-builders/generator/channelState.ts b/src/mock-builders/generator/channelState.ts index daeb7b2c31..2b2e9d8654 100644 --- a/src/mock-builders/generator/channelState.ts +++ b/src/mock-builders/generator/channelState.ts @@ -1,4 +1,5 @@ -import { AIStates, ChannelWatchStatus, StateStore } from 'stream-chat'; +import { StateStore } from '@stream-io/state-store'; +import { AIStates, ChannelWatchStatus } from 'stream-chat'; import type { ChannelMemberResponse, ChannelState, ChannelStateData } from 'stream-chat'; /** The non-reactive convenience getters the real `ChannelState` layers over its own store. */ diff --git a/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx b/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx index 8ce4efe612..f4d60279ef 100644 --- a/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx +++ b/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/PinnedMessagesView.test.tsx @@ -1,6 +1,7 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; -import { msToNs, StateStore } from 'stream-chat'; +import { StateStore } from '@stream-io/state-store'; +import { msToNs } from 'stream-chat'; import type { Channel, LocalMessage, diff --git a/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/usePinnedMessagesCount.test.ts b/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/usePinnedMessagesCount.test.ts index bc5538c6ac..09806665df 100644 --- a/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/usePinnedMessagesCount.test.ts +++ b/src/plugins/ChannelDetail/Views/PinnedMessagesView/__tests__/usePinnedMessagesCount.test.ts @@ -1,6 +1,6 @@ import { act, renderHook } from '@testing-library/react'; import { fromPartial } from '@total-typescript/shoehorn'; -import { StateStore } from 'stream-chat'; +import { StateStore } from '@stream-io/state-store'; import type { Channel, LocalMessage, PaginatorState } from 'stream-chat'; import { usePinnedMessagesCount } from '../usePinnedMessagesCount'; diff --git a/src/plugins/SlotLayout/__tests__/ChatViewNavigation.test.tsx b/src/plugins/SlotLayout/__tests__/ChatViewNavigation.test.tsx index 54f315ecdf..9579aa114a 100644 --- a/src/plugins/SlotLayout/__tests__/ChatViewNavigation.test.tsx +++ b/src/plugins/SlotLayout/__tests__/ChatViewNavigation.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import type { TranslationContextValue } from '../../../context/TranslationContext'; -import { StateStore } from 'stream-chat'; +import { StateStore } from '@stream-io/state-store'; import { fireEvent, render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; diff --git a/src/plugins/SlotLayout/__tests__/useSlotEntity.test.tsx b/src/plugins/SlotLayout/__tests__/useSlotEntity.test.tsx index 9c1a0f1dac..ba16ff57cf 100644 --- a/src/plugins/SlotLayout/__tests__/useSlotEntity.test.tsx +++ b/src/plugins/SlotLayout/__tests__/useSlotEntity.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import type { TranslationContextValue } from '../../../context/TranslationContext'; -import { StateStore } from 'stream-chat'; +import { StateStore } from '@stream-io/state-store'; import { render, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; diff --git a/src/plugins/SlotLayout/layoutController/LayoutController.ts b/src/plugins/SlotLayout/layoutController/LayoutController.ts index 3fab58e768..7509e09606 100644 --- a/src/plugins/SlotLayout/layoutController/LayoutController.ts +++ b/src/plugins/SlotLayout/layoutController/LayoutController.ts @@ -1,4 +1,4 @@ -import { StateStore } from 'stream-chat'; +import { StateStore } from '@stream-io/state-store'; import type { ChatViewLayoutState, diff --git a/src/plugins/SlotLayout/layoutController/layoutControllerTypes.ts b/src/plugins/SlotLayout/layoutController/layoutControllerTypes.ts index 034e4d7795..de3b332759 100644 --- a/src/plugins/SlotLayout/layoutController/layoutControllerTypes.ts +++ b/src/plugins/SlotLayout/layoutController/layoutControllerTypes.ts @@ -1,4 +1,4 @@ -import type { StateStore } from 'stream-chat'; +import type { StateStore } from '@stream-io/state-store'; import type { ChatView } from '../ChatView'; diff --git a/src/store/hooks/useStateStore.ts b/src/store/hooks/useStateStore.ts index cc7a326d40..73ad55d40b 100644 --- a/src/store/hooks/useStateStore.ts +++ b/src/store/hooks/useStateStore.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo } from 'react'; import { useSyncExternalStore } from 'use-sync-external-store/shim'; -import type { StateStore } from 'stream-chat'; +import type { StateStore } from '@stream-io/state-store'; // eslint-disable-next-line @typescript-eslint/no-empty-function const noop = () => {}; diff --git a/yarn.lock b/yarn.lock index 479c87a66f..64559363de 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1811,6 +1811,23 @@ __metadata: languageName: node linkType: hard +"@stream-io/i18n@npm:^1.0.2": + version: 1.0.2 + resolution: "@stream-io/i18n@npm:1.0.2" + dependencies: + "@stream-io/state-store": "npm:^1.1.6" + dayjs: "npm:^1.11.23" + i18next: "npm:^26.4.2" + use-sync-external-store: "npm:^1.7.0" + peerDependencies: + react: ^17 || ^18 || ^19 + peerDependenciesMeta: + react: + optional: true + checksum: 10c0/4b073bc8b39064ee31cab37f5c6ea6da03db916acf7fe998e0954fee6c458f0784d27678954c4aaa1eaed68eb8556d10e88ad3127e5ae4081c3ef16f747a3cda + languageName: node + linkType: hard + "@stream-io/logger@npm:^2.0.0": version: 2.0.0 resolution: "@stream-io/logger@npm:2.0.0" @@ -1818,6 +1835,21 @@ __metadata: languageName: node linkType: hard +"@stream-io/state-store@npm:^1.1.6": + version: 1.1.6 + resolution: "@stream-io/state-store@npm:1.1.6" + peerDependencies: + react: ^17 || ^18 || ^19 + use-sync-external-store: ^1.5.0 + peerDependenciesMeta: + react: + optional: true + use-sync-external-store: + optional: true + checksum: 10c0/42927cdb6a5bf6ba5f4ec11bb5e530db0a04edc65abd5348eb423780e40a3e53f57125e328d0318a0dc6b24ca0fcb28e8b91895a14a62ed8d82645f67ebcee5a + languageName: node + linkType: hard + "@stream-io/stream-chat-react-tutorial@workspace:examples/tutorial": version: 0.0.0-use.local resolution: "@stream-io/stream-chat-react-tutorial@workspace:examples/tutorial" @@ -1829,7 +1861,7 @@ __metadata: emoji-mart: "npm:^5.6.0" react: "npm:^19.2.6" react-dom: "npm:^19.2.6" - stream-chat: "npm:10.0.0-rc.9" + stream-chat: "npm:^10.0.0-rc.10" stream-chat-react: "workspace:^" typescript: "npm:^6.0.3" vite: "npm:^8.1.3" @@ -1843,20 +1875,21 @@ __metadata: "@babel/core": "npm:^7.29.0" "@emoji-mart/data": "npm:^1.2.1" "@playwright/test": "npm:^1.60.0" + "@stream-io/state-store": "npm:^1.1.6" "@types/react": "npm:^19.2.15" "@types/react-dom": "npm:^19.2.3" "@vitejs/plugin-react": "npm:^6.0.3" "@vitejs/plugin-react-swc": "npm:^4.3.1" babel-plugin-react-compiler: "npm:^1.0.0" clsx: "npm:^2.1.1" - dayjs: "npm:^1.11.20" + dayjs: "npm:^1.11.23" emoji-mart: "npm:^5.6.0" human-id: "npm:^4.1.3" modern-normalize: "npm:^3.0.1" react: "npm:^19.2.6" react-dom: "npm:^19.2.6" sass: "npm:^1.100.0" - stream-chat: "npm:10.0.0-rc.9" + stream-chat: "npm:^10.0.0-rc.10" stream-chat-react: "workspace:^" typescript: "npm:^6.0.3" vite: "npm:^8.1.3" @@ -3752,7 +3785,7 @@ __metadata: languageName: node linkType: hard -"dayjs@npm:^1.11.13, dayjs@npm:^1.11.20": +"dayjs@npm:^1.11.23": version: 1.11.23 resolution: "dayjs@npm:1.11.23" checksum: 10c0/69ab04bf19c676e44ab50cc2fca223d265f3dafd107151ef3b0f3ad74d360c37caa602abe62f87cb25d90bd9f6bee003698af47df5b0dbf10a998b7b5f3bb3f1 @@ -5323,15 +5356,15 @@ __metadata: languageName: node linkType: hard -"i18next@npm:^26.3.6": - version: 26.3.6 - resolution: "i18next@npm:26.3.6" +"i18next@npm:^26.4.2": + version: 26.4.2 + resolution: "i18next@npm:26.4.2" peerDependencies: typescript: ^5 || ^6 || ^7 peerDependenciesMeta: typescript: optional: true - checksum: 10c0/5920ac8fb6b647a2bdd439d121de04e5297246e52c4d95d13f5faae0824fd45b2faeb66038bcd61fc62362daa815f9c32fb992d6faa3787607d1dfe768e9a8b1 + checksum: 10c0/f2c9613e03e09e56a84aee939d8fbe3442a58622915e11f97e47f9ab358dfa65be783230b37a21483152711f786fccfe697d7f5ea9269c54f16179dc59960e4b languageName: node linkType: hard @@ -9461,6 +9494,8 @@ __metadata: "@semantic-release/changelog": "npm:^6.0.3" "@semantic-release/git": "npm:^10.0.1" "@semantic-release/github": "npm:^12.0.8" + "@stream-io/i18n": "npm:^1.0.2" + "@stream-io/state-store": "npm:^1.1.6" "@stream-io/transliterate": "npm:^1.5.5" "@testing-library/dom": "npm:^10.4.1" "@testing-library/jest-dom": "npm:^6.9.1" @@ -9482,7 +9517,7 @@ __metadata: clsx: "npm:^2.1.1" concurrently: "npm:^9.2.1" conventional-changelog-conventionalcommits: "npm:^9.3.1" - dayjs: "npm:^1.11.13" + dayjs: "npm:^1.11.23" emoji-mart: "npm:^5.6.0" emoji-regex: "npm:^9.2.2" eslint: "npm:^9.39.4" @@ -9516,13 +9551,13 @@ __metadata: remark-parse: "npm:^11.0.0" sass: "npm:^1.100.0" semantic-release: "npm:^25.0.3" - stream-chat: "npm:10.0.0-rc.9" + stream-chat: "npm:^10.0.0-rc.10" typescript: "npm:^6.0.3" typescript-eslint: "npm:^8.59.4" unified: "npm:^11.0.5" unist-builder: "npm:^4.0.0" unist-util-visit: "npm:^5.1.0" - use-sync-external-store: "npm:^1.6.0" + use-sync-external-store: "npm:^1.7.0" vite: "npm:^8.1.3" vitest: "npm:^4.1.9" vitest-axe: "npm:^0.1.0" @@ -9534,7 +9569,7 @@ __metadata: modern-normalize: ^3.0.1 react: ^19.0.0 || ^18.0.0 || ^17.0.0 react-dom: ^19.0.0 || ^18.0.0 || ^17.0.0 - stream-chat: 10.0.0-rc.9 + stream-chat: ^10.0.0-rc.10 dependenciesMeta: "@parcel/watcher": built: true @@ -9560,21 +9595,20 @@ __metadata: languageName: unknown linkType: soft -"stream-chat@npm:10.0.0-rc.9": - version: 10.0.0-rc.9 - resolution: "stream-chat@npm:10.0.0-rc.9" +"stream-chat@npm:^10.0.0-rc.10": + version: 10.0.0-rc.10 + resolution: "stream-chat@npm:10.0.0-rc.10" dependencies: "@stream-io/logger": "npm:^2.0.0" + "@stream-io/state-store": "npm:^1.1.6" axios: "npm:^1.19.0" - dayjs: "npm:^1.11.13" - i18next: "npm:^26.3.6" linkifyjs: "npm:^4.3.3" dependenciesMeta: esbuild: built: true husky: built: true - checksum: 10c0/4409003abe465e7d75d5b55daf6a197aeec346a9dcbbb84ad729dc2c31155899b6cf464d3a3be3249cf132b1cb3b67e4e7d0e3356379f22160779581b7b38dd1 + checksum: 10c0/00eb4de2b390f633a7b7e2613a6a2aa30543c6e0bcda96bbf10ac257cef07bd14b78e2c5677ad36c2b91d64d820baff63fa65782581f568755a9984f18843dc0 languageName: node linkType: hard @@ -10501,12 +10535,12 @@ __metadata: languageName: node linkType: hard -"use-sync-external-store@npm:^1.6.0": - version: 1.6.0 - resolution: "use-sync-external-store@npm:1.6.0" +"use-sync-external-store@npm:^1.6.0, use-sync-external-store@npm:^1.7.0": + version: 1.7.0 + resolution: "use-sync-external-store@npm:1.7.0" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - checksum: 10c0/35e1179f872a53227bdf8a827f7911da4c37c0f4091c29b76b1e32473d1670ebe7bcd880b808b7549ba9a5605c233350f800ffab963ee4a4ee346ee983b6019b + checksum: 10c0/6bae7cb778ce611131ec830c4e469fbc2872acefc25d2e4cf1c28b8628690689770742a9f2f0642c95134f9c77041fee60f0352f81ca43f96643b0ed01774726 languageName: node linkType: hard