Bluesky app fork with some witchin' additions 💫

Merge branch 'main' of https://github.com/bluesky-social/social-app

+270 -127
+1 -1
.eslintrc.js
··· 89 89 'no-unused-vars': 'off', 90 90 '@typescript-eslint/no-unused-vars': [ 91 91 'error', 92 - {argsIgnorePattern: '^_', varsIgnorePattern: '^_'}, 92 + {argsIgnorePattern: '^_', varsIgnorePattern: '^_.+'}, 93 93 ], 94 94 '@typescript-eslint/consistent-type-imports': [ 95 95 'warn',
+2 -2
package.json
··· 246 246 "babel-jest": "^29.7.0", 247 247 "babel-plugin-macros": "^3.1.0", 248 248 "babel-plugin-module-resolver": "^5.0.2", 249 - "babel-plugin-react-compiler": "^19.1.0-rc.1", 249 + "babel-plugin-react-compiler": "^19.1.0-rc.3", 250 250 "babel-preset-expo": "~54.0.0", 251 251 "eslint": "^8.19.0", 252 252 "eslint-plugin-bsky-internal": "link:./eslint", ··· 254 254 "eslint-plugin-import": "^2.31.0", 255 255 "eslint-plugin-lingui": "^0.2.0", 256 256 "eslint-plugin-react": "^7.33.2", 257 - "eslint-plugin-react-compiler": "^19.1.0-rc.1", 257 + "eslint-plugin-react-compiler": "^19.1.0-rc.2", 258 258 "eslint-plugin-react-native-a11y": "^3.3.0", 259 259 "eslint-plugin-simple-import-sort": "^12.0.0", 260 260 "file-loader": "6.2.0",
-1
src/components/PostControls/ShareMenu/RecentChats.tsx
··· 24 24 25 25 export function RecentChats({postUri}: {postUri: string}) { 26 26 const control = useDialogContext() 27 - const {_} = useLingui() 28 27 const {currentAccount} = useSession() 29 28 const {data} = useListConvosQuery({status: 'accepted'}) 30 29 const convos = data?.pages[0]?.convos?.slice(0, 10)
-1
src/components/dialogs/StarterPackDialog.tsx
··· 47 47 targetDid, 48 48 enabled, 49 49 }: StarterPackDialogProps) { 50 - const {_} = useLingui() 51 50 const navigation = useNavigation<NavigationProp>() 52 51 const requireEmailVerification = useRequireEmailVerification() 53 52
+1 -1
src/lib/hooks/useIntentHandler.ts
··· 51 51 } 52 52 53 53 const urlp = new URL(url) 54 - const [_, intent, intentType] = urlp.pathname.split('/') 54 + const [__, intent, intentType] = urlp.pathname.split('/') 55 55 56 56 // On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the 57 57 // intent check. On web, we have to check the first part of the path since we have an actual hostname
+11 -11
src/lib/strings/embed-player.ts
··· 105 105 urlp.hostname === 'm.youtube.com' || 106 106 urlp.hostname === 'music.youtube.com' 107 107 ) { 108 - const [_, page, shortOrLiveVideoId] = urlp.pathname.split('/') 108 + const [__, page, shortOrLiveVideoId] = urlp.pathname.split('/') 109 109 110 110 const isShorts = page === 'shorts' 111 111 const isLive = page === 'live' ··· 137 137 window.location.hostname 138 138 : 'localhost' 139 139 140 - const [_, channelOrVideo, clipOrId, id] = urlp.pathname.split('/') 140 + const [__, channelOrVideo, clipOrId, id] = urlp.pathname.split('/') 141 141 142 142 if (channelOrVideo === 'videos') { 143 143 return { ··· 162 162 163 163 // spotify 164 164 if (urlp.hostname === 'open.spotify.com') { 165 - const [_, typeOrLocale, idOrType, id] = urlp.pathname.split('/') 165 + const [__, typeOrLocale, idOrType, id] = urlp.pathname.split('/') 166 166 167 167 if (idOrType) { 168 168 if (typeOrLocale === 'playlist' || idOrType === 'playlist') { ··· 210 210 urlp.hostname === 'soundcloud.com' || 211 211 urlp.hostname === 'www.soundcloud.com' 212 212 ) { 213 - const [_, user, trackOrSets, set] = urlp.pathname.split('/') 213 + const [__, user, trackOrSets, set] = urlp.pathname.split('/') 214 214 215 215 if (user && trackOrSets) { 216 216 if (trackOrSets === 'sets' && set) { ··· 270 270 } 271 271 272 272 if (urlp.hostname === 'vimeo.com' || urlp.hostname === 'www.vimeo.com') { 273 - const [_, videoId] = urlp.pathname.split('/') 273 + const [__, videoId] = urlp.pathname.split('/') 274 274 if (videoId) { 275 275 return { 276 276 type: 'vimeo_video', ··· 281 281 } 282 282 283 283 if (urlp.hostname === 'giphy.com' || urlp.hostname === 'www.giphy.com') { 284 - const [_, gifs, nameAndId] = urlp.pathname.split('/') 284 + const [__, gifs, nameAndId] = urlp.pathname.split('/') 285 285 286 286 /* 287 287 * nameAndId is a string that consists of the name (dash separated) and the id of the gif (the last part of the name) ··· 309 309 // These can include (presumably) a tracking id in the path name, so we have to check for that as well 310 310 if (giphyRegex.test(urlp.hostname)) { 311 311 // We can link directly to the gif, if its a proper link 312 - const [_, media, trackingOrId, idOrFilename, filename] = 312 + const [__, media, trackingOrId, idOrFilename, filename] = 313 313 urlp.pathname.split('/') 314 314 315 315 if (media === 'media') { ··· 338 338 // Finally, we should see if it is a link to i.giphy.com. These links don't necessarily end in .gif but can also 339 339 // be .webp 340 340 if (urlp.hostname === 'i.giphy.com' || urlp.hostname === 'www.i.giphy.com') { 341 - const [_, mediaOrFilename, filename] = urlp.pathname.split('/') 341 + const [__, mediaOrFilename, filename] = urlp.pathname.split('/') 342 342 343 343 if (mediaOrFilename === 'media' && filename) { 344 344 const gifId = filename.split('.')[0] ··· 389 389 const path_components = urlp.pathname.slice(1, i + 1).split('/') 390 390 if (path_components.length === 4) { 391 391 // discard username - it's not relevant 392 - const [photos, _, albums, id] = path_components 392 + const [photos, __, albums, id] = path_components 393 393 if (photos === 'photos' && albums === 'albums') { 394 394 // this at least has the shape of a valid photo-album URL! 395 395 return { ··· 417 417 // link shortened flickr path 418 418 if (urlp.hostname === 'flic.kr') { 419 419 const b58alph = '123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ' 420 - let [_, type, idBase58Enc] = urlp.pathname.split('/') 420 + let [__, type, idBase58Enc] = urlp.pathname.split('/') 421 421 let id = 0n 422 422 for (const char of idBase58Enc) { 423 423 const nextIdx = b58alph.indexOf(char) ··· 528 528 return {success: false} 529 529 } 530 530 531 - let [_, id, filename] = urlp.pathname.split('/') 531 + let [__, id, filename] = urlp.pathname.split('/') 532 532 533 533 if (!id || !filename) { 534 534 return {success: false}
-17
src/lib/strings/helpers.ts
··· 62 62 }, [splitter, maxCount, text]) 63 63 } 64 64 65 - // https://stackoverflow.com/a/52171480 66 - export function toHashCode(str: string, seed = 0): number { 67 - let h1 = 0xdeadbeef ^ seed, 68 - h2 = 0x41c6ce57 ^ seed 69 - for (let i = 0, ch; i < str.length; i++) { 70 - ch = str.charCodeAt(i) 71 - h1 = Math.imul(h1 ^ ch, 2654435761) 72 - h2 = Math.imul(h2 ^ ch, 1597334677) 73 - } 74 - h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) 75 - h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909) 76 - h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) 77 - h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909) 78 - 79 - return 4294967296 * (2097151 & h2) + (h1 >>> 0) 80 - } 81 - 82 65 export function countLines(str: string | undefined): number { 83 66 if (!str) return 0 84 67 return str.match(/\n/g)?.length ?? 0
+1 -1
src/lib/strings/starter-pack.ts
··· 46 46 } else { 47 47 const url = new URL(uri) 48 48 const parts = url.pathname.split('/') 49 - const [_, path, name, rkey] = parts 49 + const [__, path, name, rkey] = parts 50 50 51 51 if (parts.length !== 4) return null 52 52 if (path !== 'starter-pack' && path !== 'start') return null
-2
src/screens/Onboarding/StepFinished.tsx
··· 69 69 import * as bsky from '#/types/bsky' 70 70 71 71 export function StepFinished() { 72 - const {_} = useLingui() 73 72 const {state, dispatch} = useContext(Context) 74 73 const onboardDispatch = useOnboardingDispatch() 75 74 const [saving, setSaving] = useState(false) ··· 495 494 496 495 function Dot({active}: {active: boolean}) { 497 496 const t = useTheme() 498 - const {_} = useLingui() 499 497 500 498 return ( 501 499 <View
-1
src/screens/Search/Shell.tsx
··· 428 428 const {hasSession} = useSession() 429 429 const {gtTablet} = useBreakpoints() 430 430 const [activeTab, setActiveTab] = useState(0) 431 - const {_} = useLingui() 432 431 433 432 const onPageSelected = useCallback( 434 433 (index: number) => {
-1
src/screens/Search/modules/ExploreTrendingVideos.tsx
··· 31 31 } 32 32 33 33 export function ExploreTrendingVideos() { 34 - const {_} = useLingui() 35 34 const gutters = useGutters([0, 'base']) 36 35 const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS) 37 36
+1 -1
src/state/persisted/schema.ts
··· 71 71 contentLanguages: z.array(z.string()), 72 72 /** 73 73 * The language(s) the user is currently posting in, configured within the 74 - * composer. Multiple languages are psearate by commas. 74 + * composer. Multiple languages are separated by commas. 75 75 * 76 76 * BCP-47 2-letter language code without region. 77 77 */
+4
src/state/preferences/languages.tsx
··· 156 156 return postLanguage.split(',').filter(Boolean) 157 157 } 158 158 159 + export function fromPostLanguages(languages: string[]): string { 160 + return languages.filter(Boolean).join(',') 161 + } 162 + 159 163 export function hasPostLanguage(postLanguage: string, code2: string): boolean { 160 164 return toPostLanguages(postLanguage).includes(code2) 161 165 }
+5 -5
src/state/queries/post-feed.ts
··· 495 495 } 496 496 } 497 497 } else if (feedDesc.startsWith('author')) { 498 - const [_, actor, filter] = feedDesc.split('|') 498 + const [__, actor, filter] = feedDesc.split('|') 499 499 return new AuthorFeedAPI({agent, feedParams: {actor, filter}}) 500 500 } else if (feedDesc.startsWith('likes')) { 501 - const [_, actor] = feedDesc.split('|') 501 + const [__, actor] = feedDesc.split('|') 502 502 return new LikesFeedAPI({agent, feedParams: {actor}}) 503 503 } else if (feedDesc.startsWith('feedgen')) { 504 - const [_, feed] = feedDesc.split('|') 504 + const [__, feed] = feedDesc.split('|') 505 505 return new CustomFeedAPI({ 506 506 agent, 507 507 feedParams: {feed}, 508 508 userInterests, 509 509 }) 510 510 } else if (feedDesc.startsWith('list')) { 511 - const [_, list] = feedDesc.split('|') 511 + const [__, list] = feedDesc.split('|') 512 512 return new ListFeedAPI({agent, feedParams: {list}}) 513 513 } else if (feedDesc.startsWith('posts')) { 514 - const [_, uriList] = feedDesc.split('|') 514 + const [__, uriList] = feedDesc.split('|') 515 515 return new PostListFeedAPI({agent, feedParams: {uris: uriList.split(',')}}) 516 516 } else if (feedDesc === 'demo') { 517 517 return new DemoFeedAPI({agent})
+1 -1
src/state/queries/trending/useGetSuggestedUsersQuery.ts
··· 69 69 queryClient.getQueriesData<AppBskyUnspeccedGetSuggestedUsers.OutputSchema>({ 70 70 queryKey: [getSuggestedUsersQueryKeyRoot], 71 71 }) 72 - for (const [_, response] of responses) { 72 + for (const [_key, response] of responses) { 73 73 if (!response) { 74 74 continue 75 75 }
+55 -6
src/view/com/composer/Composer.tsx
··· 88 88 import {useModalControls} from '#/state/modals' 89 89 import {useRequireAltTextEnabled} from '#/state/preferences' 90 90 import { 91 + fromPostLanguages, 91 92 toPostLanguages, 92 93 useLanguagePrefs, 93 94 useLanguagePrefsApi, ··· 197 198 const [publishingStage, setPublishingStage] = useState('') 198 199 const [error, setError] = useState('') 199 200 201 + /** 202 + * A temporary local reference to a language suggestion that the user has 203 + * accepted. This overrides the global post language preference, but is not 204 + * stored permanently. 205 + */ 206 + const [acceptedLanguageSuggestion, setAcceptedLanguageSuggestion] = useState< 207 + string | null 208 + >(null) 209 + 210 + /** 211 + * The language(s) of the post being replied to. 212 + */ 213 + const [replyToLanguages, setReplyToLanguages] = useState<string[]>( 214 + replyTo?.langs || [], 215 + ) 216 + 217 + /** 218 + * The currently selected languages of the post. Prefer local temporary 219 + * language suggestion over global lang prefs, if available. 220 + */ 221 + const currentLanguages = useMemo( 222 + () => 223 + acceptedLanguageSuggestion 224 + ? [acceptedLanguageSuggestion] 225 + : toPostLanguages(langPrefs.postLanguage), 226 + [acceptedLanguageSuggestion, langPrefs.postLanguage], 227 + ) 228 + 229 + /** 230 + * When the user selects a language from the composer language selector, 231 + * clear any temporary language suggestions they may have selected 232 + * previously, and any we might try to suggest to them. 233 + */ 234 + const onSelectLanguage = () => { 235 + setAcceptedLanguageSuggestion(null) 236 + setReplyToLanguages([]) 237 + } 238 + 200 239 const [composerState, composerDispatch] = useReducer( 201 240 composerReducer, 202 241 { ··· 414 453 thread, 415 454 replyTo: replyTo?.uri, 416 455 onStateChange: setPublishingStage, 417 - langs: toPostLanguages(langPrefs.postLanguage), 456 + langs: currentLanguages, 418 457 }) 419 458 ).uris[0] 420 459 ··· 490 529 isPartOfThread: thread.posts.length > 1, 491 530 hasLink: !!post.embed.link, 492 531 hasQuote: !!post.embed.quote, 493 - langs: langPrefs.postLanguage, 532 + langs: fromPostLanguages(currentLanguages), 494 533 logContext: 'Composer', 495 534 }) 496 535 index++ ··· 557 596 thread, 558 597 canPost, 559 598 isPublishing, 560 - langPrefs.postLanguage, 599 + currentLanguages, 561 600 onClose, 562 601 onPost, 563 602 onPostSuccess, ··· 654 693 <> 655 694 <SuggestedLanguage 656 695 text={activePost.richtext.text} 657 - // NOTE(@elijaharita): currently just choosing the first language if any exists 658 - replyToLanguage={replyTo?.langs?.[0]} 696 + replyToLanguages={replyToLanguages} 697 + currentLanguages={currentLanguages} 698 + onAcceptSuggestedLanguage={setAcceptedLanguageSuggestion} 659 699 /> 660 700 <ComposerPills 661 701 isReply={!!replyTo} ··· 678 718 type: 'add_post', 679 719 }) 680 720 }} 721 + currentLanguages={currentLanguages} 722 + onSelectLanguage={onSelectLanguage} 681 723 /> 682 724 </> 683 725 ) ··· 1289 1331 onEmojiButtonPress, 1290 1332 onSelectVideo, 1291 1333 onAddPost, 1334 + currentLanguages, 1335 + onSelectLanguage, 1292 1336 }: { 1293 1337 post: PostDraft 1294 1338 dispatch: (action: PostAction) => void ··· 1297 1341 onError: (error: string) => void 1298 1342 onSelectVideo: (postId: string, asset: ImagePickerAsset) => void 1299 1343 onAddPost: () => void 1344 + currentLanguages: string[] 1345 + onSelectLanguage?: (language: string) => void 1300 1346 }) { 1301 1347 const t = useTheme() 1302 1348 const {_} = useLingui() ··· 1450 1496 <PlusIcon size="lg" /> 1451 1497 </Button> 1452 1498 )} 1453 - <PostLanguageSelect /> 1499 + <PostLanguageSelect 1500 + currentLanguages={currentLanguages} 1501 + onSelectLanguage={onSelectLanguage} 1502 + /> 1454 1503 <CharProgress 1455 1504 count={post.shortenedGraphemeLength} 1456 1505 style={{width: 65}}
-1
src/view/com/composer/photos/EditImageDialog.web.tsx
··· 116 116 }) { 117 117 const t = useTheme() 118 118 const [isDragging, setIsDragging] = useState(false) 119 - const {_} = useLingui() 120 119 const control = Dialog.useDialogContext() 121 120 122 121 const source = image.source
+35 -9
src/view/com/composer/select-language/PostLanguageSelect.tsx
··· 17 17 import {Text} from '#/components/Typography' 18 18 import {PostLanguageSelectDialog} from './PostLanguageSelectDialog' 19 19 20 - export function PostLanguageSelect() { 20 + export function PostLanguageSelect({ 21 + currentLanguages: currentLanguagesProp, 22 + onSelectLanguage, 23 + }: { 24 + currentLanguages?: string[] 25 + onSelectLanguage?: (language: string) => void 26 + }) { 21 27 const {_} = useLingui() 22 28 const langPrefs = useLanguagePrefs() 23 29 const setLangPrefs = useLanguagePrefsApi() ··· 26 32 const dedupedHistory = Array.from( 27 33 new Set([...langPrefs.postLanguageHistory, langPrefs.postLanguage]), 28 34 ) 35 + 36 + const currentLanguages = 37 + currentLanguagesProp ?? toPostLanguages(langPrefs.postLanguage) 29 38 30 39 if ( 31 40 dedupedHistory.length === 1 && ··· 34 43 return ( 35 44 <> 36 45 <LanguageBtn onPress={languageDialogControl.open} /> 37 - <PostLanguageSelectDialog control={languageDialogControl} /> 46 + <PostLanguageSelectDialog 47 + control={languageDialogControl} 48 + currentLanguages={currentLanguages} 49 + /> 38 50 </> 39 51 ) 40 52 } ··· 43 55 <> 44 56 <Menu.Root> 45 57 <Menu.Trigger label={_(msg`Select post language`)}> 46 - {({props}) => <LanguageBtn {...props} />} 58 + {({props}) => ( 59 + <LanguageBtn currentLanguages={currentLanguages} {...props} /> 60 + )} 47 61 </Menu.Trigger> 48 62 <Menu.Outer> 49 63 <Menu.Group> ··· 56 70 <Menu.Item 57 71 key={historyItem} 58 72 label={_(msg`Select ${langName}`)} 59 - onPress={() => setLangPrefs.setPostLanguage(historyItem)}> 73 + onPress={() => { 74 + setLangPrefs.setPostLanguage(historyItem) 75 + onSelectLanguage?.(historyItem) 76 + }}> 60 77 <Menu.ItemText>{langName}</Menu.ItemText> 61 78 <Menu.ItemRadio 62 - selected={historyItem === langPrefs.postLanguage} 79 + selected={currentLanguages.includes(historyItem)} 63 80 /> 64 81 </Menu.Item> 65 82 ) ··· 77 94 </Menu.Outer> 78 95 </Menu.Root> 79 96 80 - <PostLanguageSelectDialog control={languageDialogControl} /> 97 + <PostLanguageSelectDialog 98 + control={languageDialogControl} 99 + currentLanguages={currentLanguages} 100 + onSelectLanguage={onSelectLanguage} 101 + /> 81 102 </> 82 103 ) 83 104 } 84 105 85 - function LanguageBtn(props: Omit<ButtonProps, 'label' | 'children'>) { 106 + function LanguageBtn( 107 + props: Omit<ButtonProps, 'label' | 'children'> & { 108 + currentLanguages?: string[] 109 + }, 110 + ) { 86 111 const {_} = useLingui() 87 112 const langPrefs = useLanguagePrefs() 88 113 const t = useTheme() 89 114 90 115 const postLanguagesPref = toPostLanguages(langPrefs.postLanguage) 116 + const currentLanguages = props.currentLanguages ?? postLanguagesPref 91 117 92 118 return ( 93 119 <Button ··· 106 132 {({pressed, hovered}) => { 107 133 const color = 108 134 pressed || hovered ? t.palette.primary_300 : t.palette.primary_500 109 - if (postLanguagesPref.length > 0) { 135 + if (currentLanguages.length > 0) { 110 136 return ( 111 137 <Text 112 138 style={[ ··· 117 143 {maxWidth: 100}, 118 144 ]} 119 145 numberOfLines={1}> 120 - {postLanguagesPref 146 + {currentLanguages 121 147 .map(lang => codeToLanguageName(lang, langPrefs.appLanguage)) 122 148 .join(', ')} 123 149 </Text>
+25 -3
src/view/com/composer/select-language/PostLanguageSelectDialog.tsx
··· 8 8 import {type Language, LANGUAGES, LANGUAGES_MAP_CODE2} from '#/locale/languages' 9 9 import {isNative, isWeb} from '#/platform/detection' 10 10 import { 11 + toPostLanguages, 11 12 useLanguagePrefs, 12 13 useLanguagePrefsApi, 13 14 } from '#/state/preferences/languages' ··· 23 24 24 25 export function PostLanguageSelectDialog({ 25 26 control, 27 + /** 28 + * Optionally can be passed to show different values than what is saved in 29 + * langPrefs. 30 + */ 31 + currentLanguages, 32 + onSelectLanguage, 26 33 }: { 27 34 control: Dialog.DialogControlProps 35 + currentLanguages?: string[] 36 + onSelectLanguage?: (language: string) => void 28 37 }) { 29 38 const {height} = useWindowDimensions() 30 39 const insets = useSafeAreaInsets() ··· 40 49 nativeOptions={{minHeight: height - insets.top}}> 41 50 <Dialog.Handle /> 42 51 <ErrorBoundary renderError={renderErrorBoundary}> 43 - <DialogInner /> 52 + <DialogInner 53 + currentLanguages={currentLanguages} 54 + onSelectLanguage={onSelectLanguage} 55 + /> 44 56 </ErrorBoundary> 45 57 </Dialog.Outer> 46 58 ) 47 59 } 48 60 49 - export function DialogInner() { 61 + export function DialogInner({ 62 + currentLanguages, 63 + onSelectLanguage, 64 + }: { 65 + currentLanguages?: string[] 66 + onSelectLanguage?: (language: string) => void 67 + }) { 50 68 const control = Dialog.useDialogContext() 51 69 const [headerHeight, setHeaderHeight] = useState(0) 52 70 ··· 63 81 }, []) 64 82 65 83 const langPrefs = useLanguagePrefs() 84 + const postLanguagesPref = 85 + currentLanguages ?? toPostLanguages(langPrefs.postLanguage) 86 + 66 87 const [checkedLanguagesCode2, setCheckedLanguagesCode2] = useState<string[]>( 67 - langPrefs.postLanguage.split(',') || [langPrefs.primaryLanguage], 88 + postLanguagesPref || [langPrefs.primaryLanguage], 68 89 ) 69 90 const [search, setSearch] = useState('') 70 91 ··· 79 100 langsString = langPrefs.primaryLanguage 80 101 } 81 102 setLangPrefs.setPostLanguage(langsString) 103 + onSelectLanguage?.(langsString) 82 104 }) 83 105 } 84 106
+121 -44
src/view/com/composer/select-language/SuggestedLanguage.tsx
··· 1 1 import {useEffect, useState} from 'react' 2 - import {View} from 'react-native' 2 + import {Text as RNText, View} from 'react-native' 3 3 import {parseLanguage} from '@atproto/api' 4 4 import {msg, Trans} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' 6 6 import lande from 'lande' 7 7 8 8 import {code3ToCode2Strict, codeToLanguageName} from '#/locale/helpers' 9 - import { 10 - toPostLanguages, 11 - useLanguagePrefs, 12 - useLanguagePrefsApi, 13 - } from '#/state/preferences/languages' 9 + import {useLanguagePrefs} from '#/state/preferences/languages' 14 10 import {atoms as a, useTheme} from '#/alf' 15 11 import {Button, ButtonText} from '#/components/Button' 16 12 import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe' ··· 22 18 23 19 export function SuggestedLanguage({ 24 20 text, 25 - replyToLanguage: replyToLanguageProp, 21 + replyToLanguages: replyToLanguagesProp, 22 + currentLanguages, 23 + onAcceptSuggestedLanguage, 26 24 }: { 27 25 text: string 28 - replyToLanguage?: string 26 + /** 27 + * All languages associated with the post being replied to. 28 + */ 29 + replyToLanguages: string[] 30 + /** 31 + * All languages currently selected for the post being composed. 32 + */ 33 + currentLanguages: string[] 34 + /** 35 + * Called when the user accepts a suggested language. We only pass a single 36 + * language here. If the post being replied to has multiple languages, we 37 + * only suggest the first one. 38 + */ 39 + onAcceptSuggestedLanguage: (language: string | null) => void 29 40 }) { 30 - const replyToLanguage = cleanUpLanguage(replyToLanguageProp) 41 + const langPrefs = useLanguagePrefs() 42 + const replyToLanguages = replyToLanguagesProp 43 + .map(lang => cleanUpLanguage(lang)) 44 + .filter(Boolean) as string[] 45 + const [hasInteracted, setHasInteracted] = useState(false) 31 46 const [suggestedLanguage, setSuggestedLanguage] = useState< 32 47 string | undefined 33 - >(text.length === 0 ? replyToLanguage : undefined) 34 - const langPrefs = useLanguagePrefs() 35 - const setLangPrefs = useLanguagePrefsApi() 36 - const t = useTheme() 37 - const {_} = useLingui() 48 + >(undefined) 38 49 39 50 useEffect(() => { 40 - // For replies, suggest the language of the post being replied to if no text 41 - // has been typed yet 42 - if (replyToLanguage && text.length === 0) { 43 - setSuggestedLanguage(replyToLanguage) 44 - return 51 + if (text.length > 0 && !hasInteracted) { 52 + setHasInteracted(true) 45 53 } 54 + }, [text, hasInteracted]) 46 55 56 + useEffect(() => { 47 57 const textTrimmed = text.trim() 48 58 49 59 // Don't run the language model on small posts, the results are likely ··· 58 68 }) 59 69 60 70 return () => cancelIdle(idle) 61 - }, [text, replyToLanguage]) 71 + }, [text]) 72 + 73 + /* 74 + * We've detected a language, and the user hasn't already selected it. 75 + */ 76 + const hasLanguageSuggestion = 77 + suggestedLanguage && !currentLanguages.includes(suggestedLanguage) 78 + /* 79 + * We have not detected a different language, and the user is not already 80 + * using or has not already selected one of the languages of the post they 81 + * are replying to. 82 + */ 83 + const hasSuggestedReplyLanguage = 84 + !hasInteracted && 85 + !suggestedLanguage && 86 + replyToLanguages.length && 87 + !replyToLanguages.some(l => currentLanguages.includes(l)) 62 88 63 - if ( 64 - suggestedLanguage && 65 - !toPostLanguages(langPrefs.postLanguage).includes(suggestedLanguage) 66 - ) { 89 + if (hasLanguageSuggestion) { 67 90 const suggestedLanguageName = codeToLanguageName( 68 91 suggestedLanguage, 69 92 langPrefs.appLanguage, 70 93 ) 71 94 72 95 return ( 96 + <LanguageSuggestionButton 97 + label={ 98 + <RNText> 99 + <Trans> 100 + Are you writing in{' '} 101 + <Text style={[a.font_bold]}>{suggestedLanguageName}</Text>? 102 + </Trans> 103 + </RNText> 104 + } 105 + value={suggestedLanguage} 106 + onAccept={onAcceptSuggestedLanguage} 107 + /> 108 + ) 109 + } else if (hasSuggestedReplyLanguage) { 110 + const suggestedLanguageName = codeToLanguageName( 111 + replyToLanguages[0], 112 + langPrefs.appLanguage, 113 + ) 114 + 115 + return ( 116 + <LanguageSuggestionButton 117 + label={ 118 + <RNText> 119 + <Trans> 120 + The post you're replying to was marked as being written in{' '} 121 + {suggestedLanguageName} by its author. Would you like to reply in{' '} 122 + <Text style={[a.font_bold]}>{suggestedLanguageName}</Text>? 123 + </Trans> 124 + </RNText> 125 + } 126 + value={replyToLanguages[0]} 127 + onAccept={onAcceptSuggestedLanguage} 128 + /> 129 + ) 130 + } else { 131 + return null 132 + } 133 + } 134 + 135 + function LanguageSuggestionButton({ 136 + label, 137 + value, 138 + onAccept, 139 + }: { 140 + label: React.ReactNode 141 + value: string 142 + onAccept: (language: string | null) => void 143 + }) { 144 + const t = useTheme() 145 + const {_} = useLingui() 146 + 147 + return ( 148 + <View style={[a.px_lg, a.py_sm]}> 73 149 <View 74 150 style={[ 75 - t.atoms.border_contrast_low, 76 - a.gap_sm, 151 + a.gap_md, 77 152 a.border, 78 153 a.flex_row, 79 154 a.align_center, 80 155 a.rounded_sm, 81 - a.px_lg, 82 - a.py_md, 83 - a.mx_md, 84 - a.my_sm, 156 + a.p_md, 157 + a.pl_lg, 85 158 t.atoms.bg, 159 + t.atoms.border_contrast_low, 86 160 ]}> 87 161 <EarthIcon /> 88 - <Text style={[a.flex_1]}> 89 - <Trans> 90 - Are you writing in{' '} 91 - <Text style={[a.font_semi_bold]}>{suggestedLanguageName}</Text>? 92 - </Trans> 93 - </Text> 162 + <View style={[a.flex_1]}> 163 + <Text 164 + style={[ 165 + a.flex_1, 166 + a.leading_snug, 167 + { 168 + maxWidth: 400, 169 + }, 170 + ]}> 171 + {label} 172 + </Text> 173 + </View> 94 174 95 175 <Button 96 - color="secondary" 97 176 size="small" 98 - variant="solid" 99 - onPress={() => setLangPrefs.setPostLanguage(suggestedLanguage)} 100 - label={_(msg`Change post language to ${suggestedLanguageName}`)}> 177 + color="secondary" 178 + onPress={() => onAccept(value)} 179 + label={_(msg`Accept this language suggestion`)}> 101 180 <ButtonText> 102 181 <Trans>Yes</Trans> 103 182 </ButtonText> 104 183 </Button> 105 184 </View> 106 - ) 107 - } else { 108 - return null 109 - } 185 + </View> 186 + ) 110 187 } 111 188 112 189 /**
+1 -1
src/view/com/composer/text-input/web/TagDecorator.ts
··· 30 30 31 31 let match 32 32 while ((match = regex.exec(textContent))) { 33 - const [matchedString, _, tag] = match 33 + const [matchedString, __, tag] = match 34 34 35 35 if (!tag || tag.replace(TRAILING_PUNCTUATION_REGEX, '').length > 64) 36 36 continue
+1 -1
src/view/com/posts/PostFeedErrorMessage.tsx
··· 126 126 })[knownError], 127 127 [_l, knownError], 128 128 ) 129 - const [_, uri] = feedDesc.split('|') 129 + const [__, uri] = feedDesc.split('|') 130 130 const [ownerDid] = safeParseFeedgenUri(uri) 131 131 const removePromptControl = Prompt.usePromptControl() 132 132 const {mutateAsync: removeFeed} = useRemoveFeedMutation()
-2
src/view/screens/ModerationMutedAccounts.tsx
··· 2 2 import {type StyleProp, View, type ViewStyle} from 'react-native' 3 3 import {type AppBskyActorDefs as ActorDefs} from '@atproto/api' 4 4 import {Trans} from '@lingui/macro' 5 - import {useLingui} from '@lingui/react' 6 5 import {useFocusEffect} from '@react-navigation/native' 7 6 import {type NativeStackScreenProps} from '@react-navigation/native-stack' 8 7 ··· 27 26 export function ModerationMutedAccounts({}: Props) { 28 27 const t = useTheme() 29 28 const moderationOpts = useModerationOpts() 30 - const {_} = useLingui() 31 29 const setMinimalShellMode = useSetMinimalShellMode() 32 30 33 31 const [isPTRing, setIsPTRing] = useState(false)
-2
src/view/screens/ProfileFeedLikedBy.tsx
··· 1 1 import {useCallback} from 'react' 2 2 import {Trans} from '@lingui/macro' 3 - import {useLingui} from '@lingui/react' 4 3 import {useFocusEffect} from '@react-navigation/native' 5 4 6 5 import { ··· 17 16 const setMinimalShellMode = useSetMinimalShellMode() 18 17 const {name, rkey} = route.params 19 18 const uri = makeRecordUri(name, 'app.bsky.feed.generator', rkey) 20 - const {_} = useLingui() 21 19 22 20 useFocusEffect( 23 21 useCallback(() => {
+5 -12
yarn.lock
··· 8603 8603 dependencies: 8604 8604 "@babel/helper-define-polyfill-provider" "^0.6.3" 8605 8605 8606 - babel-plugin-react-compiler@^19.1.0-rc.1: 8607 - version "19.1.0-rc.1" 8608 - resolved "https://registry.yarnpkg.com/babel-plugin-react-compiler/-/babel-plugin-react-compiler-19.1.0-rc.1.tgz#99d131be61017e40abbaedd98321069bf8b7e54a" 8609 - integrity sha512-M4fpG+Hfq5gWzsJeeMErdRokzg0fdJ8IAk+JDhfB/WLT+U3WwJWR8edphypJrk447/JEvYu6DBFwsTn10bMW4Q== 8610 - dependencies: 8611 - "@babel/types" "^7.26.0" 8612 - 8613 - babel-plugin-react-compiler@^19.1.0-rc.2: 8606 + babel-plugin-react-compiler@^19.1.0-rc.2, babel-plugin-react-compiler@^19.1.0-rc.3: 8614 8607 version "19.1.0-rc.3" 8615 8608 resolved "https://registry.yarnpkg.com/babel-plugin-react-compiler/-/babel-plugin-react-compiler-19.1.0-rc.3.tgz#45e5a282a2460b3701971e5eb8310a90a7919022" 8616 8609 integrity sha512-mjRn69WuTz4adL0bXGx8Rsyk1086zFJeKmes6aK0xPuK3aaXmDJdLHqwKKMrpm6KAI1MCoUK72d2VeqQbu8YIA== ··· 10824 10817 dependencies: 10825 10818 "@typescript-eslint/utils" "^5.61.0" 10826 10819 10827 - eslint-plugin-react-compiler@^19.1.0-rc.1: 10828 - version "19.1.0-rc.1" 10829 - resolved "https://registry.yarnpkg.com/eslint-plugin-react-compiler/-/eslint-plugin-react-compiler-19.1.0-rc.1.tgz#e974ba9541c9a4464d77723e0505b5742bc22e56" 10830 - integrity sha512-3umw5eqZXapBl7aQGmvcjheKhUbsElb9jTETxRZg371e1LG4EPs/zCHt2JzP+wNcdaZWzjU/R730zPUJblY2zw== 10820 + eslint-plugin-react-compiler@^19.1.0-rc.2: 10821 + version "19.1.0-rc.2" 10822 + resolved "https://registry.yarnpkg.com/eslint-plugin-react-compiler/-/eslint-plugin-react-compiler-19.1.0-rc.2.tgz#83343e7422e00fa61e729af8e8468f0ddec37925" 10823 + integrity sha512-oKalwDGcD+RX9mf3NEO4zOoUMeLvjSvcbbEOpquzmzqEEM2MQdp7/FY/Hx9NzmUwFzH1W9SKTz5fihfMldpEYw== 10831 10824 dependencies: 10832 10825 "@babel/core" "^7.24.4" 10833 10826 "@babel/parser" "^7.24.4"