stream-chat-react-native-core 10.0.0-rc.5 โ†’ 10.0.0

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 (121) hide show
  1. package/lib/commonjs/components/ChannelPreview/hooks/useChannelPreviewPollLabel.js +8 -5
  2. package/lib/commonjs/components/ChannelPreview/hooks/useChannelPreviewPollLabel.js.map +1 -1
  3. package/lib/commonjs/components/Poll/Poll.js +5 -2
  4. package/lib/commonjs/components/Poll/Poll.js.map +1 -1
  5. package/lib/commonjs/components/Poll/components/PollAnswersList.js +4 -2
  6. package/lib/commonjs/components/Poll/components/PollAnswersList.js.map +1 -1
  7. package/lib/commonjs/components/Poll/components/PollOption.js +8 -3
  8. package/lib/commonjs/components/Poll/components/PollOption.js.map +1 -1
  9. package/lib/commonjs/components/Poll/components/PollResults/PollOptionFullResults.js +5 -2
  10. package/lib/commonjs/components/Poll/components/PollResults/PollOptionFullResults.js.map +1 -1
  11. package/lib/commonjs/components/Poll/components/PollResults/PollResultItem.js +3 -1
  12. package/lib/commonjs/components/Poll/components/PollResults/PollResultItem.js.map +1 -1
  13. package/lib/commonjs/components/Poll/components/PollResults/PollResults.js +4 -1
  14. package/lib/commonjs/components/Poll/components/PollResults/PollResults.js.map +1 -1
  15. package/lib/commonjs/components/Poll/hooks/usePollState.js +3 -0
  16. package/lib/commonjs/components/Poll/hooks/usePollState.js.map +1 -1
  17. package/lib/commonjs/components/Poll/hooks/usePollTextTranslation.js +17 -0
  18. package/lib/commonjs/components/Poll/hooks/usePollTextTranslation.js.map +1 -0
  19. package/lib/commonjs/components/Poll/index.js +11 -0
  20. package/lib/commonjs/components/Poll/index.js.map +1 -1
  21. package/lib/commonjs/hooks/messagePreview/useMessagePreviewText.js +9 -4
  22. package/lib/commonjs/hooks/messagePreview/useMessagePreviewText.js.map +1 -1
  23. package/lib/commonjs/hooks/useTranslatedMessage.js +4 -3
  24. package/lib/commonjs/hooks/useTranslatedMessage.js.map +1 -1
  25. package/lib/commonjs/store/SqliteClient.js +1 -1
  26. package/lib/commonjs/store/mappers/mapPollToStorable.js +4 -0
  27. package/lib/commonjs/store/mappers/mapPollToStorable.js.map +1 -1
  28. package/lib/commonjs/store/mappers/mapStorableToPoll.js +5 -1
  29. package/lib/commonjs/store/mappers/mapStorableToPoll.js.map +1 -1
  30. package/lib/commonjs/store/schema.js +2 -0
  31. package/lib/commonjs/store/schema.js.map +1 -1
  32. package/lib/commonjs/utils/i18n/getTranslatedText.js +7 -0
  33. package/lib/commonjs/utils/i18n/getTranslatedText.js.map +1 -0
  34. package/lib/commonjs/utils/utils.js +35 -1
  35. package/lib/commonjs/utils/utils.js.map +1 -1
  36. package/lib/commonjs/version.json +1 -1
  37. package/lib/module/components/ChannelPreview/hooks/useChannelPreviewPollLabel.js +8 -5
  38. package/lib/module/components/ChannelPreview/hooks/useChannelPreviewPollLabel.js.map +1 -1
  39. package/lib/module/components/Poll/Poll.js +5 -2
  40. package/lib/module/components/Poll/Poll.js.map +1 -1
  41. package/lib/module/components/Poll/components/PollAnswersList.js +4 -2
  42. package/lib/module/components/Poll/components/PollAnswersList.js.map +1 -1
  43. package/lib/module/components/Poll/components/PollOption.js +8 -3
  44. package/lib/module/components/Poll/components/PollOption.js.map +1 -1
  45. package/lib/module/components/Poll/components/PollResults/PollOptionFullResults.js +5 -2
  46. package/lib/module/components/Poll/components/PollResults/PollOptionFullResults.js.map +1 -1
  47. package/lib/module/components/Poll/components/PollResults/PollResultItem.js +3 -1
  48. package/lib/module/components/Poll/components/PollResults/PollResultItem.js.map +1 -1
  49. package/lib/module/components/Poll/components/PollResults/PollResults.js +4 -1
  50. package/lib/module/components/Poll/components/PollResults/PollResults.js.map +1 -1
  51. package/lib/module/components/Poll/hooks/usePollState.js +3 -0
  52. package/lib/module/components/Poll/hooks/usePollState.js.map +1 -1
  53. package/lib/module/components/Poll/hooks/usePollTextTranslation.js +17 -0
  54. package/lib/module/components/Poll/hooks/usePollTextTranslation.js.map +1 -0
  55. package/lib/module/components/Poll/index.js +11 -0
  56. package/lib/module/components/Poll/index.js.map +1 -1
  57. package/lib/module/hooks/messagePreview/useMessagePreviewText.js +9 -4
  58. package/lib/module/hooks/messagePreview/useMessagePreviewText.js.map +1 -1
  59. package/lib/module/hooks/useTranslatedMessage.js +4 -3
  60. package/lib/module/hooks/useTranslatedMessage.js.map +1 -1
  61. package/lib/module/store/SqliteClient.js +1 -1
  62. package/lib/module/store/mappers/mapPollToStorable.js +4 -0
  63. package/lib/module/store/mappers/mapPollToStorable.js.map +1 -1
  64. package/lib/module/store/mappers/mapStorableToPoll.js +5 -1
  65. package/lib/module/store/mappers/mapStorableToPoll.js.map +1 -1
  66. package/lib/module/store/schema.js +2 -0
  67. package/lib/module/store/schema.js.map +1 -1
  68. package/lib/module/utils/i18n/getTranslatedText.js +7 -0
  69. package/lib/module/utils/i18n/getTranslatedText.js.map +1 -0
  70. package/lib/module/utils/utils.js +35 -1
  71. package/lib/module/utils/utils.js.map +1 -1
  72. package/lib/module/version.json +1 -1
  73. package/lib/typescript/components/ChannelPreview/hooks/useChannelPreviewPollLabel.d.ts.map +1 -1
  74. package/lib/typescript/components/Poll/Poll.d.ts.map +1 -1
  75. package/lib/typescript/components/Poll/components/PollAnswersList.d.ts.map +1 -1
  76. package/lib/typescript/components/Poll/components/PollOption.d.ts.map +1 -1
  77. package/lib/typescript/components/Poll/components/PollResults/PollOptionFullResults.d.ts.map +1 -1
  78. package/lib/typescript/components/Poll/components/PollResults/PollResultItem.d.ts.map +1 -1
  79. package/lib/typescript/components/Poll/components/PollResults/PollResults.d.ts.map +1 -1
  80. package/lib/typescript/components/Poll/hooks/usePollState.d.ts +1 -0
  81. package/lib/typescript/components/Poll/hooks/usePollState.d.ts.map +1 -1
  82. package/lib/typescript/components/Poll/hooks/usePollTextTranslation.d.ts +7 -0
  83. package/lib/typescript/components/Poll/hooks/usePollTextTranslation.d.ts.map +1 -0
  84. package/lib/typescript/components/Poll/index.d.ts +1 -0
  85. package/lib/typescript/components/Poll/index.d.ts.map +1 -1
  86. package/lib/typescript/hooks/messagePreview/useMessagePreviewText.d.ts.map +1 -1
  87. package/lib/typescript/hooks/useTranslatedMessage.d.ts.map +1 -1
  88. package/lib/typescript/store/mappers/mapPollToStorable.d.ts.map +1 -1
  89. package/lib/typescript/store/mappers/mapStorableToPoll.d.ts.map +1 -1
  90. package/lib/typescript/store/schema.d.ts +2 -0
  91. package/lib/typescript/store/schema.d.ts.map +1 -1
  92. package/lib/typescript/utils/i18n/getTranslatedText.d.ts +8 -0
  93. package/lib/typescript/utils/i18n/getTranslatedText.d.ts.map +1 -0
  94. package/lib/typescript/utils/utils.d.ts.map +1 -1
  95. package/package.json +2 -2
  96. package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewPollLabel.test.tsx +28 -3
  97. package/src/components/ChannelPreview/hooks/useChannelPreviewPollLabel.ts +8 -4
  98. package/src/components/Poll/Poll.tsx +4 -2
  99. package/src/components/Poll/__tests__/PollTranslation.test.tsx +152 -0
  100. package/src/components/Poll/components/PollAnswersList.tsx +5 -1
  101. package/src/components/Poll/components/PollOption.tsx +8 -4
  102. package/src/components/Poll/components/PollResults/PollOptionFullResults.tsx +5 -2
  103. package/src/components/Poll/components/PollResults/PollResultItem.tsx +3 -1
  104. package/src/components/Poll/components/PollResults/PollResults.tsx +4 -2
  105. package/src/components/Poll/hooks/usePollState.ts +4 -0
  106. package/src/components/Poll/hooks/usePollTextTranslation.ts +19 -0
  107. package/src/components/Poll/index.ts +1 -0
  108. package/src/hooks/__tests__/useTranslatedMessage.test.tsx +20 -0
  109. package/src/hooks/messagePreview/__tests__/useMessagePreviewText.test.tsx +37 -2
  110. package/src/hooks/messagePreview/useMessagePreviewText.tsx +5 -2
  111. package/src/hooks/useTranslatedMessage.ts +6 -11
  112. package/src/store/SqliteClient.ts +1 -1
  113. package/src/store/apis/__tests__/pollTranslations.test.ts +136 -0
  114. package/src/store/mappers/mapPollToStorable.ts +6 -0
  115. package/src/store/mappers/mapStorableToPoll.ts +4 -0
  116. package/src/store/schema.ts +4 -0
  117. package/src/utils/__tests__/utils.test.ts +42 -1
  118. package/src/utils/i18n/__tests__/getTranslatedText.test.ts +17 -0
  119. package/src/utils/i18n/getTranslatedText.ts +10 -0
  120. package/src/utils/utils.ts +57 -1
  121. package/src/version.json +1 -1
