Skip to content

Commit b1ca9cc

Browse files
committed
feat: add translation building capability to Streami18n
1 parent 0a4ffde commit b1ca9cc

29 files changed

Lines changed: 450 additions & 9 deletions

src/components/MessageInput/MessageInputFlat.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export const MessageInputFlat = () => {
7777

7878
/**
7979
* This bit here is needed to make sure that we can get rid of the default behaviour
80-
* if need be. Essentially this allows us to pass StopAIGenerationButton={null} and
80+
* if need be. Essentially, this allows us to pass StopAIGenerationButton={null} and
8181
* completely circumvent the default logic if it's not what we want. We need it as a
8282
* prop because there is no other trivial way to override the SendMessage button otherwise.
8383
*/

src/components/MessageList/MessageListNotifications.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,29 @@ import { ConnectionStatus } from './ConnectionStatus';
44
import { CustomNotification } from './CustomNotification';
55

66
import { useTranslationContext } from '../../context/TranslationContext';
7-
7+
import { useNotifications } from '../Notifications/hooks/useNotifications';
88
import type { MessageNotificationProps } from './MessageNotification';
9-
109
import type { ChannelNotifications } from '../../context/ChannelStateContext';
1110

11+
const ClientNotifications = () => {
12+
const clientNotifications = useNotifications();
13+
const { t } = useTranslationContext();
14+
15+
return (
16+
<>
17+
{clientNotifications.map((notification) => (
18+
<CustomNotification
19+
active={true}
20+
key={notification.id}
21+
type={notification.severity}
22+
>
23+
{t<string>('translationBuilder/notification', { notification })}
24+
</CustomNotification>
25+
))}
26+
</>
27+
);
28+
};
29+
1230
export type MessageListNotificationsProps = {
1331
hasNewMessages: boolean;
1432
isMessageListScrolledToBottom: boolean;
@@ -41,6 +59,7 @@ export const MessageListNotifications = (props: MessageListNotificationsProps) =
4159
{notification.text}
4260
</CustomNotification>
4361
))}
62+
<ClientNotifications />
4463
<ConnectionStatus />
4564
<MessageNotification
4665
isMessageListScrolledToBottom={isMessageListScrolledToBottom}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './useNotifications';
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { useChatContext } from '../../../context';
2+
import { useStateStore } from '../../../store';
3+
import type { Notification, NotificationManagerState } from 'stream-chat';
4+
5+
const selector = (state: NotificationManagerState) => ({
6+
notifications: state.notifications,
7+
});
8+
9+
export const useNotifications = (): Notification[] => {
10+
const { client } = useChatContext();
11+
const result = useStateStore(client.notifications.store, selector);
12+
return result.notifications;
13+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './hooks';

src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export * from './MessageInput';
2222
export * from './MessageList';
2323
export * from './MML';
2424
export * from './Modal';
25+
export * from './Notifications';
2526
export * from './Poll';
2627
export * from './Reactions';
2728
export * from './SafeAnchor';

src/i18n/Streami18n.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@ import localeData from 'dayjs/plugin/localeData';
77
import relativeTime from 'dayjs/plugin/relativeTime';
88
import utc from 'dayjs/plugin/utc';
99
import timezone from 'dayjs/plugin/timezone';
10+
import { NotificationTranslationTopic, TranslationBuilder } from './TranslationBuilder';
1011
import { defaultTranslatorFunction, predefinedFormatters } from './utils';
12+
1113
import type { TFunction } from 'i18next';
1214
import type momentTimezone from 'moment-timezone';
1315
import type { TranslationLanguages } from 'stream-chat';
1416

17+
import type { TranslationTopicConstructor } from './TranslationBuilder';
1518
import type { UnknownType } from '../types/types';
1619
import type { CustomFormatters, PredefinedFormatters, TDateTimeParser } from './types';
1720

@@ -90,7 +93,7 @@ Dayjs.updateLocale('fr', {
9093
lastWeek: 'dddd [dernier à] LT',
9194
nextDay: '[Demain à] LT',
9295
nextWeek: 'dddd [à] LT',
93-
sameDay: '[Aujourdhui à] LT',
96+
sameDay: "[Aujourd'hui à] LT",
9497
sameElse: 'L',
9598
},
9699
});
@@ -258,15 +261,16 @@ export type Streami18nOptions = {
258261
formatters?: Partial<PredefinedFormatters> & CustomFormatters;
259262
language?: TranslationLanguages;
260263
logger?: (message?: string) => void;
264+
translationBuilderTopics?: Record<string, TranslationTopicConstructor>;
261265
parseMissingKeyHandler?: (key: string, defaultValue?: string) => string;
262266
timezone?: string;
263267
translationsForLanguage?: Partial<typeof enTranslations>;
264268
};
265269

