stream-chat-react-native-core 10.0.0-rc.5 โ 10.0.1-beta.1
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.
- package/lib/commonjs/components/ChannelPreview/hooks/useChannelPreviewPollLabel.js +8 -5
- package/lib/commonjs/components/ChannelPreview/hooks/useChannelPreviewPollLabel.js.map +1 -1
- package/lib/commonjs/components/Poll/Poll.js +5 -2
- package/lib/commonjs/components/Poll/Poll.js.map +1 -1
- package/lib/commonjs/components/Poll/components/PollAnswersList.js +4 -2
- package/lib/commonjs/components/Poll/components/PollAnswersList.js.map +1 -1
- package/lib/commonjs/components/Poll/components/PollOption.js +8 -3
- package/lib/commonjs/components/Poll/components/PollOption.js.map +1 -1
- package/lib/commonjs/components/Poll/components/PollResults/PollOptionFullResults.js +5 -2
- package/lib/commonjs/components/Poll/components/PollResults/PollOptionFullResults.js.map +1 -1
- package/lib/commonjs/components/Poll/components/PollResults/PollResultItem.js +3 -1
- package/lib/commonjs/components/Poll/components/PollResults/PollResultItem.js.map +1 -1
- package/lib/commonjs/components/Poll/components/PollResults/PollResults.js +4 -1
- package/lib/commonjs/components/Poll/components/PollResults/PollResults.js.map +1 -1
- package/lib/commonjs/components/Poll/hooks/usePollState.js +3 -0
- package/lib/commonjs/components/Poll/hooks/usePollState.js.map +1 -1
- package/lib/commonjs/components/Poll/hooks/usePollTextTranslation.js +17 -0
- package/lib/commonjs/components/Poll/hooks/usePollTextTranslation.js.map +1 -0
- package/lib/commonjs/components/Poll/index.js +11 -0
- package/lib/commonjs/components/Poll/index.js.map +1 -1
- package/lib/commonjs/hooks/messagePreview/useMessagePreviewText.js +9 -4
- package/lib/commonjs/hooks/messagePreview/useMessagePreviewText.js.map +1 -1
- package/lib/commonjs/hooks/useTranslatedMessage.js +4 -3
- package/lib/commonjs/hooks/useTranslatedMessage.js.map +1 -1
- package/lib/commonjs/store/SqliteClient.js +1 -1
- package/lib/commonjs/store/mappers/mapPollToStorable.js +4 -0
- package/lib/commonjs/store/mappers/mapPollToStorable.js.map +1 -1
- package/lib/commonjs/store/mappers/mapStorableToPoll.js +5 -1
- package/lib/commonjs/store/mappers/mapStorableToPoll.js.map +1 -1
- package/lib/commonjs/store/schema.js +2 -0
- package/lib/commonjs/store/schema.js.map +1 -1
- package/lib/commonjs/utils/i18n/getTranslatedText.js +7 -0
- package/lib/commonjs/utils/i18n/getTranslatedText.js.map +1 -0
- package/lib/commonjs/utils/utils.js +35 -1
- package/lib/commonjs/utils/utils.js.map +1 -1
- package/lib/commonjs/version.json +1 -1
- package/lib/module/components/ChannelPreview/hooks/useChannelPreviewPollLabel.js +8 -5
- package/lib/module/components/ChannelPreview/hooks/useChannelPreviewPollLabel.js.map +1 -1
- package/lib/module/components/Poll/Poll.js +5 -2
- package/lib/module/components/Poll/Poll.js.map +1 -1
- package/lib/module/components/Poll/components/PollAnswersList.js +4 -2
- package/lib/module/components/Poll/components/PollAnswersList.js.map +1 -1
- package/lib/module/components/Poll/components/PollOption.js +8 -3
- package/lib/module/components/Poll/components/PollOption.js.map +1 -1
- package/lib/module/components/Poll/components/PollResults/PollOptionFullResults.js +5 -2
- package/lib/module/components/Poll/components/PollResults/PollOptionFullResults.js.map +1 -1
- package/lib/module/components/Poll/components/PollResults/PollResultItem.js +3 -1
- package/lib/module/components/Poll/components/PollResults/PollResultItem.js.map +1 -1
- package/lib/module/components/Poll/components/PollResults/PollResults.js +4 -1
- package/lib/module/components/Poll/components/PollResults/PollResults.js.map +1 -1
- package/lib/module/components/Poll/hooks/usePollState.js +3 -0
- package/lib/module/components/Poll/hooks/usePollState.js.map +1 -1
- package/lib/module/components/Poll/hooks/usePollTextTranslation.js +17 -0
- package/lib/module/components/Poll/hooks/usePollTextTranslation.js.map +1 -0
- package/lib/module/components/Poll/index.js +11 -0
- package/lib/module/components/Poll/index.js.map +1 -1
- package/lib/module/hooks/messagePreview/useMessagePreviewText.js +9 -4
- package/lib/module/hooks/messagePreview/useMessagePreviewText.js.map +1 -1
- package/lib/module/hooks/useTranslatedMessage.js +4 -3
- package/lib/module/hooks/useTranslatedMessage.js.map +1 -1
- package/lib/module/store/SqliteClient.js +1 -1
- package/lib/module/store/mappers/mapPollToStorable.js +4 -0
- package/lib/module/store/mappers/mapPollToStorable.js.map +1 -1
- package/lib/module/store/mappers/mapStorableToPoll.js +5 -1
- package/lib/module/store/mappers/mapStorableToPoll.js.map +1 -1
- package/lib/module/store/schema.js +2 -0
- package/lib/module/store/schema.js.map +1 -1
- package/lib/module/utils/i18n/getTranslatedText.js +7 -0
- package/lib/module/utils/i18n/getTranslatedText.js.map +1 -0
- package/lib/module/utils/utils.js +35 -1
- package/lib/module/utils/utils.js.map +1 -1
- package/lib/module/version.json +1 -1
- package/lib/typescript/components/ChannelPreview/hooks/useChannelPreviewPollLabel.d.ts.map +1 -1
- package/lib/typescript/components/Poll/Poll.d.ts.map +1 -1
- package/lib/typescript/components/Poll/components/PollAnswersList.d.ts.map +1 -1
- package/lib/typescript/components/Poll/components/PollOption.d.ts.map +1 -1
- package/lib/typescript/components/Poll/components/PollResults/PollOptionFullResults.d.ts.map +1 -1
- package/lib/typescript/components/Poll/components/PollResults/PollResultItem.d.ts.map +1 -1
- package/lib/typescript/components/Poll/components/PollResults/PollResults.d.ts.map +1 -1
- package/lib/typescript/components/Poll/hooks/usePollState.d.ts +1 -0
- package/lib/typescript/components/Poll/hooks/usePollState.d.ts.map +1 -1
- package/lib/typescript/components/Poll/hooks/usePollTextTranslation.d.ts +7 -0
- package/lib/typescript/components/Poll/hooks/usePollTextTranslation.d.ts.map +1 -0
- package/lib/typescript/components/Poll/index.d.ts +1 -0
- package/lib/typescript/components/Poll/index.d.ts.map +1 -1
- package/lib/typescript/hooks/messagePreview/useMessagePreviewText.d.ts.map +1 -1
- package/lib/typescript/hooks/useTranslatedMessage.d.ts.map +1 -1
- package/lib/typescript/store/mappers/mapPollToStorable.d.ts.map +1 -1
- package/lib/typescript/store/mappers/mapStorableToPoll.d.ts.map +1 -1
- package/lib/typescript/store/schema.d.ts +2 -0
- package/lib/typescript/store/schema.d.ts.map +1 -1
- package/lib/typescript/utils/i18n/getTranslatedText.d.ts +8 -0
- package/lib/typescript/utils/i18n/getTranslatedText.d.ts.map +1 -0
- package/lib/typescript/utils/utils.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/components/ChannelPreview/hooks/__tests__/useChannelPreviewPollLabel.test.tsx +28 -3
- package/src/components/ChannelPreview/hooks/useChannelPreviewPollLabel.ts +8 -4
- package/src/components/Poll/Poll.tsx +4 -2
- package/src/components/Poll/__tests__/PollTranslation.test.tsx +152 -0
- package/src/components/Poll/components/PollAnswersList.tsx +5 -1
- package/src/components/Poll/components/PollOption.tsx +8 -4
- package/src/components/Poll/components/PollResults/PollOptionFullResults.tsx +5 -2
- package/src/components/Poll/components/PollResults/PollResultItem.tsx +3 -1
- package/src/components/Poll/components/PollResults/PollResults.tsx +4 -2
- package/src/components/Poll/hooks/usePollState.ts +4 -0
- package/src/components/Poll/hooks/usePollTextTranslation.ts +19 -0
- package/src/components/Poll/index.ts +1 -0
- package/src/hooks/__tests__/useTranslatedMessage.test.tsx +20 -0
- package/src/hooks/messagePreview/__tests__/useMessagePreviewText.test.tsx +37 -2
- package/src/hooks/messagePreview/useMessagePreviewText.tsx +5 -2
- package/src/hooks/useTranslatedMessage.ts +6 -11
- package/src/store/SqliteClient.ts +1 -1
- package/src/store/apis/__tests__/pollTranslations.test.ts +136 -0
- package/src/store/mappers/mapPollToStorable.ts +6 -0
- package/src/store/mappers/mapStorableToPoll.ts +4 -0
- package/src/store/schema.ts +4 -0
- package/src/utils/__tests__/utils.test.ts +42 -1
- package/src/utils/i18n/__tests__/getTranslatedText.test.ts +17 -0
- package/src/utils/i18n/getTranslatedText.ts +10 -0
- package/src/utils/utils.ts +57 -1
- 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 (
|
|
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
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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),
|
package/src/store/schema.ts
CHANGED
|
@@ -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`];
|
package/src/utils/utils.ts
CHANGED
|
@@ -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) =>
|
|
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