Skip to content

Commit 83b18a2

Browse files
authored
fix: frequently used emoji crash on non-ASCII record ids (#7378)
1 parent 876124f commit 83b18a2

6 files changed

Lines changed: 259 additions & 48 deletions

File tree

app/lib/database/model/migrations.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { addColumns, createTable, schemaMigrations } from '@nozbe/watermelondb/Schema/migrations';
1+
import { addColumns, createTable, schemaMigrations, unsafeExecuteSql } from '@nozbe/watermelondb/Schema/migrations';
22

33
export default schemaMigrations({
44
migrations: [
@@ -345,6 +345,14 @@ export default schemaMigrations({
345345
]
346346
})
347347
]
348+
},
349+
{
350+
toVersion: 29,
351+
steps: [
352+
// Drop legacy rows whose id contains a non-printable-ASCII char: emoji content/names
353+
// were used as ids and corrupt across the native bridge. ASCII ids (e.g. heart_eyes) are kept.
354+
unsafeExecuteSql("DELETE FROM frequently_used_emojis WHERE id GLOB '*[^ -~]*';")
355+
]
348356
}
349357
]
350358
});

app/lib/database/schema/app.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { appSchema, tableSchema } from '@nozbe/watermelondb';
22

33
export default appSchema({
4-
version: 28,
4+
version: 29,
55
tables: [
66
tableSchema({
77
name: 'subscriptions',
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { act, renderHook, waitFor } from '@testing-library/react-native';
2+
3+
import { useFrequentlyUsedEmoji } from './useFrequentlyUsedEmoji';
4+
import { getFrequentlyUsedEmojis } from '../methods/emojis';
5+
import { type IEmoji } from '../../definitions';
6+
7+
// jest.setup.js stubs this hook globally for component tests; use the real one here.
8+
jest.mock('./useFrequentlyUsedEmoji', () => jest.requireActual('./useFrequentlyUsedEmoji'));
9+
jest.mock('../methods/emojis', () => ({
10+
getFrequentlyUsedEmojis: jest.fn()
11+
}));
12+
13+
const mockedGetFrequentlyUsedEmojis = jest.mocked(getFrequentlyUsedEmojis);
14+
15+
const createDeferred = <T>() => {
16+
let resolve!: (value: T) => void;
17+
const promise = new Promise<T>(res => {
18+
resolve = res;
19+
});
20+
21+
return { promise, resolve };
22+
};
23+
24+
describe('useFrequentlyUsedEmoji', () => {
25+
beforeEach(() => {
26+
jest.clearAllMocks();
27+
});
28+
29+
it('starts unloaded, then exposes the fetched emojis and flips loaded', async () => {
30+
const deferred = createDeferred<IEmoji[]>();
31+
mockedGetFrequentlyUsedEmojis.mockReturnValue(deferred.promise);
32+
33+
const { result } = renderHook(() => useFrequentlyUsedEmoji());
34+
35+
expect(result.current).toEqual({ frequentlyUsed: [], loaded: false });
36+
37+
await act(async () => {
38+
deferred.resolve(['grinning']);
39+
await deferred.promise;
40+
});
41+
42+
await waitFor(() => expect(result.current.loaded).toBe(true));
43+
expect(result.current.frequentlyUsed).toEqual(['grinning']);
44+
expect(mockedGetFrequentlyUsedEmojis).toHaveBeenCalledWith(false);
45+
});
46+
47+
it('refetches when withDefaultEmojis changes', async () => {
48+
mockedGetFrequentlyUsedEmojis.mockResolvedValue([]);
49+
50+
const { result, rerender } = renderHook(
51+
({ withDefaults }: { withDefaults: boolean }) => useFrequentlyUsedEmoji(withDefaults),
52+
{
53+
initialProps: { withDefaults: false }
54+
}
55+
);
56+
57+
await waitFor(() => expect(result.current.loaded).toBe(true));
58+
expect(mockedGetFrequentlyUsedEmojis).toHaveBeenCalledWith(false);
59+
60+
rerender({ withDefaults: true });
61+
62+
await waitFor(() => expect(mockedGetFrequentlyUsedEmojis).toHaveBeenCalledWith(true));
63+
expect(mockedGetFrequentlyUsedEmojis).toHaveBeenCalledTimes(2);
64+
});
65+
});
Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import { useEffect, useState } from 'react';
2-
import { Q } from '@nozbe/watermelondb';
32

4-
import database from '../database';
53
import { type IEmoji } from '../../definitions';
6-
import { DEFAULT_EMOJIS } from '../constants/emojis';
4+
import { getFrequentlyUsedEmojis } from '../methods/emojis';
75

86
export const useFrequentlyUsedEmoji = (
97
withDefaultEmojis = false
@@ -14,26 +12,12 @@ export const useFrequentlyUsedEmoji = (
1412
const [frequentlyUsed, setFrequentlyUsed] = useState<IEmoji[]>([]);
1513
const [loaded, setLoaded] = useState(false);
1614
useEffect(() => {
17-
const getFrequentlyUsedEmojis = async () => {
18-
const db = database.active;
19-
const frequentlyUsedRecords = await db.get('frequently_used_emojis').query(Q.sortBy('count', Q.desc)).fetch();
20-
let frequentlyUsedEmojis = frequentlyUsedRecords.map(item => {
21-
if (item.isCustom) {
22-
return { name: item.content, extension: item.extension! }; // if isCustom is true, extension is not null
23-
}
24-
return item.content;
25-
});
26-
27-
if (withDefaultEmojis && frequentlyUsedEmojis.length < DEFAULT_EMOJIS.length) {
28-
frequentlyUsedEmojis = frequentlyUsedEmojis
29-
.concat(DEFAULT_EMOJIS.filter(de => !frequentlyUsedEmojis.find(fue => typeof fue === 'string' && fue === de)))
30-
.slice(0, DEFAULT_EMOJIS.length);
31-
}
32-
33-
setFrequentlyUsed(frequentlyUsedEmojis);
15+
const fetchFrequentlyUsedEmojis = async () => {
16+
const emojis = await getFrequentlyUsedEmojis(withDefaultEmojis);
17+
setFrequentlyUsed(emojis);
3418
setLoaded(true);
3519
};
36-
getFrequentlyUsedEmojis();
37-
}, []);
20+
fetchFrequentlyUsedEmojis();
21+
}, [withDefaultEmojis]);
3822
return { frequentlyUsed, loaded };
3923
};

app/lib/methods/emojis.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import database from '../database';
2+
import { DEFAULT_EMOJIS } from '../constants/emojis';
3+
import migrations from '../database/model/migrations';
4+
import appSchema from '../database/schema/app';
5+
import { addFrequentlyUsed, getFrequentlyUsedEmojis } from './emojis';
6+
7+
jest.mock('../database', () => ({
8+
__esModule: true,
9+
default: {
10+
active: {
11+
get: jest.fn(),
12+
write: jest.fn()
13+
}
14+
}
15+
}));
16+
jest.mock('./helpers/log', () => ({ __esModule: true, default: jest.fn() }));
17+
18+
const mockGet = database.active.get as jest.Mock;
19+
const mockWrite = database.active.write as jest.Mock;
20+
21+
let mockFetch: jest.Mock;
22+
let mockCreate: jest.Mock;
23+
let mockQuery: jest.Mock;
24+
25+
beforeEach(() => {
26+
jest.clearAllMocks();
27+
mockFetch = jest.fn().mockResolvedValue([]);
28+
mockCreate = jest.fn();
29+
mockQuery = jest.fn(() => ({ fetch: mockFetch }));
30+
mockGet.mockReturnValue({ query: mockQuery, create: mockCreate });
31+
mockWrite.mockImplementation((cb: () => Promise<void>) => cb());
32+
});
33+
34+
describe('addFrequentlyUsed', () => {
35+
it('creates a new row by querying content + is_custom and never sets the emoji as the id', async () => {
36+
mockFetch.mockResolvedValue([]);
37+
38+
await addFrequentlyUsed({ name: '大丈夫', extension: 'png' });
39+
40+
expect(mockQuery).toHaveBeenCalledTimes(1);
41+
expect(mockQuery.mock.calls[0]).toHaveLength(2);
42+
expect(mockCreate).toHaveBeenCalledTimes(1);
43+
44+
const record: any = {};
45+
mockCreate.mock.calls[0][0](record);
46+
expect(record.content).toBe('大丈夫');
47+
expect(record.isCustom).toBe(true);
48+
expect(record.extension).toBe('png');
49+
expect(record.count).toBe(1);
50+
expect(record.id).toBeUndefined();
51+
expect(record._raw).toBeUndefined();
52+
});
53+
54+
it('does not set a custom extension or id for a standard emoji', async () => {
55+
mockFetch.mockResolvedValue([]);
56+
57+
await addFrequentlyUsed('grinning');
58+
59+
const record: any = {};
60+
mockCreate.mock.calls[0][0](record);
61+
expect(record.content).toBe('grinning');
62+
expect(record.isCustom).toBe(false);
63+
expect(record.extension).toBeUndefined();
64+
expect(record.id).toBeUndefined();
65+
});
66+
67+
it('increments count for an existing emoji instead of creating a duplicate', async () => {
68+
const existing: any = { count: 3 };
69+
existing.update = jest.fn((fn: (f: any) => void) => fn(existing));
70+
mockFetch.mockResolvedValue([existing]);
71+
72+
await addFrequentlyUsed('grinning');
73+
74+
expect(mockCreate).not.toHaveBeenCalled();
75+
expect(existing.update).toHaveBeenCalledTimes(1);
76+
expect(existing.count).toBe(4);
77+
});
78+
79+
it('runs the existing-row lookup inside the serialized write so concurrent calls cannot both create', async () => {
80+
mockWrite.mockImplementation(() => Promise.resolve());
81+
82+
await addFrequentlyUsed('grinning');
83+
84+
expect(mockWrite).toHaveBeenCalledTimes(1);
85+
expect(mockQuery).not.toHaveBeenCalled();
86+
expect(mockFetch).not.toHaveBeenCalled();
87+
});
88+
});
89+
90+
describe('getFrequentlyUsedEmojis', () => {
91+
it('maps standard and custom rows to the shapes the UI expects', async () => {
92+
mockFetch.mockResolvedValue([
93+
{ isCustom: false, content: 'grinning' },
94+
{ isCustom: true, content: 'rocketchat', extension: 'png' }
95+
]);
96+
97+
const result = await getFrequentlyUsedEmojis();
98+
99+
expect(result).toEqual(['grinning', { name: 'rocketchat', extension: 'png' }]);
100+
});
101+
102+
it('returns an empty list instead of throwing when the native bridge desyncs', async () => {
103+
mockFetch.mockRejectedValue(
104+
new Error("Record ID frequently_used_emojis#大丈夫 was sent over the bridge, but it's not cached")
105+
);
106+
107+
await expect(getFrequentlyUsedEmojis()).resolves.toEqual([]);
108+
});
109+
110+
it('falls back to the default emojis on a bridge error when defaults are requested', async () => {
111+
mockFetch.mockRejectedValue(
112+
new Error("Record ID frequently_used_emojis#大丈夫 was sent over the bridge, but it's not cached")
113+
);
114+
115+
await expect(getFrequentlyUsedEmojis(true)).resolves.toEqual(DEFAULT_EMOJIS);
116+
});
117+
});
118+
119+
describe('frequently_used_emojis migration', () => {
120+
it('bumps the schema to v29 with a matching migration', () => {
121+
expect(appSchema.version).toBe(29);
122+
expect((migrations as any).maxVersion).toBe(29);
123+
});
124+
125+
it('v29 deletes only legacy rows whose id contains a non-printable-ASCII character', () => {
126+
const v29 = (migrations as any).sortedMigrations.find((m: any) => m.toVersion === 29);
127+
expect(v29).toBeDefined();
128+
const sqls = (v29.steps as any[]).filter(s => s.type === 'sql').map(s => s.sql);
129+
expect(sqls.some(sql => /DELETE FROM frequently_used_emojis/i.test(sql) && /\[\^ -~\]/.test(sql))).toBe(true);
130+
});
131+
});

app/lib/methods/emojis.ts

Lines changed: 47 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,42 @@
1-
import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord';
21
import { Q } from '@nozbe/watermelondb';
32

43
import database from '../database';
5-
import { type IEmoji, type TFrequentlyUsedEmojiModel } from '../../definitions';
4+
import { type ICustomEmoji, type IEmoji, type TFrequentlyUsedEmojiModel } from '../../definitions';
65
import log from './helpers/log';
76
import { sanitizeLikeString } from '../database/utils';
8-
import { emojis } from '../constants/emojis';
7+
import { DEFAULT_EMOJIS, emojis } from '../constants/emojis';
8+
9+
const FREQUENTLY_USED_TABLE = 'frequently_used_emojis';
10+
11+
// Looked up by content, never used as the record id: emoji content / custom names can be
12+
// non-ASCII and corrupt across the native SQLite bridge when used as WatermelonDB ids.
13+
const getEmojiContent = (emoji: IEmoji) => (typeof emoji === 'string' ? emoji : emoji.name);
914

1015
export const addFrequentlyUsed = async (emoji: IEmoji) => {
1116
const db = database.active;
12-
const freqEmojiCollection = db.get('frequently_used_emojis');
13-
let freqEmojiRecord: TFrequentlyUsedEmojiModel;
14-
try {
15-
if (typeof emoji === 'string') {
16-
freqEmojiRecord = await freqEmojiCollection.find(emoji);
17-
} else {
18-
freqEmojiRecord = await freqEmojiCollection.find(emoji.name);
19-
}
20-
} catch (error) {
21-
// Do nothing
22-
}
23-
17+
const freqEmojiCollection = db.get(FREQUENTLY_USED_TABLE);
18+
const content = getEmojiContent(emoji);
19+
const isCustom = typeof emoji !== 'string';
2420
try {
2521
await db.write(async () => {
26-
if (freqEmojiRecord) {
27-
await freqEmojiRecord.update(f => {
22+
const [existing] = (await freqEmojiCollection
23+
.query(Q.where('content', content), Q.where('is_custom', isCustom))
24+
.fetch()) as TFrequentlyUsedEmojiModel[];
25+
if (existing) {
26+
await existing.update(f => {
2827
if (f.count) {
2928
f.count += 1;
3029
}
3130
});
3231
} else {
3332
await freqEmojiCollection.create(f => {
34-
if (typeof emoji === 'string') {
35-
f._raw = sanitizedRaw({ id: emoji }, freqEmojiCollection.schema);
36-
Object.assign(f, { content: emoji, isCustom: false });
37-
} else {
38-
f._raw = sanitizedRaw({ id: emoji.name }, freqEmojiCollection.schema);
39-
Object.assign(f, { content: emoji.name, extension: emoji.extension, isCustom: true });
33+
const record = f as TFrequentlyUsedEmojiModel;
34+
record.content = content;
35+
record.isCustom = isCustom;
36+
if (isCustom) {
37+
record.extension = (emoji as ICustomEmoji).extension;
4038
}
41-
f.count = 1;
39+
record.count = 1;
4240
});
4341
}
4442
});
@@ -47,6 +45,31 @@ export const addFrequentlyUsed = async (emoji: IEmoji) => {
4745
}
4846
};
4947

48+
export const getFrequentlyUsedEmojis = async (withDefaultEmojis = false): Promise<IEmoji[]> => {
49+
const db = database.active;
50+
try {
51+
const records = (await db.get(FREQUENTLY_USED_TABLE).query(Q.sortBy('count', Q.desc)).fetch()) as TFrequentlyUsedEmojiModel[];
52+
let frequentlyUsedEmojis: IEmoji[] = records.map(item => {
53+
if (item.isCustom) {
54+
return { name: item.content, extension: item.extension! }; // if isCustom is true, extension is not null
55+
}
56+
return item.content;
57+
});
58+
59+
if (withDefaultEmojis && frequentlyUsedEmojis.length < DEFAULT_EMOJIS.length) {
60+
frequentlyUsedEmojis = frequentlyUsedEmojis
61+
.concat(DEFAULT_EMOJIS.filter(de => !frequentlyUsedEmojis.find(fue => typeof fue === 'string' && fue === de)))
62+
.slice(0, DEFAULT_EMOJIS.length);
63+
}
64+
65+
return frequentlyUsedEmojis;
66+
} catch (e) {
67+
// A legacy non-ASCII id can still reject the fetch; degrade rather than crash the emoji UI.
68+
log(e);
69+
return withDefaultEmojis ? [...DEFAULT_EMOJIS] : [];
70+
}
71+
};
72+
5073
export const searchEmojis = async (keyword: string): Promise<IEmoji[]> => {
5174
const likeString = sanitizeLikeString(keyword);
5275
const whereClause = [];

0 commit comments

Comments
 (0)