Bluesky app fork with some witchin' additions 💫

Replace `graphemer` with `unicode-segmenter` (#9526)

* replace graphemer with unicode-segmenter

* use grapheme entrypoint

* force resolution of unicode-segmenter

authored by samuel.fm and committed by

GitHub 9743149c 82877a09

+57 -99
+3 -1
package.json
··· 222 222 "tippy.js": "^6.3.7", 223 223 "tlds": "^1.234.0", 224 224 "tldts": "^6.1.46", 225 + "unicode-segmenter": "^0.14.5", 225 226 "zod": "^3.20.2" 226 227 }, 227 228 "devDependencies": { ··· 286 287 "**/expo-constants": "18.0.8", 287 288 "**/expo-device": "7.1.4", 288 289 "**/zod": "3.23.8", 289 - "**/multiformats": "9.9.0" 290 + "**/multiformats": "9.9.0", 291 + "unicode-segmenter": "0.14.5" 290 292 }, 291 293 "jest": { 292 294 "preset": "jest-expo/ios",
+3 -3
src/components/dialogs/lists/CreateOrEditListDialog.tsx
··· 5 5 import {useLingui} from '@lingui/react' 6 6 7 7 import {cleanError} from '#/lib/strings/errors' 8 - import {useWarnMaxGraphemeCount} from '#/lib/strings/helpers' 8 + import {isOverMaxGraphemeCount} from '#/lib/strings/helpers' 9 9 import {richTextToString} from '#/lib/strings/rich-text-helpers' 10 10 import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' 11 11 import {logger} from '#/logger' ··· 259 259 _, 260 260 ]) 261 261 262 - const displayNameTooLong = useWarnMaxGraphemeCount({ 262 + const displayNameTooLong = isOverMaxGraphemeCount({ 263 263 text: displayName, 264 264 maxCount: DISPLAY_NAME_MAX_GRAPHEMES, 265 265 }) 266 - const descriptionTooLong = useWarnMaxGraphemeCount({ 266 + const descriptionTooLong = isOverMaxGraphemeCount({ 267 267 text: descriptionRt, 268 268 maxCount: DESCRIPTION_MAX_GRAPHEMES, 269 269 })
+7 -27
src/lib/strings/helpers.ts
··· 1 - import {useCallback, useMemo} from 'react' 2 1 import {type RichText} from '@atproto/api' 3 - import Graphemer from 'graphemer' 2 + import {countGraphemes} from 'unicode-segmenter/grapheme' 4 3 5 4 import {shortenLinks} from './rich-text-manip' 6 5 ··· 29 28 return str 30 29 } 31 30 32 - export function useEnforceMaxGraphemeCount() { 33 - const splitter = useMemo(() => new Graphemer(), []) 34 - 35 - return useCallback( 36 - (text: string, maxCount: number) => { 37 - if (splitter.countGraphemes(text) > maxCount) { 38 - return splitter.splitGraphemes(text).slice(0, maxCount).join('') 39 - } else { 40 - return text 41 - } 42 - }, 43 - [splitter], 44 - ) 45 - } 46 - 47 - export function useWarnMaxGraphemeCount({ 31 + export function isOverMaxGraphemeCount({ 48 32 text, 49 33 maxCount, 50 34 }: { 51 35 text: string | RichText 52 36 maxCount: number 53 37 }) { 54 - const splitter = useMemo(() => new Graphemer(), []) 55 - 56 - return useMemo(() => { 57 - if (typeof text === 'string') { 58 - return splitter.countGraphemes(text) > maxCount 59 - } else { 60 - return shortenLinks(text).graphemeLength > maxCount 61 - } 62 - }, [splitter, maxCount, text]) 38 + if (typeof text === 'string') { 39 + return countGraphemes(text) > maxCount 40 + } else { 41 + return shortenLinks(text).graphemeLength > maxCount 42 + } 63 43 } 64 44 65 45 export function countLines(str: string | undefined): number {
+2 -2
src/screens/Messages/components/MessageInput.tsx
··· 14 14 import {useSafeAreaInsets} from 'react-native-safe-area-context' 15 15 import {msg} from '@lingui/macro' 16 16 import {useLingui} from '@lingui/react' 17 - import Graphemer from 'graphemer' 17 + import {countGraphemes} from 'unicode-segmenter/grapheme' 18 18 19 19 import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' 20 20 import {useHaptics} from '#/lib/haptics' ··· 75 75 if (!hasEmbed && message.trim() === '') { 76 76 return 77 77 } 78 - if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { 78 + if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { 79 79 Toast.show(_(msg`Message is too long`), 'xmark') 80 80 return 81 81 }
+2 -2
src/screens/Messages/components/MessageInput.web.tsx
··· 2 2 import {Pressable, View} from 'react-native' 3 3 import {msg} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' 5 - import Graphemer from 'graphemer' 6 5 import {flushSync} from 'react-dom' 7 6 import TextareaAutosize from 'react-textarea-autosize' 7 + import {countGraphemes} from 'unicode-segmenter/grapheme' 8 8 9 9 import {isSafari, isTouchDevice} from '#/lib/browser' 10 10 import {MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' ··· 56 56 if (!hasEmbed && message.trim() === '') { 57 57 return 58 58 } 59 - if (new Graphemer().countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { 59 + if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { 60 60 Toast.show(_(msg`Message is too long`), 'xmark') 61 61 return 62 62 }
+3 -3
src/screens/Profile/Header/EditProfileDialog.tsx
··· 6 6 7 7 import {urls} from '#/lib/constants' 8 8 import {cleanError} from '#/lib/strings/errors' 9 - import {useWarnMaxGraphemeCount} from '#/lib/strings/helpers' 9 + import {isOverMaxGraphemeCount} from '#/lib/strings/helpers' 10 10 import {logger} from '#/logger' 11 11 import {type ImageMeta} from '#/state/gallery' 12 12 import {useProfileUpdateMutation} from '#/state/queries/profile' ··· 203 203 _, 204 204 ]) 205 205 206 - const displayNameTooLong = useWarnMaxGraphemeCount({ 206 + const displayNameTooLong = isOverMaxGraphemeCount({ 207 207 text: displayName, 208 208 maxCount: DISPLAY_NAME_MAX_GRAPHEMES, 209 209 }) 210 - const descriptionTooLong = useWarnMaxGraphemeCount({ 210 + const descriptionTooLong = isOverMaxGraphemeCount({ 211 211 text: description, 212 212 maxCount: DESCRIPTION_MAX_GRAPHEMES, 213 213 })
+9 -14
src/screens/Takendown.tsx
··· 1 - import {useMemo, useState} from 'react' 1 + import {useState} from 'react' 2 2 import {View} from 'react-native' 3 3 import {KeyboardAwareScrollView} from 'react-native-keyboard-controller' 4 4 import {useSafeAreaInsets} from 'react-native-safe-area-context' ··· 6 6 import {msg, Trans} from '@lingui/macro' 7 7 import {useLingui} from '@lingui/react' 8 8 import {useMutation} from '@tanstack/react-query' 9 - import Graphemer from 'graphemer' 9 + import {countGraphemes} from 'unicode-segmenter/grapheme' 10 10 11 11 import { 12 12 BLUESKY_MOD_SERVICE_HEADERS, ··· 37 37 const agent = useAgent() 38 38 const [isAppealling, setIsAppealling] = useState(false) 39 39 const [reason, setReason] = useState('') 40 - const graphemer = useMemo(() => new Graphemer(), []) 41 40 42 - const reasonGraphemeLength = useMemo(() => { 43 - return graphemer.countGraphemes(reason) 44 - }, [graphemer, reason]) 41 + const reasonGraphemeLength = countGraphemes(reason) 42 + const isOverMaxLength = 43 + reasonGraphemeLength > MAX_REPORT_REASON_GRAPHEME_LENGTH 45 44 46 45 const { 47 46 mutate: submitAppeal, ··· 72 71 const primaryBtn = 73 72 isAppealling && !isSuccess ? ( 74 73 <Button 75 - variant="solid" 76 74 color="primary" 77 75 size="large" 78 76 label={_(msg`Submit appeal`)} 79 77 onPress={() => submitAppeal(reason)} 80 - disabled={ 81 - isPending || reasonGraphemeLength > MAX_REPORT_REASON_GRAPHEME_LENGTH 82 - }> 78 + disabled={isPending || isOverMaxLength}> 83 79 <ButtonText> 84 80 <Trans>Submit Appeal</Trans> 85 81 </ButtonText> ··· 87 83 </Button> 88 84 ) : ( 89 85 <Button 90 - variant="solid" 91 86 size="large" 92 87 color="secondary_inverted" 93 88 label={_(msg`Sign out`)} ··· 204 199 <Text 205 200 style={[ 206 201 a.text_md, 207 - a.leading_normal, 202 + a.leading_snug, 208 203 {color: t.palette.negative_500}, 209 204 a.mt_lg, 210 205 ]}> ··· 213 208 )} 214 209 </View> 215 210 ) : ( 216 - <P style={[t.atoms.text_contrast_medium]}> 211 + <P style={[t.atoms.text_contrast_medium, a.leading_snug]}> 217 212 <Trans> 218 213 Your account was found to be in violation of the{' '} 219 214 <SimpleInlineLinkText 220 215 label={_(msg`Bluesky Social Terms of Service`)} 221 216 to="https://bsky.social/about/support/tos" 222 - style={[a.text_md, a.leading_normal]}> 217 + style={[a.text_md, a.leading_snug]}> 223 218 Bluesky Social Terms of Service 224 219 </SimpleInlineLinkText> 225 220 . You have been sent an email outlining the specific violation
+2 -2
src/view/com/composer/text-input/TextInput.web.tsx
··· 20 20 import {generateJSON} from '@tiptap/html' 21 21 import {Fragment, Node, Slice} from '@tiptap/pm/model' 22 22 import {EditorContent, type JSONContent, useEditor} from '@tiptap/react' 23 - import Graphemer from 'graphemer' 23 + import {splitGraphemes} from 'unicode-segmenter/grapheme' 24 24 25 25 import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' 26 26 import {blobToDataUri, isUriImage} from '#/lib/media/util' ··· 218 218 // all the lines get mushed together -sfn 219 219 '\n', 220 220 ) 221 - const graphemes = new Graphemer().splitGraphemes(textBefore) 221 + const graphemes = [...splitGraphemes(textBefore)] 222 222 223 223 if (graphemes.length > 0) { 224 224 const lastGrapheme = graphemes[graphemes.length - 1]
-36
src/view/com/composer/text-input/hooks/useGrapheme.tsx
··· 1 - import {useCallback, useMemo} from 'react' 2 - import Graphemer from 'graphemer' 3 - 4 - export const useGrapheme = () => { 5 - const splitter = useMemo(() => new Graphemer(), []) 6 - 7 - const getGraphemeString = useCallback( 8 - (name: string, length: number) => { 9 - let remainingCharacters = 0 10 - 11 - if (name.length > length) { 12 - const graphemes = splitter.splitGraphemes(name) 13 - 14 - if (graphemes.length > length) { 15 - remainingCharacters = 0 16 - name = `${graphemes.slice(0, length).join('')}…` 17 - } else { 18 - remainingCharacters = length - graphemes.length 19 - name = graphemes.join('') 20 - } 21 - } else { 22 - remainingCharacters = length - name.length 23 - } 24 - 25 - return { 26 - name, 27 - remainingCharacters, 28 - } 29 - }, 30 - [splitter], 31 - ) 32 - 33 - return { 34 - getGraphemeString, 35 - } 36 - }
+22 -5
src/view/com/composer/videos/SubtitleDialog.tsx
··· 4 4 import {useLingui} from '@lingui/react' 5 5 6 6 import {MAX_ALT_TEXT} from '#/lib/constants' 7 - import {useEnforceMaxGraphemeCount} from '#/lib/strings/helpers' 7 + import {isOverMaxGraphemeCount} from '#/lib/strings/helpers' 8 8 import {LANGUAGES} from '#/locale/languages' 9 9 import {isWeb} from '#/platform/detection' 10 10 import {useLanguagePrefs} from '#/state/preferences' ··· 72 72 const control = Dialog.useDialogContext() 73 73 const {_} = useLingui() 74 74 const t = useTheme() 75 - const enforceLen = useEnforceMaxGraphemeCount() 76 75 const {primaryLanguage} = useLanguagePrefs() 77 76 78 77 const [altText, setAltText] = useState(defaultAltText) ··· 93 92 ) 94 93 95 94 const subtitleMissingLanguage = captions.some(sub => sub.lang === '') 95 + 96 + const isOverMaxLength = isOverMaxGraphemeCount({ 97 + text: altText, 98 + maxCount: MAX_ALT_TEXT, 99 + }) 96 100 97 101 return ( 98 102 <Dialog.ScrollableInner label={_(msg`Video settings`)}> ··· 100 104 <Text style={[a.text_xl, a.font_semi_bold, a.leading_tight]}> 101 105 <Trans>Alt text</Trans> 102 106 </Text> 103 - <TextField.Root> 107 + <TextField.Root isInvalid={isOverMaxLength}> 104 108 <Dialog.Input 105 109 label={_(msg`Alt text`)} 106 110 placeholder={_(msg`Add alt text (optional)`)} 107 111 value={altText} 108 - onChangeText={evt => setAltText(enforceLen(evt, MAX_ALT_TEXT))} 112 + onChangeText={setAltText} 109 113 maxLength={MAX_ALT_TEXT * 10} 110 114 multiline 111 115 style={{maxHeight: 300}} ··· 118 122 /> 119 123 </TextField.Root> 120 124 125 + {isOverMaxLength && ( 126 + <Text 127 + style={[ 128 + a.text_md, 129 + {color: t.palette.negative_500}, 130 + a.leading_snug, 131 + a.mt_md, 132 + ]}> 133 + <Trans>Alt text must be less than {MAX_ALT_TEXT} characters.</Trans> 134 + </Text> 135 + )} 136 + 121 137 {isWeb && ( 122 138 <> 123 139 <View ··· 173 189 saveAltText(altText) 174 190 control.close() 175 191 }} 176 - style={a.mt_lg}> 192 + style={a.mt_lg} 193 + disabled={isOverMaxLength}> 177 194 <ButtonText> 178 195 <Trans>Done</Trans> 179 196 </ButtonText>
+4 -4
yarn.lock
··· 19158 19158 resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz#43d41e3be698bd493ef911077c9b131f827e8ccd" 19159 19159 integrity sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w== 19160 19160 19161 - unicode-segmenter@^0.14.0: 19162 - version "0.14.0" 19163 - resolved "https://registry.yarnpkg.com/unicode-segmenter/-/unicode-segmenter-0.14.0.tgz#090128182bcc710327a1b7e4af4f5834444eaa61" 19164 - integrity sha512-AH4lhPCJANUnSLEKnM4byboctePJzltF4xj8b+NbNiYeAkAXGh7px2K/4NANFp7dnr6+zB3e6HLu8Jj8SKyvYg== 19161 + unicode-segmenter@0.14.5, unicode-segmenter@^0.14.0, unicode-segmenter@^0.14.5: 19162 + version "0.14.5" 19163 + resolved "https://registry.yarnpkg.com/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz#c658f6dd30de172cdcd94542adc205ba43fb63c6" 19164 + integrity sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g== 19165 19165 19166 19166 unimodules-app-loader@~6.0.8: 19167 19167 version "6.0.8"