From 88bed9eaf6eb7b23d712e8bc5cd1fe807e73a6e0 Mon Sep 17 00:00:00 2001 From: absidue <48293849+absidue@users.noreply.github.com> Date: Sat, 27 Apr 2024 04:53:03 +0200 Subject: [PATCH] Fix handling of emojis with ZWJ sequences in profile initials (#5023) --- .../ft-profile-bubble/ft-profile-bubble.js | 8 +++++- .../ft-profile-edit/ft-profile-edit.js | 8 +++++- .../ft-profile-selector.js | 13 +++++++-- .../ft-subscribe-button.js | 8 +++++- src/renderer/helpers/strings.js | 28 +++++++++++++++++++ 5 files changed, 59 insertions(+), 6 deletions(-) diff --git a/src/renderer/components/ft-profile-bubble/ft-profile-bubble.js b/src/renderer/components/ft-profile-bubble/ft-profile-bubble.js index 682057bf8..39d4e226e 100644 --- a/src/renderer/components/ft-profile-bubble/ft-profile-bubble.js +++ b/src/renderer/components/ft-profile-bubble/ft-profile-bubble.js @@ -1,6 +1,7 @@ import { defineComponent } from 'vue' import { sanitizeForHtmlId } from '../../helpers/accessibility' import { MAIN_PROFILE_ID } from '../../../constants' +import { getFirstCharacter } from '../../helpers/strings' export default defineComponent({ name: 'FtProfileBubble', @@ -24,6 +25,9 @@ export default defineComponent({ }, emits: ['click'], computed: { + locale: function () { + return this.$i18n.locale.replace('_', '-') + }, isMainProfile: function () { return this.profileId === MAIN_PROFILE_ID }, @@ -31,7 +35,9 @@ export default defineComponent({ return 'profileBubble' + sanitizeForHtmlId(this.profileId) }, profileInitial: function () { - return this?.profileName?.length > 0 ? Array.from(this.translatedProfileName)[0].toUpperCase() : '' + return this.profileName + ? getFirstCharacter(this.translatedProfileName, this.locale).toUpperCase() + : '' }, translatedProfileName: function () { return this.isMainProfile ? this.$t('Profile.All Channels') : this.profileName diff --git a/src/renderer/components/ft-profile-edit/ft-profile-edit.js b/src/renderer/components/ft-profile-edit/ft-profile-edit.js index f89b449f1..240501a0f 100644 --- a/src/renderer/components/ft-profile-edit/ft-profile-edit.js +++ b/src/renderer/components/ft-profile-edit/ft-profile-edit.js @@ -8,6 +8,7 @@ import FtButton from '../../components/ft-button/ft-button.vue' import { MAIN_PROFILE_ID } from '../../../constants' import { calculateColorLuminance, colors } from '../../helpers/colors' import { showToast } from '../../helpers/utils' +import { getFirstCharacter } from '../../helpers/strings' export default defineComponent({ name: 'FtProfileEdit', @@ -47,11 +48,16 @@ export default defineComponent({ } }, computed: { + locale: function () { + return this.$i18n.locale.replace('_', '-') + }, colorValues: function () { return colors.map(color => color.value) }, profileInitial: function () { - return this?.profileName?.length > 0 ? Array.from(this.translatedProfileName)[0].toUpperCase() : '' + return this.profileName + ? getFirstCharacter(this.translatedProfileName, this.locale).toUpperCase() + : '' }, activeProfile: function () { return this.$store.getters.getActiveProfile diff --git a/src/renderer/components/ft-profile-selector/ft-profile-selector.js b/src/renderer/components/ft-profile-selector/ft-profile-selector.js index 6026abd06..3dc775de4 100644 --- a/src/renderer/components/ft-profile-selector/ft-profile-selector.js +++ b/src/renderer/components/ft-profile-selector/ft-profile-selector.js @@ -5,6 +5,7 @@ import FtCard from '../../components/ft-card/ft-card.vue' import FtIconButton from '../../components/ft-icon-button/ft-icon-button.vue' import { showToast } from '../../helpers/utils' import { MAIN_PROFILE_ID } from '../../../constants' +import { getFirstCharacter } from '../../helpers/strings' export default defineComponent({ name: 'FtProfileSelector', @@ -19,6 +20,9 @@ export default defineComponent({ } }, computed: { + locale: function () { + return this.$i18n.locale.replace('_', '-') + }, profileList: function () { return this.$store.getters.getProfileList }, @@ -26,12 +30,15 @@ export default defineComponent({ return this.$store.getters.getActiveProfile }, activeProfileInitial: function () { - // use Array.from, so that emojis don't get split up into individual character codes - return this.activeProfile?.name?.length > 0 ? Array.from(this.translatedProfileName(this.activeProfile))[0].toUpperCase() : '' + return this.activeProfile?.name + ? getFirstCharacter(this.translatedProfileName(this.activeProfile), this.locale).toUpperCase() + : '' }, profileInitials: function () { return this.profileList.map((profile) => { - return profile?.name?.length > 0 ? Array.from(this.translatedProfileName(profile))[0].toUpperCase() : '' + return profile?.name + ? getFirstCharacter(this.translatedProfileName(profile), this.locale).toUpperCase() + : '' }) } }, diff --git a/src/renderer/components/ft-subscribe-button/ft-subscribe-button.js b/src/renderer/components/ft-subscribe-button/ft-subscribe-button.js index 806310857..13f5c65bf 100644 --- a/src/renderer/components/ft-subscribe-button/ft-subscribe-button.js +++ b/src/renderer/components/ft-subscribe-button/ft-subscribe-button.js @@ -6,6 +6,7 @@ import FtButton from '../../components/ft-button/ft-button.vue' import { MAIN_PROFILE_ID } from '../../../constants' import { deepCopy, showToast } from '../../helpers/utils' +import { getFirstCharacter } from '../../helpers/strings' export default defineComponent({ name: 'FtSubscribeButton', @@ -45,9 +46,14 @@ export default defineComponent({ } }, computed: { + locale: function () { + return this.$i18n.locale.replace('_', '-') + }, profileInitials: function () { return this.profileDisplayList.map((profile) => { - return profile?.name?.length > 0 ? Array.from(profile.name)[0].toUpperCase() : '' + return profile.name + ? getFirstCharacter(profile.name, this.locale).toUpperCase() + : '' }) }, diff --git a/src/renderer/helpers/strings.js b/src/renderer/helpers/strings.js index 255630484..97a5d6d23 100644 --- a/src/renderer/helpers/strings.js +++ b/src/renderer/helpers/strings.js @@ -52,3 +52,31 @@ export function translateWindowTitle(title, i18n) { return null } } + +/** + * Returns the first user-perceived character, + * respecting language specific rules and + * emojis made up of multiple codepoints + * like flags, families and skin tone modifiers. + * @param {string} text + * @param {string} locale + * @returns {string} + */ +export function getFirstCharacter(text, locale) { + if (text.length === 0) { + return '' + } + + // Firefox only received support for Intl.Segmenter support in version 125 (2024-04-16) + // so fallback to Array.from just in case. + // TODO: Remove fallback in the future + if (Intl.Segmenter) { + const segmenter = new Intl.Segmenter([locale, 'en'], { granularity: 'grapheme' }) + + // Use iterator directly as we only need the first segment + const firstSegment = segmenter.segment(text)[Symbol.iterator]().next().value + return firstSegment.segment + } else { + return Array.from(text)[0] + } +}