Skip to content

Commit 4fff26a

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

28 files changed

Lines changed: 393 additions & 8 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: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import type { TFunction } from 'i18next';
1212
import type momentTimezone from 'moment-timezone';
1313
import type { TranslationLanguages } from 'stream-chat';
1414

15+
import {
16+
NotificationTranslationBuilder,
17+
TranslationsBuilderManager,
18+
} from './TranslationBuilder';
1519
import type { UnknownType } from '../types/types';
1620
import type { CustomFormatters, PredefinedFormatters, TDateTimeParser } from './types';
1721

@@ -90,7 +94,7 @@ Dayjs.updateLocale('fr', {
9094
lastWeek: 'dddd [dernier à] LT',
9195
nextDay: '[Demain à] LT',
9296
nextWeek: 'dddd [à] LT',
93-
sameDay: '[Aujourdhui à] LT',
97+
sameDay: "[Aujourd'hui à] LT",
9498
sameElse: 'L',
9599
},
96100
});
@@ -258,15 +262,16 @@ export type Streami18nOptions = {
258262
formatters?: Partial<PredefinedFormatters> & CustomFormatters;
259263
language?: TranslationLanguages;
260264
logger?: (message?: string) => void;
265+
postProcess?: string[];
261266
parseMissingKeyHandler?: (key: string, defaultValue?: string) => string;
262267
timezone?: string;
263268
translationsForLanguage?: Partial<typeof enTranslations>;
264269
};
265270

266271
/**
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:
272+
* Wrapper around [i18next](https://www.i18next.com/) class for Stream related i18n.
273+
* Instance of this class should be provided to Chat component to handle i18n.
274+
* Stream provides following list of in-built i18n:
270275
* 1. English (en)
271276
* 2. Dutch (nl)
272277
* 3. Russian (ru)
@@ -330,7 +335,7 @@ export type Streami18nOptions = {
330335
* </Chat>
331336
* ```
332337
*
333-
* ## Datetime translations
338+
* ## Datetime i18n
334339
*
335340
* Stream react chat components uses [dayjs](https://day.js.org/en/) internally by default to format datetime stamp.
336341
* e.g., in ChannelPreview, MessageContent components.
@@ -422,10 +427,12 @@ const defaultStreami18nOptions = {
422427
disableDateTimeTranslations: false,
423428
language: 'en' as TranslationLanguages,
424429
logger: (message?: string) => console.warn(message),
430+
postProcess: ['notification'],
425431
};
426432

427433
export class Streami18n {
428434
i18nInstance = i18n.createInstance();
435+
translationsBuilderManager: TranslationsBuilderManager;
429436
Dayjs = null;
430437
setLanguageCallback: (t: TFunction) => void = () => null;
431438
initialized = false;
@@ -477,6 +484,7 @@ export class Streami18n {
477484
lng: string;
478485
nsSeparator: false;
479486
parseMissingKeyHandler?: (key: string, defaultValue?: string) => string;
487+
postProcess: string[];
480488
};
481489
/**
482490
* A valid TZ identifier string (https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)
@@ -519,6 +527,7 @@ export class Streami18n {
519527
this.DateTimeParser = finalOptions.DateTimeParser;
520528
this.timezone = finalOptions.timezone;
521529
this.formatters = { ...predefinedFormatters, ...options?.formatters };
530+
this.translationsBuilderManager = new TranslationsBuilderManager(this.i18nInstance);
522531

523532
try {
524533
if (this.DateTimeParser && isDayJs(this.DateTimeParser)) {
@@ -563,6 +572,7 @@ export class Streami18n {
563572
keySeparator: false,
564573
lng: this.currentLanguage,
565574
nsSeparator: false,
575+
postProcess: finalOptions.postProcess,
566576
};
567577

568578
if (finalOptions.parseMissingKeyHandler) {
@@ -624,6 +634,11 @@ export class Streami18n {
624634
this.i18nInstance.services.formatter?.add(name, formatterFactory(this));
625635
});
626636
}
637+
// Register post-processors after initialization
638+
this.translationsBuilderManager.register(
639+
'notification',
640+
NotificationTranslationBuilder,
641+
);
627642
} catch (error) {
628643
this.logger(`Something went wrong with init: ${JSON.stringify(error)}`);
629644
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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 TranslationBuilderOptions<
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 TranslationBuilder<
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: TranslationBuilderOptions<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 build(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 class TranslationsBuilderManager {
40+
private builders = new Map<string, TranslationBuilder>();
41+
42+
constructor(private i18next: i18n) {}
43+
44+
register = (
45+
name: string,
46+
Builder: new (options: TranslationBuilderOptions) => TranslationBuilder,
47+
) => {
48+
const builder = new Builder({ i18next: this.i18next });
49+
this.builders.set(name, builder);
50+
this.i18next.use({
51+
name,
52+
process: (value: string, key: string, options: Record<string, unknown>) => {
53+
const builder = this.builders.get(name);
54+
if (!builder) return value;
55+
return builder.build(value, key, options);
56+
},
57+
type: 'postProcessor' as const,
58+
});
59+
60+
return builder;
61+
};
62+
63+
remove = (name: string) => {
64+
this.builders.delete(name);
65+
};
66+
67+
get = (name: string) => this.builders.get(name);
68+
69+
registerTranslators(builderName: string, translators: Record<string, Translator>) {
70+
const builder = this.get(builderName);
71+
if (!builder) return;
72+
Object.entries(translators).forEach(([name, translator]) => {
73+
builder.setTranslator(name, translator);
74+
});
75+
}
76+
77+
removeTranslators(builderName: string, translators: string[]) {
78+
const builder = this.get(builderName);
79+
if (!builder) return;
80+
translators.forEach((name) => {
81+
builder.removeTranslator(name);
82+
});
83+
}
84+
}
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 { TranslationBuilder } from '../../index';
6+
import type { Notification } from 'stream-chat';
7+
import type { NotificationTranslatorOptions } from './types';
8+
import type { TranslationBuilderOptions, 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 NotificationTranslationBuilder extends TranslationBuilder<NotificationTranslatorOptions> {
19+
constructor({ i18next, translators }: TranslationBuilderOptions) {
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+
build = (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)