diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index 362f80630..2a5c66047 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -175,6 +175,7 @@ export type ComponentContextValue = { /** Custom UI component to display the reactions modal, defaults to and accepts same props as: [ReactionsListModal](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Reactions/ReactionsListModal.tsx) */ ReactionsListModal?: React.ComponentType; RecordingPermissionDeniedNotification?: React.ComponentType; + /** Custom UI component to display the message reminder information in the Message UI, defaults to and accepts same props as: [ReminderNotification](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/ReminderNotification.tsx) */ ReminderNotification?: React.ComponentType; /** Custom component to display the search UI, defaults to and accepts same props as: [Search](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Search/Search.tsx) */ Search?: React.ComponentType; diff --git a/src/i18n/TranslationBuilder/TranslationBuilder.ts b/src/i18n/TranslationBuilder/TranslationBuilder.ts index efcfffdb4..a9ec9034f 100644 --- a/src/i18n/TranslationBuilder/TranslationBuilder.ts +++ b/src/i18n/TranslationBuilder/TranslationBuilder.ts @@ -1,5 +1,8 @@ import type { i18n, TFunction } from 'i18next'; +type TopicName = string; +type TranslatorName = string; + export type Translator = Record> = (params: { key: string; value: string; t: TFunction; options: O }) => string | null; @@ -44,25 +47,45 @@ export type TranslationTopicConstructor = new ( export class TranslationBuilder { private topics = new Map(); + // need to keep a registration buffer so that translators can be registered once a topic is registered + // what does not happen when Streami18n is instantiated but rather once Streami18n.init() is invoked + private translatorRegistrationsBuffer: Record< + TopicName, + Record + > = {}; constructor(private i18next: i18n) {} - registerTopic = (name: string, Topic: TranslationTopicConstructor) => { - const topic = new Topic({ i18next: this.i18next }); - this.topics.set(name, topic); - this.i18next.use({ - name, - process: (value: string, key: string, options: Record) => { - const topic = this.topics.get(name); - if (!topic) return value; - return topic.translate(value, key, options); - }, - type: 'postProcessor' as const, - }); + registerTopic = (name: TopicName, Topic: TranslationTopicConstructor) => { + let topic = this.topics.get(name); + + if (!topic) { + topic = new Topic({ i18next: this.i18next }); + this.topics.set(name, topic); + this.i18next.use({ + name, + process: (value: string, key: string, options: Record) => { + const topic = this.topics.get(name); + if (!topic) return value; + return topic.translate(value, key, options); + }, + type: 'postProcessor' as const, + }); + } + + const additionalTranslatorsToRegister = this.translatorRegistrationsBuffer[name]; + if (additionalTranslatorsToRegister) { + Object.entries(additionalTranslatorsToRegister).forEach( + ([translatorName, translator]) => { + topic.setTranslator(translatorName, translator); + }, + ); + delete this.translatorRegistrationsBuffer[name]; + } return topic; }; - disableTopic = (topicName: string) => { + disableTopic = (topicName: TopicName) => { const topic = this.topics.get(topicName); if (!topic) return; this.i18next.use({ @@ -73,18 +96,34 @@ export class TranslationBuilder { this.topics.delete(topicName); }; - getTopic = (topicName: string) => this.topics.get(topicName); + getTopic = (topicName: TopicName) => this.topics.get(topicName); - registerTranslators(topicName: string, translators: Record) { + registerTranslators( + topicName: TopicName, + translators: Record, + ) { const topic = this.getTopic(topicName); - if (!topic) return; + if (!topic) { + if (!this.translatorRegistrationsBuffer[topicName]) + this.translatorRegistrationsBuffer[topicName] = {}; + + Object.entries(translators).forEach(([translatorName, translator]) => { + this.translatorRegistrationsBuffer[topicName][translatorName] = translator; + }); + return; + } Object.entries(translators).forEach(([name, translator]) => { topic.setTranslator(name, translator); }); } - removeTranslators(topicName: string, translators: string[]) { + removeTranslators(topicName: TopicName, translators: TranslatorName[]) { const topic = this.getTopic(topicName); + if (this.translatorRegistrationsBuffer[topicName]) { + translators.forEach((translatorName) => { + delete this.translatorRegistrationsBuffer[topicName][translatorName]; + }); + } if (!topic) return; translators.forEach((name) => { topic.removeTranslator(name); diff --git a/src/i18n/TranslationBuilder/notifications/index.ts b/src/i18n/TranslationBuilder/notifications/index.ts index cabe0139e..e1afaa9ed 100644 --- a/src/i18n/TranslationBuilder/notifications/index.ts +++ b/src/i18n/TranslationBuilder/notifications/index.ts @@ -1 +1,2 @@ export { NotificationTranslationTopic } from './NotificationTranslationTopic'; +export * from './types'; diff --git a/src/i18n/__tests__/TranslationBuilder.test.js b/src/i18n/__tests__/TranslationBuilder.test.js index f35c95106..973754c6d 100644 --- a/src/i18n/__tests__/TranslationBuilder.test.js +++ b/src/i18n/__tests__/TranslationBuilder.test.js @@ -6,17 +6,20 @@ describe('TranslationBuilder and TranslationTopic', () => { const manager = new TranslationBuilder(mockI18Next); expect(manager.i18next).toEqual(mockI18Next); }); + it('registers and retrieves the builder', () => { const manager = new TranslationBuilder(mockI18Next); manager.registerTopic('notification', NotificationTranslationTopic); expect(manager.getTopic('notification')).toBeInstanceOf(NotificationTranslationTopic); }); + it('removes builder', () => { const manager = new TranslationBuilder(mockI18Next); manager.registerTopic('notification', NotificationTranslationTopic); manager.disableTopic('notification'); expect(manager.getTopic('notification')).toBeUndefined(); }); + it('registers and removes translators', () => { const translator = jest.fn(); const manager = new TranslationBuilder(mockI18Next); @@ -27,4 +30,59 @@ describe('TranslationBuilder and TranslationTopic', () => { manager.removeTranslators('notification', ['test']); expect(notificationBuilder.translators.get('test')).toBeUndefined(); }); + + it('stores translators for non-existent topic in a buffer', () => { + const manager = new TranslationBuilder(mockI18Next); + const translators = { custom1: jest.fn(), custom2: jest.fn() }; + manager.registerTranslators('notification', translators); + expect(manager.topics.size).toEqual(0); + expect(manager.translatorRegistrationsBuffer.notification).toEqual(translators); + }); + + it('removes translators from buffer on translation removal', () => { + const manager = new TranslationBuilder(mockI18Next); + const translators = { custom1: jest.fn(), custom2: jest.fn() }; + manager.registerTranslators('notification', translators); + manager.removeTranslators('notification', ['custom1']); + expect(Object.keys(manager.translatorRegistrationsBuffer.notification).length).toBe( + 1, + ); + expect(manager.translatorRegistrationsBuffer.notification.custom2).toBeDefined(); + }); + + it('flushes the buffered translators on topic registration', () => { + const manager = new TranslationBuilder(mockI18Next); + const translators = { custom1: jest.fn(), custom2: jest.fn() }; + manager.registerTranslators('notification', translators); + manager.registerTopic('notification', NotificationTranslationTopic); + expect(manager.translatorRegistrationsBuffer.notification).toBeUndefined(); + }); + + it("overrides the topic's translators with buffered translators", () => { + const manager = new TranslationBuilder(mockI18Next); + const translator = jest.fn().mockImplementation(); + const translatorName = 'api:attachment:upload:failed'; + const translators = { [translatorName]: translator }; + manager.registerTranslators('notification', translators); + manager.registerTopic('notification', NotificationTranslationTopic); + manager + .getTopic('notification') + .translate('key', 'value', { notification: { type: translatorName } }); + + expect(translator).toHaveBeenCalledTimes(1); + }); + + it('reuses the already registered topic on repeated registerTopic calls', () => { + const manager = new TranslationBuilder(mockI18Next); + class Topic { + constructor() { + this.id = Math.random().toString(); + } + } + manager.registerTopic('custom', Topic); + const firstRegistrationId = manager.getTopic('custom').id; + manager.registerTopic('custom', Topic); + const secondRegistrationId = manager.getTopic('custom').id; + expect(firstRegistrationId).toBe(secondRegistrationId); + }); }); diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 9f59933d8..4d3f5a0cf 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -1,6 +1,6 @@ export * from './translations'; export * from './Streami18n'; -export * from './TranslationBuilder/TranslationBuilder'; +export * from './TranslationBuilder'; export { defaultDateTimeParser, defaultTranslatorFunction,