diff --git a/app/soapbox/actions/__tests__/soapbox.test.ts b/app/soapbox/actions/__tests__/soapbox.test.ts new file mode 100644 index 000000000..e3dcf9a85 --- /dev/null +++ b/app/soapbox/actions/__tests__/soapbox.test.ts @@ -0,0 +1,19 @@ +import { rootState } from '../../jest/test-helpers'; +import { getSoapboxConfig } from '../soapbox'; + +const ASCII_HEART = '❤'; // '\u2764\uFE0F' +const RED_HEART_RGI = '❤️'; // '\u2764' + +describe('getSoapboxConfig()', () => { + it('returns RGI heart on Pleroma > 2.3', () => { + const state = rootState.setIn(['instance', 'version'], '2.7.2 (compatible; Pleroma 2.3.0)'); + expect(getSoapboxConfig(state).allowedEmoji.includes(RED_HEART_RGI)).toBe(true); + expect(getSoapboxConfig(state).allowedEmoji.includes(ASCII_HEART)).toBe(false); + }); + + it('returns an ASCII heart on Pleroma < 2.3', () => { + const state = rootState.setIn(['instance', 'version'], '2.7.2 (compatible; Pleroma 2.0.0)'); + expect(getSoapboxConfig(state).allowedEmoji.includes(ASCII_HEART)).toBe(true); + expect(getSoapboxConfig(state).allowedEmoji.includes(RED_HEART_RGI)).toBe(false); + }); +}); diff --git a/app/soapbox/actions/soapbox.js b/app/soapbox/actions/soapbox.js index bc3bdc82f..0b18c9bc1 100644 --- a/app/soapbox/actions/soapbox.js +++ b/app/soapbox/actions/soapbox.js @@ -1,9 +1,9 @@ -import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import { createSelector } from 'reselect'; import { getHost } from 'soapbox/actions/instance'; import { normalizeSoapboxConfig } from 'soapbox/normalizers'; import KVStore from 'soapbox/storage/kv_store'; +import { removeVS16s } from 'soapbox/utils/emoji'; import { getFeatures } from 'soapbox/utils/features'; import api, { staticClient } from '../api'; @@ -15,38 +15,24 @@ export const SOAPBOX_CONFIG_REMEMBER_REQUEST = 'SOAPBOX_CONFIG_REMEMBER_REQUEST' export const SOAPBOX_CONFIG_REMEMBER_SUCCESS = 'SOAPBOX_CONFIG_REMEMBER_SUCCESS'; export const SOAPBOX_CONFIG_REMEMBER_FAIL = 'SOAPBOX_CONFIG_REMEMBER_FAIL'; -const allowedEmoji = ImmutableList([ - '👍', - '❤', - '😆', - '😮', - '😢', - '😩', -]); - -// https://git.pleroma.social/pleroma/pleroma/-/issues/2355 -const allowedEmojiRGI = ImmutableList([ - '👍', - '❤️', - '😆', - '😮', - '😢', - '😩', -]); - -export const makeDefaultConfig = features => { - return ImmutableMap({ - allowedEmoji: features.emojiReactsRGI ? allowedEmojiRGI : allowedEmoji, - displayFqn: Boolean(features.federating), - }); -}; - export const getSoapboxConfig = createSelector([ - state => state.get('soapbox'), - state => getFeatures(state.get('instance')), + state => state.soapbox, + state => getFeatures(state.instance), ], (soapbox, features) => { - const defaultConfig = makeDefaultConfig(features); - return normalizeSoapboxConfig(soapbox).merge(defaultConfig); + // Do some additional normalization with the state + return normalizeSoapboxConfig(soapbox).withMutations(soapboxConfig => { + + // If displayFqn isn't set, infer it from federation + if (soapbox.get('displayFqn') === undefined) { + soapboxConfig.set('displayFqn', features.federating); + } + + // If RGI reacts aren't supported, strip VS16s + // // https://git.pleroma.social/pleroma/pleroma/-/issues/2355 + if (!features.emojiReactsRGI) { + soapboxConfig.set('allowedEmoji', soapboxConfig.allowedEmoji.map(removeVS16s)); + } + }); }); export function rememberSoapboxConfig(host) { diff --git a/app/soapbox/components/status_action_bar.tsx b/app/soapbox/components/status_action_bar.tsx index 549c2f4ba..9011bec71 100644 --- a/app/soapbox/components/status_action_bar.tsx +++ b/app/soapbox/components/status_action_bar.tsx @@ -578,9 +578,10 @@ class StatusActionBar extends ImmutablePureComponent { - const vs16RegExp = /\uFE0F/g; - const zeroWidthJoiner = String.fromCharCode(0x200d); - return rawEmoji.indexOf(zeroWidthJoiner) < 0 ? rawEmoji.replace(vs16RegExp, '') : rawEmoji; -}; - -const toCodePoints = (unicodeSurrogates: string): string[] => { - const points = []; - let char = 0; - let previous = 0; - let i = 0; - while (i < unicodeSurrogates.length) { - char = unicodeSurrogates.charCodeAt(i++); - if (previous) { - points.push((0x10000 + ((previous - 0xd800) << 10) + (char - 0xdc00)).toString(16)); - previous = 0; - } else if (char > 0xd800 && char <= 0xdbff) { - previous = char; - } else { - points.push(char.toString(16)); - } - } - return points; -}; - interface IEmoji extends React.ImgHTMLAttributes { emoji: string, } diff --git a/app/soapbox/features/status/components/action-bar.tsx b/app/soapbox/features/status/components/action-bar.tsx index 96d25edd6..1e5efa765 100644 --- a/app/soapbox/features/status/components/action-bar.tsx +++ b/app/soapbox/features/status/components/action-bar.tsx @@ -355,9 +355,10 @@ class ActionBar extends React.PureComponent { '😮': messages.reactionOpenMouth, '😢': messages.reactionCry, '😩': messages.reactionWeary, + '': messages.favourite, }; - const meEmojiTitle = intl.formatMessage(meEmojiReact ? reactMessages[meEmojiReact] : messages.favourite); + const meEmojiTitle = intl.formatMessage(reactMessages[meEmojiReact || ''] || messages.favourite); const menu: Menu = []; diff --git a/app/soapbox/utils/__tests__/emoji.test.ts b/app/soapbox/utils/__tests__/emoji.test.ts new file mode 100644 index 000000000..ab1a4ddaa --- /dev/null +++ b/app/soapbox/utils/__tests__/emoji.test.ts @@ -0,0 +1,39 @@ +import { + removeVS16s, + toCodePoints, +} from '../emoji'; + +const ASCII_HEART = '❤'; // '\u2764\uFE0F' +const RED_HEART_RGI = '❤️'; // '\u2764' +const JOY = '😂'; + +describe('removeVS16s()', () => { + it('removes Variation Selector-16 characters from emoji', () => { + // Sanity check + expect(ASCII_HEART).not.toBe(RED_HEART_RGI); + + // It normalizes an emoji with VS16s + expect(removeVS16s(RED_HEART_RGI)).toBe(ASCII_HEART); + + // Leaves a regular emoji alone + expect(removeVS16s(JOY)).toBe(JOY); + }); +}); + +describe('toCodePoints()', () => { + it('converts a plain emoji', () => { + expect(toCodePoints('😂')).toEqual(['1f602']); + }); + + it('converts a VS16 emoji', () => { + expect(toCodePoints(RED_HEART_RGI)).toEqual(['2764', 'fe0f']); + }); + + it('converts an ASCII character', () => { + expect(toCodePoints(ASCII_HEART)).toEqual(['2764']); + }); + + it('converts a sequence emoji', () => { + expect(toCodePoints('🇺🇸')).toEqual(['1f1fa', '1f1f8']); + }); +}); diff --git a/app/soapbox/utils/emoji.ts b/app/soapbox/utils/emoji.ts new file mode 100644 index 000000000..1d6da69d1 --- /dev/null +++ b/app/soapbox/utils/emoji.ts @@ -0,0 +1,35 @@ +// Taken from twemoji-parser +// https://github.com/twitter/twemoji-parser/blob/a97ef3994e4b88316812926844d51c296e889f76/src/index.js + +/** Remove Variation Selector-16 characters from emoji */ +// https://emojipedia.org/variation-selector-16/ +const removeVS16s = (rawEmoji: string): string => { + const vs16RegExp = /\uFE0F/g; + const zeroWidthJoiner = String.fromCharCode(0x200d); + return rawEmoji.indexOf(zeroWidthJoiner) < 0 ? rawEmoji.replace(vs16RegExp, '') : rawEmoji; +}; + +/** Convert emoji into an array of Unicode codepoints */ +const toCodePoints = (unicodeSurrogates: string): string[] => { + const points = []; + let char = 0; + let previous = 0; + let i = 0; + while (i < unicodeSurrogates.length) { + char = unicodeSurrogates.charCodeAt(i++); + if (previous) { + points.push((0x10000 + ((previous - 0xd800) << 10) + (char - 0xdc00)).toString(16)); + previous = 0; + } else if (char > 0xd800 && char <= 0xdbff) { + previous = char; + } else { + points.push(char.toString(16)); + } + } + return points; +}; + +export { + removeVS16s, + toCodePoints, +}; diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 4f70f9341..93529f21e 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -83,7 +83,7 @@ const getInstanceFeatures = (instance: Instance) => { chats: v.software === PLEROMA && gte(v.version, '2.1.0'), chatsV2: v.software === PLEROMA && gte(v.version, '2.3.0'), scopes: v.software === PLEROMA ? 'read write follow push admin' : 'read write follow push', - federating: federation.get('enabled', true), // Assume true unless explicitly false + federating: federation.get('enabled', true) === true, // Assume true unless explicitly false richText: v.software === PLEROMA, securityAPI: any([ v.software === PLEROMA,