266270
/**
267-
* Wrapper around [i18next](https://www.i18next.com/) class for Stream related translations.
268-
* Instance of this class should be provided to Chat component to handle translations.
269-
* Stream provides following list of in-built translations:
271+
* Wrapper around [i18next](https://www.i18next.com/) class for Stream related i18n.
272+
* Instance of this class should be provided to Chat component to handle i18n.
273+
* Stream provides following list of in-built i18n:
270274
* 1. English (en)
271275
* 2. Dutch (nl)
272276
* 3. Russian (ru)
@@ -330,7 +334,7 @@ export type Streami18nOptions = {
330334
* </Chat>
331335
* ```
332336
*
333-
* ## Datetime translations
337+
* ## Datetime i18n
334338
*
335339
* Stream react chat components uses [dayjs](https://day.js.org/en/) internally by default to format datetime stamp.
336340
* e.g., in ChannelPreview, MessageContent components.
@@ -422,10 +426,14 @@ const defaultStreami18nOptions = {
422426
disableDateTimeTranslations: false,
423427
language: 'en' as TranslationLanguages,
424428
logger: (message?: string) => console.warn(message),
429+
translationBuilderTopics: {
430+
notifications: NotificationTranslationTopic,
431+
},
425432
};
426433

427434
export class Streami18n {
428435
i18nInstance = i18n.createInstance();
436+
translationBuilder: TranslationBuilder;
429437
Dayjs = null;
430438
setLanguageCallback: (t: TFunction) => void = () => null;
431439
initialized = false;
@@ -477,6 +485,7 @@ export class Streami18n {
477485
lng: string;
478486
nsSeparator: false;
479487
parseMissingKeyHandler?: (key: string, defaultValue?: string) => string;
488+
translationBuilderTopics: Record<string, TranslationTopicConstructor>;
480489
};
481490
/**
482491
* A valid TZ identifier string (https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)
@@ -519,6 +528,7 @@ export class Streami18n {
519528
this.DateTimeParser = finalOptions.DateTimeParser;
520529
this.timezone = finalOptions.timezone;
521530
this.formatters = { ...predefinedFormatters, ...options?.formatters };
531+
this.translationBuilder = new TranslationBuilder(this.i18nInstance);
522532

523533
try {
524534
if (this.DateTimeParser && isDayJs(this.DateTimeParser)) {
@@ -563,6 +573,10 @@ export class Streami18n {
563573
keySeparator: false,
564574
lng: this.currentLanguage,
565575
nsSeparator: false,
576+
translationBuilderTopics: {
577+
...defaultStreami18nOptions.translationBuilderTopics,
578+
...options.translationBuilderTopics,
579+
},
566580
};
567581

568582
if (finalOptions.parseMissingKeyHandler) {
@@ -624,6 +638,12 @@ export class Streami18n {
624638
this.i18nInstance.services.formatter?.add(name, formatterFactory(this));
625639
});
626640
}
641+
// Register post-processors after initialization
642+
Object.entries(this.i18nextConfig.translationBuilderTopics).forEach(
643+
([topic, TranslationTopic]) => {
644+
this.translationBuilder.register(topic, TranslationTopic);
645+
},
646+
);
627647
} catch (error) {
628648
this.logger(`Something went wrong with init: ${JSON.stringify(error)}`);
629649
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import type { i18n, TFunction } from 'i18next';
2+
3+
export type Translator<O extends Record<string, unknown> = Record<string, unknown>> =
4+
(params: { t: TFunction; options: O }) => string | null;
5+
6+
export type TranslationTopicOptions<
7+
O extends Record<string, unknown> = Record<string, unknown>,
8+
> = {
9+
i18next: i18n;
10+
translators?: Record<string, Translator<O>>;
11+
};
12+
13+
export abstract class TranslationTopic<
14+
O extends Record<string, unknown> = Record<string, unknown>,
15+
> {
16+
protected translators: Map<string, Translator<O>> = new Map();
17+
protected i18next: i18n;
18+
19+
constructor(protected options: TranslationTopicOptions<O>) {
20+
this.i18next = options.i18next;
21+
if (options.translators) {
22+
Object.entries(options.translators).forEach(([name, translator]) => {
23+
this.setTranslator(name, translator);
24+
});
25+
}
26+
}
27+
28+
abstract translate(value: string, key: string, options: O): string;
29+
30+
setTranslator = (name: string, translator: Translator<O>) => {
31+
this.translators.set(name, translator);
32+
};
33+
34+
removeTranslator = (name: string) => {
35+
this.translators.delete(name);
36+
};
37+
}
38+
39+
export type TranslationTopicConstructor = new (
40+
options: TranslationTopicOptions,
41+
) => TranslationTopic;
42+
43+
export class TranslationBuilder {
44+
private topics = new Map<string, TranslationTopic>();
45+
46+
constructor(private i18next: i18n) {}
47+
48+
register = (name: string, Topic: TranslationTopicConstructor) => {
49+
const topic = new Topic({ i18next: this.i18next });
50+
this.topics.set(name, topic);
51+
this.i18next.use({
52+
name,
53+
process: (value: string, key: string, options: Record<string, unknown>) => {
54+
const topic = this.topics.get(name);
55+
if (!topic) return value;
56+
return topic.translate(value, key, options);
57+
},
58+
type: 'postProcessor' as const,
59+
});
60+
61+
return topic;
62+
};
63+
64+
remove = (topicName: string) => {
65+
this.topics.delete(topicName);
66+
};
67+
68+
get = (topicName: string) => this.topics.get(topicName);
69+
70+
registerTranslators(topicName: string, translators: Record<string, Translator>) {
71+
const topic = this.get(topicName);
72+
if (!topic) return;
73+
Object.entries(translators).forEach(([name, translator]) => {
74+
topic.setTranslator(name, translator);
75+
});
76+
}
77+
78+
removeTranslators(topicName: string, translators: string[]) {
79+
const topic = this.get(topicName);
80+
if (!topic) return;
81+
translators.forEach((name) => {
82+
topic.removeTranslator(name);
83+
});
84+
}
85+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './TranslationBuilder';
2+
export * from './notifications';
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import {
2+
attachmentUploadBlockedNotificationTranslator,
3+
attachmentUploadFailedNotificationTranslator,
4+
} from './attachmentUpload';
5+
import { TranslationTopic } from '../../index';
6+
import type { Notification } from 'stream-chat';
7+
import type { NotificationTranslatorOptions } from './types';
8+
import type { TranslationTopicOptions, Translator } from '../../index';
9+
10+
export const defaultUploadNotificationTranslators: Record<
11+
string,
12+
Translator<NotificationTranslatorOptions>
13+
> = {
14+
'attachment.upload.blocked': attachmentUploadBlockedNotificationTranslator,
15+
'attachment.upload.failed': attachmentUploadFailedNotificationTranslator,
16+
};
17+
18+
export class NotificationTranslationTopic extends TranslationTopic<NotificationTranslatorOptions> {
19+
constructor({ i18next, translators }: TranslationTopicOptions) {
20+
super({ i18next, translators: defaultUploadNotificationTranslators });
21+
if (translators) {
22+
Object.entries(translators).forEach(([name, translator]) => {
23+
this.setTranslator(name, translator);
24+
});
25+
}
26+
}
27+
28+
translate = (value: string, key: string, options: { notification?: Notification }) => {
29+
const { notification } = options;
30+
if (!notification) return value;
31+
const translator = notification.code && this.translators.get(notification.code);
32+
if (!translator) return value;
33+
return translator({ options, t: this.i18next.t }) || value;
34+
};
35+
}

0 commit comments

Comments
 (0)