diff --git a/src/view/com/composer/select-language/SuggestedLanguage.tsx b/src/view/com/composer/select-language/SuggestedLanguage.tsx index ab731cda2..04fad82a5 100644 --- a/src/view/com/composer/select-language/SuggestedLanguage.tsx +++ b/src/view/com/composer/select-language/SuggestedLanguage.tsx @@ -2,10 +2,6 @@ import {useEffect, useMemo, useRef, useState} from 'react' import {Platform, Text as RNText, View} from 'react-native' import {RichText} from '@atproto/api' import {parseLanguageString} from '@atproto/syntax' -import { - guessLanguageAsync, - type LanguageResult, -} from '@bsky.app/expo-guess-language' import {Trans, useLingui} from '@lingui/react/macro' import debounce from 'lodash.debounce' @@ -22,6 +18,7 @@ import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Ti import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import {IS_WEB} from '#/env' +import {guessLanguageAsync, type LanguageResult} from './languageDetection' type LanguageDetectionPerLanguageConfig = { acceptanceThreshold?: number diff --git a/src/view/com/composer/select-language/languageDetection.ts b/src/view/com/composer/select-language/languageDetection.ts new file mode 100644 index 000000000..6b993c1a2 --- /dev/null +++ b/src/view/com/composer/select-language/languageDetection.ts @@ -0,0 +1,4 @@ +export { + guessLanguageAsync, + type LanguageResult, +} from '@bsky.app/expo-guess-language' diff --git a/src/view/com/composer/select-language/languageDetection.web.ts b/src/view/com/composer/select-language/languageDetection.web.ts new file mode 100644 index 000000000..1a21fe8bd --- /dev/null +++ b/src/view/com/composer/select-language/languageDetection.web.ts @@ -0,0 +1,70 @@ +import {type LanguageResult} from '@bsky.app/expo-guess-language' + +type WorkerResponse = + | {id: number; results: LanguageResult[]} + | {id: number; error: string} + +type PendingRequest = { + resolve: (results: LanguageResult[]) => void + reject: (error: Error) => void +} + +let nextRequestId = 0 +let languageWorker: Worker | undefined +const pendingRequests = new Map() + +function getLanguageWorker(): Worker { + if (languageWorker) { + return languageWorker + } + + languageWorker = new Worker( + new URL('./languageDetection.worker.ts', import.meta.url), + {type: 'module'}, + ) + languageWorker.onmessage = (event: MessageEvent) => { + const request = pendingRequests.get(event.data.id) + if (!request) { + return + } + pendingRequests.delete(event.data.id) + + if ('error' in event.data) { + request.reject(new Error(event.data.error)) + } else { + request.resolve(event.data.results) + } + } + languageWorker.onerror = event => { + const error = new Error(event.message || 'Language detection worker failed') + for (const request of pendingRequests.values()) { + request.reject(error) + } + pendingRequests.clear() + languageWorker?.terminate() + languageWorker = undefined + } + + return languageWorker +} + +/** + * Runs web language inference away from the UI thread. The web implementation + * of expo-guess-language uses lande synchronously even though its public API + * returns a Promise, so calling it on the main thread can delay composer input + * on slower devices. + */ +export function guessLanguageAsync(text: string): Promise { + const id = ++nextRequestId + return new Promise((resolve, reject) => { + pendingRequests.set(id, {resolve, reject}) + try { + getLanguageWorker().postMessage({id, text}) + } catch (error) { + pendingRequests.delete(id) + reject(error instanceof Error ? error : new Error(String(error))) + } + }) +} + +export {type LanguageResult} diff --git a/src/view/com/composer/select-language/languageDetection.worker.ts b/src/view/com/composer/select-language/languageDetection.worker.ts new file mode 100644 index 000000000..f1c0e374a --- /dev/null +++ b/src/view/com/composer/select-language/languageDetection.worker.ts @@ -0,0 +1,14 @@ +import {guessLanguageAsync} from '@bsky.app/expo-guess-language' + +self.onmessage = async (event: MessageEvent<{id: number; text: string}>) => { + const {id, text} = event.data + try { + const results = await guessLanguageAsync(text) + self.postMessage({id, results}) + } catch (error) { + self.postMessage({ + id, + error: error instanceof Error ? error.message : String(error), + }) + } +} diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 8715eb9cd..db31824ab 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -1,4 +1,5 @@ import { + startTransition, useCallback, useEffect, useImperativeHandle, @@ -385,7 +386,17 @@ export function TextInput({ ) } - setRichText(newRt) + /* + * TipTap owns the editable DOM, so its transaction has already made + * the new character visible. The composer state update can re-render + * most of the sheet (counter, controls, embeds, etc.), which is not + * urgent and can be expensive on slower browsers. Marking it as a + * transition lets React yield to the browser so the editor paints the + * keystroke before doing that work. + */ + startTransition(() => { + setRichText(newRt) + }) const nextDetectedUris = new Map() if (newRt.facets) {