@@ -2,7 +2,7 @@ import React, { PropsWithChildren } from 'react';
2
2
 
3
3
  import { renderHook } from '@testing-library/react-native';
4
4
 
5
- import type { Attachment } from 'stream-chat';
5
+ import type { Attachment, PollResponseData, StreamChat } from 'stream-chat';
6
6
 
7
7
  import { Chat } from '../../../components/Chat/Chat';
8
8
  import { initiateClientWithChannels } from '../../../mock-builders/api/initiateClientWithChannels';
@@ -15,8 +15,12 @@ import { generateMessage } from '../../../mock-builders/generator/message';
15
15
  import { useMessagePreviewText } from '../useMessagePreviewText';
16
16
 
17
17
  describe('useMessagePreviewText', () => {
18
- const render = async (message: ReturnType<typeof generateMessage>) => {
18
+ const render = async (
19
+ message: ReturnType<typeof generateMessage>,
20
+ prepareClient?: (client: StreamChat) => void,
21
+ ) => {
19
22
  const { client } = await initiateClientWithChannels();
23
+ prepareClient?.(client);
20
24
  const wrapper = ({ children }: PropsWithChildren) => <Chat client={client}>{children}</Chat>;
21
25
  const { result } = renderHook(() => useMessagePreviewText({ message }), { wrapper });
22
26
  return result;
@@ -54,4 +58,35 @@ describe('useMessagePreviewText', () => {
54
58
  );
55
59
  expect(result.current).toBe('hello');
56
60
  });
61
+
62
+ describe('poll messages', () => {
63
+ const poll = {
64
+ id: 'poll-id',
65
+ latest_answers: [],
66
+ latest_votes_by_option: {},
67
+ name: 'Favourite colour?',
68
+ name_i18n: { es_text: 'ยฟColor favorito?', language: 'en' },
69
+ options: [],
70
+ vote_count: 0,
71
+ vote_counts_by_option: {},
72
+ } as unknown as PollResponseData;
73
+
74
+ const renderPollMessage = (language: string) => {
75
+ const message = generateMessage({ poll, poll_id: poll.id, text: '' });
76
+ return render(message, (client) => {
77
+ (client.user as { language?: string }).language = language;
78
+ client.polls.hydratePollCache([message]);
79
+ });
80
+ };
81
+
82
+ it("shows the poll question in the user's language", async () => {
83
+ const result = await renderPollMessage('es');
84
+ expect(result.current).toBe('ยฟColor favorito?');
85
+ });
86
+
87
+ it('shows the original poll question when it has no translation', async () => {
88
+ const result = await renderPollMessage('de');
89
+ expect(result.current).toBe('Favourite colour?');
90
+ });
91
+ });
57
92
  });
@@ -14,9 +14,11 @@ import { useGroupedAttachments } from './useGroupedAttachments';
14
14
  import { useChatContext } from '../../contexts/chatContext/ChatContext';
15
15
  import { useTranslationContext } from '../../contexts/translationContext/TranslationContext';
16
16
  import { useStateStore } from '../../hooks/useStateStore';
17
+ import { getTranslatedText } from '../../utils/i18n/getTranslatedText';
17
18
 
18
19
  const selector = (nextValue: PollState) => ({
19
20
  name: nextValue.name,
21
+ name_i18n: nextValue.name_i18n,
20
22
  });
21
23
 
22
24
  export const useMessagePreviewText = ({
@@ -24,10 +26,11 @@ export const useMessagePreviewText = ({
24
26
  }: {
25
27
  message?: LocalMessage | MessageResponse | DraftMessage | null;
26
28
  }) => {
27
- const { t } = useTranslationContext();
29
+ const { t, userLanguage } = useTranslationContext();
28
30
  const { client } = useChatContext();
29
31
  const poll = client.polls.fromState(message?.poll_id ?? '');
30
- const { name: pollName } = useStateStore(poll?.state, selector) ?? {};
32
+ const { name, name_i18n } = useStateStore(poll?.state, selector) ?? {};
33
+ const pollName = getTranslatedText(name_i18n, userLanguage) ?? name;
31
34
  const { giphys, audios, images, videos, files, voiceRecordings } = useGroupedAttachments(
32
35
  message?.attachments,
33
36
  );
@@ -1,13 +1,7 @@
1
1
  import type { LocalMessage, MessageResponse } from 'stream-chat';
2
2
 
3
3
  import { useTranslationContext } from '../contexts/translationContext/TranslationContext';
4
-
5
- /**
6
- * The key under which auto-translated text lives in `message.i18n`. The UI language can be any
7
- * registered string, while `message.i18n` only carries the languages the auto-translation endpoint
8
- * supports: the `in` check below makes an unsupported language fall through to the original text.
9
- */
10
- type TranslationKey = `${string}_text`;
4
+ import { getTranslatedText } from '../utils/i18n/getTranslatedText';
11
5
 
12
6
  /**
13
7
  * Returns a copy of `message` with its text swapped for the auto-translation into `userLanguage`,
@@ -21,16 +15,17 @@ export const getTranslatedMessage = (
21
15
  message: LocalMessage | MessageResponse | undefined,
22
16
  userLanguage: string,
23
17
  ) => {
24
- const translationKey: TranslationKey = `${userLanguage}_text`;
25
-
26
18
  if (!message) {
27
19
  return undefined;
28
20
  }
29
21
 
30
- if (message.i18n && translationKey in message.i18n && message.type !== 'deleted') {
22
+ const translatedText =
23
+ message.type !== 'deleted' ? getTranslatedText(message.i18n, userLanguage) : undefined;
24
+
25
+ if (translatedText !== undefined) {
31
26
  return {
32
27
  ...message,
33
- text: message.i18n[translationKey],
28
+ text: translatedText,
34
29
  };
35
30
  }
36
31
 
@@ -44,7 +44,7 @@ export class SqliteClientError extends Error {
44
44
 
45
45
  /** Wraps all direct use of `@op-engineering/op-sqlite`. */
46
46
  export class SqliteClient {
47
- static dbVersion = 18;
47
+ static dbVersion = 19;
48
48
 
49
49
  static dbName = DB_NAME;
50
50
  static dbLocation = DB_LOCATION;
@@ -0,0 +1,136 @@
1
+ import type { ChannelStateResponseFields, PollResponseData } from 'stream-chat';
2
+
3
+ import { generateChannelResponse } from '../../../mock-builders/generator/channel';
4
+ import { generateMessage } from '../../../mock-builders/generator/message';
5
+ import { BetterSqlite } from '../../../test-utils/BetterSqlite';
6
+ import { SqliteClient } from '../../SqliteClient';
7
+ import { getChannels } from '../getChannels';
8
+ import { upsertChannels } from '../upsertChannels';
9
+ import { upsertPoll } from '../upsertPoll';
10
+
11
+ const POLL_ID = 'poll-id';
12
+
13
+ const translatedPoll = {
14
+ answers_count: 0,
15
+ created_at: new Date('2026-10-27T10:00:00.000Z').getTime(),
16
+ created_by: { id: 'other-user' },
17
+ created_by_id: 'other-user',
18
+ description: 'For the team dinner',
19
+ description_i18n: { es_text: 'Para la cena del equipo', language: 'en' },
20
+ enforce_unique_vote: true,
21
+ id: POLL_ID,
22
+ latest_answers: [],
23
+ latest_votes_by_option: {},
24
+ max_votes_allowed: 1,
25
+ name: 'Favourite colour?',
26
+ name_i18n: { es_text: 'ยฟColor favorito?', language: 'en' },
27
+ options: [
28
+ {
29
+ id: 'option-red',
30
+ poll_id: POLL_ID,
31
+ text: 'Red',
32
+ text_i18n: { es_text: 'Rojo', language: 'en' },
33
+ },
34
+ ],
35
+ updated_at: new Date('2026-10-27T10:00:00.000Z').getTime(),
36
+ vote_count: 0,
37
+ vote_counts_by_option: {},
38
+ } as unknown as PollResponseData;
39
+
40
+ /**
41
+ * The backend translates poll texts. The offline DB has to keep those translations, so a cold start
42
+ * offline shows the poll in the user's language, the same way message translations are kept.
43
+ */
44
+ describe('poll translations in the offline DB', () => {
45
+ let cid: string;
46
+
47
+ beforeEach(async () => {
48
+ await SqliteClient.initializeDatabase();
49
+ await BetterSqlite.openDB();
50
+
51
+ const channelResponse = generateChannelResponse({ members: [], messages: [] });
52
+ cid = channelResponse.channel.cid as string;
53
+ channelResponse.messages = [
54
+ generateMessage({ cid, poll: translatedPoll, poll_id: POLL_ID, text: '' }),
55
+ ] as unknown as typeof channelResponse.messages;
56
+ await upsertChannels({
57
+ channels: [channelResponse] as unknown as ChannelStateResponseFields[],
58
+ isLatestMessagesSet: true,
59
+ });
60
+ });
61
+
62
+ afterEach(() => {
63
+ BetterSqlite.dropAllTables();
64
+ BetterSqlite.closeDB();
65
+ });
66
+
67
+ const getStoredPoll = async () => {
68
+ const [channel] = await getChannels({ channelIds: [cid], userId: 'some-user' });
69
+ return channel.messages[0].poll;
70
+ };
71
+
72
+ it("keeps the question's, description's and options' translations", async () => {
73
+ const poll = await getStoredPoll();
74
+
75
+ expect(poll?.name_i18n).toEqual(translatedPoll.name_i18n);
76
+ expect(poll?.description_i18n).toEqual(translatedPoll.description_i18n);
77
+ expect(poll?.options[0].text_i18n).toEqual(translatedPoll.options[0].text_i18n);
78
+ });
79
+
80
+ it('replaces the translations when the poll is stored again', async () => {
81
+ const name_i18n = { de_text: 'Lieblingsfarbe?', language: 'en' };
82
+ await upsertPoll({ poll: { ...translatedPoll, name_i18n } as PollResponseData });
83
+
84
+ expect((await getStoredPoll())?.name_i18n).toEqual(name_i18n);
85
+ });
86
+
87
+ it('stores a poll without translations unchanged', async () => {
88
+ const {
89
+ description_i18n: _description_i18n,
90
+ name_i18n: _name_i18n,
91
+ ...untranslatedPoll
92
+ } = translatedPoll;
93
+ const untranslatedChannel = generateChannelResponse({ members: [], messages: [] });
94
+ const untranslatedCid = untranslatedChannel.channel.cid as string;
95
+ untranslatedChannel.messages = [
96
+ generateMessage({
97
+ cid: untranslatedCid,
98
+ poll: {
99
+ ...untranslatedPoll,
100
+ id: 'untranslated-poll-id',
101
+ options: [{ id: 'option-red', poll_id: 'untranslated-poll-id', text: 'Red' }],
102
+ } as unknown as PollResponseData,
103
+ poll_id: 'untranslated-poll-id',
104
+ text: '',
105
+ }),
106
+ ] as unknown as typeof untranslatedChannel.messages;
107
+ await upsertChannels({
108
+ channels: [untranslatedChannel] as unknown as ChannelStateResponseFields[],
109
+ isLatestMessagesSet: true,
110
+ });
111
+
112
+ const [channel] = await getChannels({
113
+ channelIds: [untranslatedCid],
114
+ userId: 'some-user',
115
+ });
116
+ const poll = channel.messages[0].poll;
117
+
118
+ expect(poll?.name).toBe('Favourite colour?');
119
+ expect(poll?.description).toBe('For the team dinner');
120
+ expect(poll?.options).toEqual([
121
+ { id: 'option-red', poll_id: 'untranslated-poll-id', text: 'Red' },
122
+ ]);
123
+ expect(poll?.name_i18n).toBeUndefined();
124
+ expect(poll?.description_i18n).toBeUndefined();
125
+ });
126
+
127
+ it('clears translations that the poll no longer has', async () => {
128
+ await upsertPoll({
129
+ poll: { ...translatedPoll, description_i18n: undefined, name_i18n: undefined },
130
+ });
131
+
132
+ const poll = await getStoredPoll();
133
+ expect(poll?.name_i18n).toBeUndefined();
134
+ expect(poll?.description_i18n).toBeUndefined();
135
+ });
136
+ });
@@ -13,6 +13,7 @@ export const mapPollToStorable = (poll: PollResponseData): TableRow<'poll'> => {
13
13
  created_by,
14
14
  created_by_id,
15
15
  description,
16
+ description_i18n,
16
17
  enforce_unique_vote,
17
18
  id,
18
19
  is_closed,
@@ -20,6 +21,7 @@ export const mapPollToStorable = (poll: PollResponseData): TableRow<'poll'> => {
20
21
  latest_votes_by_option,
21
22
  max_votes_allowed,
22
23
  name,
24
+ name_i18n,
23
25
  options,
24
26
  own_votes,
25
27
  updated_at,
@@ -36,6 +38,9 @@ export const mapPollToStorable = (poll: PollResponseData): TableRow<'poll'> => {
36
38
  created_by: JSON.stringify(created_by), // stored as JSON to decouple the user from the poll
37
39
  created_by_id,
38
40
  description,
41
+ // Always written (as `null` when absent): an upsert skips undefined columns, which would keep
42
+ // translations the server has since dropped, e.g. after the question was edited.
43
+ description_i18n: JSON.stringify(description_i18n ?? null),
39
44
  enforce_unique_vote,
40
45
  id,
41
46
  is_closed,
@@ -43,6 +48,7 @@ export const mapPollToStorable = (poll: PollResponseData): TableRow<'poll'> => {
43
48
  latest_votes_by_option: JSON.stringify(latest_votes_by_option),
44
49
  max_votes_allowed: max_votes_allowed ?? 1,
45
50
  name,
51
+ name_i18n: JSON.stringify(name_i18n ?? null),
46
52
  options: JSON.stringify(options),
47
53
  own_votes: JSON.stringify(own_votes),
48
54
  updated_at: mapTimestampToStorable(updated_at),
@@ -13,6 +13,7 @@ export const mapStorableToPoll = (pollRow: TableRow<'poll'>): PollResponseData =
13
13
  created_by,
14
14
  created_by_id,
15
15
  description,
16
+ description_i18n,
16
17
  enforce_unique_vote,
17
18
  id,
18
19
  is_closed,
@@ -20,6 +21,7 @@ export const mapStorableToPoll = (pollRow: TableRow<'poll'>): PollResponseData =
20
21
  latest_votes_by_option,
21
22
  max_votes_allowed,
22
23
  name,
24
+ name_i18n,
23
25
  options,
24
26
  own_votes,
25
27
  updated_at,
@@ -37,6 +39,7 @@ export const mapStorableToPoll = (pollRow: TableRow<'poll'>): PollResponseData =
37
39
  created_by_id,
38
40
  custom: {},
39
41
  description: description ?? '',
42
+ description_i18n: description_i18n ? (JSON.parse(description_i18n) ?? undefined) : undefined,
40
43
  enforce_unique_vote,
41
44
  id,
42
45
  is_closed,
@@ -44,6 +47,7 @@ export const mapStorableToPoll = (pollRow: TableRow<'poll'>): PollResponseData =
44
47
  latest_votes_by_option: JSON.parse(latest_votes_by_option),
45
48
  max_votes_allowed,
46
49
  name,
50
+ name_i18n: name_i18n ? (JSON.parse(name_i18n) ?? undefined) : undefined,
47
51
  options: JSON.parse(options),
48
52
  own_votes: own_votes ? JSON.parse(own_votes) : [],
49
53
  updated_at: mapStorableToRequiredTimestamp(updated_at),
@@ -225,6 +225,7 @@ export const tables: Tables = {
225
225
  created_by: 'TEXT',
226
226
  created_by_id: 'TEXT',
227
227
  description: 'TEXT',
228
+ description_i18n: 'TEXT',
228
229
  enforce_unique_vote: 'BOOLEAN DEFAULT FALSE',
229
230
  id: 'TEXT NOT NULL',
230
231
  is_closed: 'BOOLEAN DEFAULT FALSE',
@@ -232,6 +233,7 @@ export const tables: Tables = {
232
233
  latest_votes_by_option: 'TEXT',
233
234
  max_votes_allowed: 'INTEGER DEFAULT 1',
234
235
  name: 'TEXT',
236
+ name_i18n: 'TEXT',
235
237
  options: 'TEXT',
236
238
  own_votes: 'TEXT',
237
239
  updated_at: 'INTEGER',
@@ -462,7 +464,9 @@ export type Schema = {
462
464
  allow_answers?: boolean;
463
465
  allow_user_suggested_options?: boolean;
464
466
  description?: string;
467
+ description_i18n?: string;
465
468
  is_closed?: boolean;
469
+ name_i18n?: string;
466
470
  own_votes?: string;
467
471
  voting_visibility?: string;
468
472
  };
@@ -1,4 +1,45 @@
1
- import { formatMsToMinSec, getUrlWithoutParams } from '../utils';
1
+ import { formatMsToMinSec, getInitialsFromName, getUrlWithoutParams } from '../utils';
2
+
3
+ describe('getInitialsFromName', () => {
4
+ it('should return the uppercased first letter of the first two words', () => {
5
+ expect(getInitialsFromName('ivan sekovanikj')).toBe('IS');
6
+ expect(getInitialsFromName(' Jane Mary Doe ')).toBe('JM');
7
+ expect(getInitialsFromName('Jane Mary Doe', 3)).toBe('JMD');
8
+ expect(getInitialsFromName('Jane')).toBe('J');
9
+ });
10
+
11
+ it('should return an empty string for empty or blank names', () => {
12
+ expect(getInitialsFromName('')).toBe('');
13
+ expect(getInitialsFromName(' ')).toBe('');
14
+ });
15
+
16
+ it('should keep characters outside the Basic Multilingual Plane intact', () => {
17
+ expect(getInitialsFromName('๐Ÿ˜€ Smiley')).toBe('๐Ÿ˜€S');
18
+ expect(getInitialsFromName('๐’œlice Bob')).toBe('๐’œB');
19
+ });
20
+
21
+ it('should keep emoji sequences intact', () => {
22
+ expect(getInitialsFromName('๐Ÿ‘๐Ÿฝ Thumb')).toBe('๐Ÿ‘๐ŸฝT');
23
+ expect(getInitialsFromName('๐Ÿ‡บ๐Ÿ‡ธ Flag')).toBe('๐Ÿ‡บ๐Ÿ‡ธF');
24
+ expect(getInitialsFromName('๐Ÿ‡บ๐Ÿ‡ธ๐Ÿ‡ฌ๐Ÿ‡ง Flags')).toBe('๐Ÿ‡บ๐Ÿ‡ธF');
25
+ expect(getInitialsFromName('๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง Family')).toBe('๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งF');
26
+ expect(getInitialsFromName('โค๏ธ Heart')).toBe('โค๏ธH');
27
+ expect(getInitialsFromName('๐Ÿด๓ ง๓ ข๓ ฅ๓ ฎ๓ ง๓ ฟ England')).toBe('๐Ÿด๓ ง๓ ข๓ ฅ๓ ฎ๓ ง๓ ฟE');
28
+ });
29
+
30
+ it('should bound the length of a pathological emoji sequence', () => {
31
+ const initials = getInitialsFromName(`${'๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง'.repeat(2000)} x`);
32
+
33
+ expect(initials.length).toBeLessThanOrEqual(33);
34
+ expect(initials.endsWith('X')).toBe(true);
35
+ });
36
+
37
+ it('should take the first code unit of words that do not start with an emoji', () => {
38
+ expect(getInitialsFromName('ะ˜ะฒะฐะฝ ะŸะตั‚ั€ะพะฒ')).toBe('ะ˜ะŸ');
39
+ expect(getInitialsFromName('ๅคงๅญฆ ๅŒๅญฆ')).toBe('ๅคงๅŒ');
40
+ expect(getInitialsFromName('1๏ธโƒฃ One')).toBe('1O');
41
+ });
42
+ });
2
43
 
3
44
  describe('getUrlWithoutParams', () => {
4
45
  const testUrlMap: Record<string, string> = {
@@ -0,0 +1,17 @@
1
+ import { getTranslatedText } from '../getTranslatedText';
2
+
3
+ describe('getTranslatedText', () => {
4
+ const i18n = { de_text: 'Wie geht es?', language: 'en' };
5
+
6
+ it('returns the translation into the given language', () => {
7
+ expect(getTranslatedText(i18n, 'de')).toBe('Wie geht es?');
8
+ });
9
+
10
+ it('returns undefined when there is no translation into the given language', () => {
11
+ expect(getTranslatedText(i18n, 'fr')).toBeUndefined();
12
+ });
13
+
14
+ it('returns undefined when the text has no translations', () => {
15
+ expect(getTranslatedText(undefined, 'de')).toBeUndefined();
16
+ });
17
+ });
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Returns the translation of a text into `language`, or undefined if there is none.
3
+ *
4
+ * The UI language can be any registered string, while `i18n` only carries the languages the
5
+ * auto-translation endpoint supports, so an unsupported language returns undefined.
6
+ */
7
+ export const getTranslatedText = (
8
+ i18n: Record<string, string> | undefined,
9
+ language: string,
10
+ ): string | undefined => i18n?.[`${language}_text`];
@@ -313,6 +313,55 @@ export const checkQuotedMessageEquality = (
313
313
  return quotedMessageEqual;
314
314
  };
315
315
 
316
+ const ZERO_WIDTH_JOINER = 0x200d;
317
+ // The longest real emoji sequences are ~15 code units, this only bounds pathological input.
318
+ const MAX_EMOJI_LENGTH = 32;
319
+ // Emoji start with a high surrogate or sit in the BMP symbol blocks (e.g. โค U+2764). Kept as
320
+ // one-character strings so a word's first character can be compared without `charCodeAt`.
321
+ const SYMBOLS_START = String.fromCharCode(0x2000);
322
+ const SYMBOLS_END = String.fromCharCode(0x2bff);
323
+ const HIGH_SURROGATE_START = String.fromCharCode(0xd800);
324
+ const HIGH_SURROGATE_END = String.fromCharCode(0xdbff);
325
+
326
+ const isRegionalIndicator = (codePoint: number) => codePoint >= 0x1f1e6 && codePoint <= 0x1f1ff;
327
+
328
+ // Code points that attach to the preceding emoji instead of starting a new character.
329
+ const isEmojiModifier = (codePoint: number) =>
330
+ (codePoint >= 0xfe00 && codePoint <= 0xfe0f) || // variation selectors
331
+ (codePoint >= 0x1f3fb && codePoint <= 0x1f3ff) || // skin tone modifiers
332
+ (codePoint >= 0xe0020 && codePoint <= 0xe007f); // tags, used by subdivision flags
333
+
334
+ const codePointLength = (codePoint: number) => (codePoint > 0xffff ? 2 : 1);
335
+
336
+ /**
337
+ * Returns the first character of a string that starts with an emoji, keeping skin tones, flags
338
+ * and ZWJ sequences intact. Hermes has no `Intl.Segmenter`, so the common cases are covered with
339
+ * explicit code point ranges.
340
+ */
341
+ const getFirstEmoji = (text: string) => {
342
+ const first = text.codePointAt(0) as number;
343
+ let end = codePointLength(first);
344
+
345
+ if (isRegionalIndicator(first)) {
346
+ const next = text.codePointAt(end);
347
+ if (next !== undefined && isRegionalIndicator(next)) {
348
+ end += 2;
349
+ }
350
+ }
351
+ while (end < text.length && end < MAX_EMOJI_LENGTH) {
352
+ const codePoint = text.codePointAt(end) as number;
353
+ if (isEmojiModifier(codePoint)) {
354
+ end += codePointLength(codePoint);
355
+ } else if (codePoint === ZERO_WIDTH_JOINER && end + 1 < text.length) {
356
+ end += 1 + codePointLength(text.codePointAt(end + 1) as number);
357
+ } else {
358
+ break;
359
+ }
360
+ }
361
+
362
+ return text.slice(0, end);
363
+ };
364
+
316
365
  /**
317
366
  * Returns the uppercase first letters of the first words of a name.
318
367
  *
@@ -328,7 +377,14 @@ export const getInitialsFromName = (name: string, numberOfInitials: number = 2)
328
377
  return trimmed
329
378
  .split(/\s+/)
330
379
  .slice(0, numberOfInitials)
331
- .map((n) => n.charAt(0).toUpperCase())
380
+ .map((n) => {
381
+ const first = n.charAt(0);
382
+ // Plain names take the same `charAt(0)` path as before; only possible emoji do more work.
383
+ const mayBeEmoji =
384
+ first >= SYMBOLS_START &&
385
+ (first <= SYMBOLS_END || (first >= HIGH_SURROGATE_START && first <= HIGH_SURROGATE_END));
386
+ return (mayBeEmoji ? getFirstEmoji(n) : first).toUpperCase();
387
+ })
332
388
  .join('');
333
389
  };
334
390
 
package/src/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "10.0.0-rc.5"
2
+ "version": "10.0.0"
3
3
  }