Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/containers/EmojiPicker/EmojiCategory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { type ICustomEmojis, type IEmoji } from '../../definitions/IEmoji';
import scrollPersistTaps from '../../lib/methods/helpers/scrollPersistTaps';
import { PressableEmoji } from './PressableEmoji';
import { EMOJI_BUTTON_SIZE } from './styles';
import { emojisByCategory } from '../../lib/constants/emojis';
import { emojisByCategory } from '../../lib/constants/emojis/data';
import { useAppSelector } from '../../lib/hooks/useAppSelector';
import { useFrequentlyUsedEmoji } from '../../lib/hooks/useFrequentlyUsedEmoji';
import { type IEmojiCategoryProps, type TEmojiCategory } from './interfaces';
Expand Down
2 changes: 1 addition & 1 deletion app/containers/EmojiPicker/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import EmojiCategory from './EmojiCategory';
import Footer from './Footer';
import styles from './styles';
import { categories } from '../../lib/constants/emojis';
import { categories } from '../../lib/constants/emojis/categories';
import { type IEmoji } from '../../definitions';
import { addFrequentlyUsed } from '../../lib/methods/emojis';
import { type IEmojiPickerProps, EventTypes } from './interfaces';
Expand Down
2 changes: 1 addition & 1 deletion app/containers/EmojiPicker/interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type ImageStyle, type StyleProp, type TextInputProps } from 'react-native';

import { type emojisByCategory } from '../../lib/constants/emojis';
import { type emojisByCategory } from '../../lib/constants/emojis/data';
import { type ICustomEmoji, type IEmoji } from '../../definitions';

export enum EventTypes {
Expand Down
14 changes: 5 additions & 9 deletions app/containers/MessageComposer/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { sanitizeLikeString } from '../../lib/database/utils';
import { generateTriggerId } from '../../lib/methods/actions';
import { runSlashCommand } from '../../lib/services/restApi';
import log from '../../lib/methods/helpers/log';
import { prepareQuoteMessage, insertEmojiAtCursor } from './helpers';
import { prepareQuoteMessage, insertEmojiAtCursor, lastGlyphLength } from './helpers';
import useShortnameToUnicode from '../../lib/hooks/useShortnameToUnicode';
import { useCloseKeyboardWhenOrientationChanges } from './hooks/useCloseKeyboardWhenOrientationChanges';
import { useEmojiKeyboard } from './hooks/useEmojiKeyboard';
Expand Down Expand Up @@ -186,14 +186,10 @@ export const MessageComposer = ({

switch (eventType) {
case EventTypes.BACKSPACE_PRESSED:
const emojiRegex = /\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]/;
let charsToRemove = 1;
const lastEmoji = text.substr(cursor > 0 ? cursor - 2 : text.length - 2, cursor > 0 ? cursor : text.length);
// Check if last character is an emoji
if (emojiRegex.test(lastEmoji)) charsToRemove = 2;
newText =
text.substr(0, (cursor > 0 ? cursor : text.length) - charsToRemove) + text.substr(cursor > 0 ? cursor : text.length);
newCursor = cursor - charsToRemove;
const deleteAt = cursor > 0 ? cursor : text.length;
const charsToRemove = lastGlyphLength(text, deleteAt);
newText = text.substr(0, deleteAt - charsToRemove) + text.substr(deleteAt);
newCursor = deleteAt - charsToRemove;
composerInputComponentRef.current.setInput(newText, { start: newCursor, end: newCursor });
break;
case EventTypes.EMOJI_PRESSED:
Expand Down
1 change: 1 addition & 0 deletions app/containers/MessageComposer/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './forceJpgExtension';
export * from './getMentionRegexp';
export * from './prepareQuoteMessage';
export * from './insertEmojiAtCursor';
export * from './lastGlyphLength';
50 changes: 50 additions & 0 deletions app/containers/MessageComposer/helpers/lastGlyphLength.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { lastGlyphLength } from './lastGlyphLength';

const at = (text: string) => lastGlyphLength(text, text.length);

describe('lastGlyphLength', () => {
it('counts a plain character as one code unit', () => {
expect(at('ab')).toBe(1);
});

it('counts a surrogate pair as one glyph', () => {
expect(at('\u{1F6B2}')).toBe(2);
});

it('counts a surrogate pair followed by a variation selector as one glyph', () => {
// What emojibase emits for :bike:, and what the old two-code-unit window missed.
expect(at('\u{1F6B2}️')).toBe(3);
});

it('counts a variation selector on a BMP base as one glyph', () => {
expect(at('❀️')).toBe(2);
});

it('counts a skin tone modifier as part of the glyph', () => {
expect(at('\u{1F44D}\u{1F3FD}')).toBe(4);
});

it('counts a ZWJ sequence as one glyph', () => {
expect(at('\u{1F468}‍\u{1F469}‍\u{1F467}‍\u{1F466}')).toBe(11);
});

it('counts a ZWJ sequence ending in a variation selector as one glyph', () => {
expect(at('\u{1F93C}‍♂️')).toBe(5);
});

it('counts a keycap as one glyph', () => {
expect(at('1️⃣')).toBe(3);
});

it('counts a flag as one glyph', () => {
expect(at('\u{1F1E7}\u{1F1F7}')).toBe(4);
});

it('leaves text before the glyph alone', () => {
expect(lastGlyphLength('hi \u{1F6B2}️ there', 6)).toBe(3);
});

it('returns zero at the start of the text', () => {
expect(lastGlyphLength('abc', 0)).toBe(0);
});
});
57 changes: 57 additions & 0 deletions app/containers/MessageComposer/helpers/lastGlyphLength.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
const ZWJ = 0x200d;
const VARIATION_SELECTOR_16 = 0xfe0f;
const VARIATION_SELECTOR_15 = 0xfe0e;
const COMBINING_ENCLOSING_KEYCAP = 0x20e3;

const isSkinTone = (codePoint: number) => codePoint >= 0x1f3fb && codePoint <= 0x1f3ff;
const isRegionalIndicator = (codePoint: number) => codePoint >= 0x1f1e6 && codePoint <= 0x1f1ff;
const isAttachedToWhatPrecedesIt = (codePoint: number) =>
codePoint === VARIATION_SELECTOR_16 ||
codePoint === VARIATION_SELECTOR_15 ||
codePoint === COMBINING_ENCLOSING_KEYCAP ||
isSkinTone(codePoint);
Comment thread
OtavioStasiak marked this conversation as resolved.

const codePointBefore = (text: string, index: number) => {
const low = text.charCodeAt(index - 1);
if (low >= 0xdc00 && low <= 0xdfff && index >= 2) {
const high = text.charCodeAt(index - 2);
if (high >= 0xd800 && high <= 0xdbff) {
return { codePoint: text.codePointAt(index - 2) as number, size: 2 };
}
}
return { codePoint: low, size: 1 };
};

// How many UTF-16 code units the character ending at `end` occupies, counting a whole emoji
// sequence as one. Backspace deletes that many, so a tap removes the glyph rather than an
// invisible modifier β€” emojibase emits fully-qualified sequences, so most emoji end in U+FE0F.
export const lastGlyphLength = (text: string, end: number): number => {
if (end <= 0) {
return 0;
}
let index = end;
let length = 0;
while (index > 0) {
const { codePoint, size } = codePointBefore(text, index);
index -= size;
length += size;

if (isAttachedToWhatPrecedesIt(codePoint)) {
continue;
}
if (index >= 1 && text.charCodeAt(index - 1) === ZWJ) {
index -= 1;
length += 1;
continue;
}
// Flags are a pair of regional indicators with nothing joining them.
if (isRegionalIndicator(codePoint) && index >= 2) {
const previous = codePointBefore(text, index);
if (isRegionalIndicator(previous.codePoint)) {
length += previous.size;
}
Comment thread
OtavioStasiak marked this conversation as resolved.
}
break;
}
return length;
};
33 changes: 33 additions & 0 deletions app/containers/MessageComposer/hooks/useAutocomplete.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { renderHook, waitFor } from '@testing-library/react-native';

import { useAutocomplete } from './useAutocomplete';
import { type IAutocompleteEmoji } from '../interfaces';

jest.mock('../../../lib/database', () => ({
__esModule: true,
default: { active: { get: jest.fn(() => ({ query: jest.fn(() => ({ fetch: jest.fn().mockResolvedValue([]) })) })) } }
}));
jest.mock('../../../lib/methods/search', () => ({ searchLocal: jest.fn(), searchRemote: jest.fn() }));
jest.mock('../../../lib/services/restApi', () => ({ getCommandPreview: jest.fn(), getListCannedResponse: jest.fn() }));
jest.mock('../../../lib/hooks/usePermissions', () => ({ usePermissions: () => [false, false] }));
jest.mock('../../../lib/methods/helpers/log', () => ({ __esModule: true, default: jest.fn() }));

const names = async (text: string) => {
const { result } = renderHook(() => useAutocomplete({ text, type: ':', rid: 'rid', accessibilityFocusOnInput: () => null }));
await waitFor(() => expect(result.current.some(item => item.type === ':')).toBe(true));
return (result.current as IAutocompleteEmoji[]).map(item => item.emoji);
};

describe('useAutocomplete emoji suggestions', () => {
it('suggests an emoji by its listed shortname', async () => {
expect(await names('ocean')).toContain('ocean');
});

it('suggests an emoji typed by an alias, returning the listed shortname', async () => {
expect(await names('water_wave')).toContain('ocean');
});

it('matches case insensitively, like the emoji picker search', async () => {
expect(await names('Ocean')).toContain('ocean');
});
});
4 changes: 2 additions & 2 deletions app/containers/MessageComposer/hooks/useAutocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import {
type TAutocompleteType
} from '../interfaces';
import { searchLocal, searchRemote, type TSearch } from '../../../lib/methods/search';
import { searchEmojiNames } from '../../../lib/methods/emojis';
import { sanitizeLikeString } from '../../../lib/database/utils';
import database from '../../../lib/database';
import { emojis } from '../../../lib/constants/emojis';
import { type ICustomEmoji } from '../../../definitions';
import { getCommandPreview, getListCannedResponse } from '../../../lib/services/restApi';
import log from '../../../lib/methods/helpers/log';
Expand Down Expand Up @@ -144,7 +144,7 @@ export const useAutocomplete = ({
if (type === ':') {
const customEmojis = await getCustomEmojis(text);
if (ignore) return;
const filteredStandardEmojis = emojis.filter(emoji => emoji.indexOf(text) !== -1).slice(0, MENTIONS_COUNT_TO_DISPLAY);
const filteredStandardEmojis = searchEmojiNames(text).slice(0, MENTIONS_COUNT_TO_DISPLAY);
let mergedEmojis: IAutocompleteEmoji[] = customEmojis.map(emoji => ({
id: emoji.name,
emoji,
Expand Down
10 changes: 5 additions & 5 deletions app/containers/markdown/__snapshots__/Markdown.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -715,7 +715,7 @@ exports[`Story Snapshots: Emoji should match snapshot 1`] = `
}
>

πŸ‘
πŸ‘οΈ
</Text>
</Text>
</Text>
Expand Down Expand Up @@ -1005,7 +1005,7 @@ exports[`Story Snapshots: Emoji should match snapshot 1`] = `
]
}
>
πŸ‘
πŸ‘οΈ
</Text>
<View
style={
Expand Down Expand Up @@ -7821,7 +7821,7 @@ exports[`Story Snapshots: Preview should match snapshot 1`] = `
@rocket.cat @name1 @all @here @unknown #general #unknown
</Text>
<Text
accessibilityLabel="Testing: πŸ˜ƒ πŸ‘ :marioparty:"
accessibilityLabel="Testing: πŸ˜ƒ πŸ‘οΈ :marioparty:"
numberOfLines={1}
style={
[
Expand All @@ -7842,9 +7842,9 @@ exports[`Story Snapshots: Preview should match snapshot 1`] = `
},
]
}
testID="markdown-preview-Testing: πŸ˜ƒ πŸ‘ :marioparty:"
testID="markdown-preview-Testing: πŸ˜ƒ πŸ‘οΈ :marioparty:"
>
Testing: πŸ˜ƒ πŸ‘ :marioparty:
Testing: πŸ˜ƒ πŸ‘οΈ :marioparty:
</Text>
<Text
accessibilityLabel="Fallback from new md to old"
Expand Down
105 changes: 105 additions & 0 deletions app/lib/constants/emojis/data.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { aliasesByEmojiName, emojisByCategory, shortnameToUnicodeMap } from './data';
import { emojis } from './emojis';
import { legacyShortnameToUnicodeMap } from './legacyShortnames';
import pinnedShortnames from '../../../../scripts/pinned-shortnames';

const bare = (unicode: string) => unicode.replace(/\uFE0F/g, '');
const resolve = (name: string) => shortnameToUnicodeMap[`:${name}:`] ?? legacyShortnameToUnicodeMap[`:${name}:`];

// The only seven glyphs that changed against the pre-emojibase map, all repairs of a missing joiner.
const JOINER_REPAIRS: [string, string][] = [
[':kiss_mm:', 'πŸ‘¨\u200D❀\uFE0F\u200DπŸ’‹\u200DπŸ‘¨'],
[':couplekiss_mm:', 'πŸ‘¨\u200D❀\uFE0F\u200DπŸ’‹\u200DπŸ‘¨'],
[':kiss_ww:', 'πŸ‘©\u200D❀\uFE0F\u200DπŸ’‹\u200DπŸ‘©'],
[':couplekiss_ww:', 'πŸ‘©\u200D❀\uFE0F\u200DπŸ’‹\u200DπŸ‘©'],
[':kiss_woman_man:', 'πŸ‘©\u200D❀\uFE0F\u200DπŸ’‹\u200DπŸ‘¨'],
[':men_wrestling:', '🀼\u200Dβ™‚\uFE0F'],
[':women_wrestling:', '🀼\u200D♀\uFE0F']
];

// The pins, spelled out so editing scripts/pinned-shortnames.js fails here and not only in the picker.
const PINNED_GLYPHS: [string, string][] = [
[':beetle:', '🐞'],
[':man_in_tuxedo:', '🀡'],
[':man_in_tuxedo_tone1:', '🀡🏻'],
[':man_in_tuxedo_tone2:', '🀡🏼'],
[':man_in_tuxedo_tone3:', '🀡🏽'],
[':man_in_tuxedo_tone4:', '🀡🏾'],
[':man_in_tuxedo_tone5:', '🀡🏿']
];

const UNLISTED_COMPONENTS = [
...Array.from({ length: 26 }, (_, i) => `regional_indicator_${String.fromCharCode(97 + i)}`),
'digit_zero',
'digit_one',
'digit_two',
'digit_three',
'digit_four',
'digit_five',
'digit_six',
'digit_seven',
'digit_eight',
'digit_nine',
'asterisk_symbol',
'pound_symbol'
];

describe('emoji data', () => {
it('resolves every listed emoji to a unicode character', () => {
const unresolved = emojis.filter(name => !shortnameToUnicodeMap[`:${name}:`]);
expect(unresolved).toEqual([]);
});

it('lists every emoji exactly once', () => {
expect(emojis.length).toBe(new Set(emojis).size);
});

it('keys aliases by a listed emoji name', () => {
const listed = new Set(emojis);
expect(Object.keys(aliasesByEmojiName).filter(name => !listed.has(name))).toEqual([]);
});

it('resolves every alias to the same emoji as the name it belongs to', () => {
Comment thread
OtavioStasiak marked this conversation as resolved.
const mismatched = Object.keys(aliasesByEmojiName).filter(name => {
const expected = bare(shortnameToUnicodeMap[`:${name}:`]);
return aliasesByEmojiName[name].some(alias => bare(resolve(alias) ?? '') !== expected);
});
expect(mismatched).toEqual([]);
});
Comment thread
OtavioStasiak marked this conversation as resolved.

it('has no category with an empty emoji list', () => {
expect(
Object.keys(emojisByCategory).filter(key => emojisByCategory[key as keyof typeof emojisByCategory].length === 0)
).toEqual([]);
});

it('holds every pinned shortname at its pinned glyph', () => {
expect(Object.entries(pinnedShortnames)).toEqual(PINNED_GLYPHS);
const drifted = Object.keys(pinnedShortnames).filter(
shortname => shortnameToUnicodeMap[shortname] !== pinnedShortnames[shortname as keyof typeof pinnedShortnames]
);
expect(drifted).toEqual([]);
});

it('holds every repaired joiner glyph at the value this branch decided on', () => {
const drifted = JOINER_REPAIRS.filter(([shortname, unicode]) => shortnameToUnicodeMap[shortname] !== unicode);
expect(drifted).toEqual([]);
});

it('never puts a variation selector before a skin tone modifier', () => {
const illFormed = [shortnameToUnicodeMap, legacyShortnameToUnicodeMap].flatMap(map =>
Object.keys(map).filter(key => /\uFE0F[\u{1F3FB}-\u{1F3FF}]/u.test(map[key]))
);
expect(illFormed).toEqual([]);
});

it('resolves every unlisted component without listing it', () => {
expect(UNLISTED_COMPONENTS.filter(name => !resolve(name))).toEqual([]);
const listed = new Set(emojis);
expect(UNLISTED_COMPONENTS.filter(name => listed.has(name))).toEqual([]);
});

it('keeps legacy shortnames out of the current map', () => {
expect(Object.keys(legacyShortnameToUnicodeMap).filter(key => key in shortnameToUnicodeMap)).toEqual([]);
});
});
Loading
Loading