From 1386a559b75531769ac525ce464e1e03cd7c00e6 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:31:23 -0700 Subject: [PATCH] Log languages a post is tagged with after translating (#10014) Co-authored-by: Eric Bailey --- package.json | 2 +- src/analytics/metrics/types.ts | 7 ++- src/components/Post/Translated/index.tsx | 4 ++ .../PostControls/PostMenu/PostMenuItems.tsx | 3 ++ src/components/dms/MessageContextMenu.tsx | 3 +- src/lib/translation/context.ts | 14 +----- src/lib/translation/index.tsx | 47 ++++++++++--------- src/lib/translation/index.web.tsx | 42 +++++++---------- src/lib/translation/types.ts | 26 ++++++++++ src/locale/helpers.ts | 27 +++++------ yarn.lock | 8 ++-- 11 files changed, 102 insertions(+), 81 deletions(-) diff --git a/package.json b/package.json index 3066a04213..5a5415bd78 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "@braintree/sanitize-url": "^6.0.2", "@bsky.app/alf": "^0.1.7", "@bsky.app/expo-image-crop-tool": "^0.5.0", - "@bsky.app/expo-translate-text": "^0.2.7", + "@bsky.app/expo-translate-text": "^0.2.9", "@bsky.app/react-native-mmkv": "2.12.5", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@emoji-mart/data": "^1.2.1", diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 4946213f40..494c7ee1d9 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -707,15 +707,20 @@ export type Events = { 'reportDialog:failure': {} translate: { + os: Platform['OS'] sourceLanguages: string[] targetLanguage: string textLength: number } 'translate:result': { - method: 'on-device' | 'google-translate' | 'fallback-alert' + method: 'on-device' | 'fallback-alert' os: Platform['OS'] + sourceSelection: 'automatic' | 'manual' sourceLanguage: string | null targetLanguage: string + + /* Only relevant to posts */ + postLanguages?: string[] } 'translate:override': { os: Platform['OS'] diff --git a/src/components/Post/Translated/index.tsx b/src/components/Post/Translated/index.tsx index c6402978a2..1100c81bfa 100644 --- a/src/components/Post/Translated/index.tsx +++ b/src/components/Post/Translated/index.tsx @@ -9,6 +9,7 @@ import {useTranslate} from '#/lib/translation' import {type TranslationFunction} from '#/lib/translation' import { codeToLanguageName, + getPostLanguageTags, isPostInLanguage, languageName, } from '#/locale/helpers' @@ -42,6 +43,7 @@ export function TranslatedPost({ const langPrefs = useLanguagePrefs() const {clearTranslation, translate, translationState} = useTranslate({ key: post.uri, + postLangCodes: getPostLanguageTags(post), }) const needsTranslation = useMemo(() => { @@ -122,6 +124,7 @@ function TranslationLink({ }) ax.metric('translate', { + os: Platform.OS, sourceLanguages: [], // todo: get from post maybe? targetLanguage: primaryLanguage, textLength: postText.length, @@ -405,6 +408,7 @@ function TranslationLanguageSelect({ text: postText, targetLangCode: langPrefs.primaryLanguage, sourceLangCode, + sourceSelection: 'manual', }) } diff --git a/src/components/PostControls/PostMenu/PostMenuItems.tsx b/src/components/PostControls/PostMenu/PostMenuItems.tsx index 6fe8692d20..4f0fbbb2a5 100644 --- a/src/components/PostControls/PostMenu/PostMenuItems.tsx +++ b/src/components/PostControls/PostMenu/PostMenuItems.tsx @@ -28,6 +28,7 @@ import { import {richTextToString} from '#/lib/strings/rich-text-helpers' import {toShareUrl} from '#/lib/strings/url-helpers' import {useTranslate} from '#/lib/translation' +import {getPostLanguageTags} from '#/locale/helpers' import {logger} from '#/logger' import {type Shadow} from '#/state/cache/post-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow' @@ -137,6 +138,7 @@ let PostMenuItems = ({ const openLink = useOpenLink() const {clearTranslation, translate, translationState} = useTranslate({ key: post.uri, + postLangCodes: getPostLanguageTags(post), forceGoogleTranslate, }) const navigation = useNavigation() @@ -287,6 +289,7 @@ let PostMenuItems = ({ ) ) { ax.metric('translate', { + os: Platform.OS, sourceLanguages: post.record.langs ?? [], targetLanguage: langPrefs.primaryLanguage, textLength: post.record.text.length, diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx index dd48eda127..1c6a721775 100644 --- a/src/components/dms/MessageContextMenu.tsx +++ b/src/components/dms/MessageContextMenu.tsx @@ -1,5 +1,5 @@ import {memo, useCallback} from 'react' -import {LayoutAnimation} from 'react-native' +import {LayoutAnimation, Platform} from 'react-native' import * as Clipboard from 'expo-clipboard' import {type ChatBskyConvoDefs, RichText} from '@atproto/api' import {msg} from '@lingui/core/macro' @@ -67,6 +67,7 @@ export let MessageContextMenu = ({ void translate(message.text, langPrefs.primaryLanguage) ax.metric('translate', { + os: Platform.OS, sourceLanguages: [], targetLanguage: langPrefs.primaryLanguage, textLength: message.text.length, diff --git a/src/lib/translation/context.ts b/src/lib/translation/context.ts index 423efc4f1c..aa671cf983 100644 --- a/src/lib/translation/context.ts +++ b/src/lib/translation/context.ts @@ -1,16 +1,6 @@ import {createContext} from 'react' -import {type TranslationFunctionParams, type TranslationState} from './types' +import {type ContextType} from './types' -export const Context = createContext<{ - translationState: Record - translate: ( - parameters: TranslationFunctionParams & { - key: string - forceGoogleTranslate: boolean - }, - ) => Promise - clearTranslation: (key: string) => void - acquireTranslation: (key: string) => () => void -} | null>(null) +export const Context = createContext(null) Context.displayName = 'TranslationContext' diff --git a/src/lib/translation/index.tsx b/src/lib/translation/index.tsx index 50445767c2..9f41a4e894 100644 --- a/src/lib/translation/index.tsx +++ b/src/lib/translation/index.tsx @@ -11,7 +11,12 @@ import {logger} from '#/logger' import {useAnalytics} from '#/analytics' import {HAS_ON_DEVICE_TRANSLATION, IS_ANDROID, IS_IOS} from '#/env' import {Context} from './context' -import {type TranslationFunctionParams, type TranslationState} from './types' +import { + type ContextType, + type TranslationFunctionParams, + type TranslationOptions, + type TranslationState, +} from './types' import {guessLanguage} from './utils' export * from './types' @@ -98,10 +103,8 @@ async function attemptTranslation( export function useTranslate({ key, forceGoogleTranslate = false, -}: { - key: string - forceGoogleTranslate?: boolean -}) { + postLangCodes, +}: TranslationOptions) { const context = useContext(Context) if (!context) { throw new Error( @@ -118,9 +121,14 @@ export function useTranslate({ const translate = useCallback( async (params: TranslationFunctionParams) => { - return context.translate({...params, key, forceGoogleTranslate}) + return context.translate({ + ...params, + key, + forceGoogleTranslate, + postLangCodes, + }) }, - [context, forceGoogleTranslate, key], + [context, forceGoogleTranslate, key, postLangCodes], ) const clearTranslation = useCallback( @@ -199,27 +207,17 @@ export function Provider({children}: React.PropsWithChildren) { }) }, []) - const translate = useCallback( + const translate = useCallback( async ({ key, text, targetLangCode, sourceLangCode, + sourceSelection = 'automatic', + postLangCodes, ...options - }: { - key: string - text: string - targetLangCode: string - sourceLangCode?: string - forceGoogleTranslate?: boolean }) => { if (options?.forceGoogleTranslate || !HAS_ON_DEVICE_TRANSLATION) { - ax.metric('translate:result', { - method: 'google-translate', - os: Platform.OS, - sourceLanguage: sourceLangCode ?? null, - targetLanguage: targetLangCode, - }) await googleTranslate(text, targetLangCode, sourceLangCode) return } @@ -240,8 +238,10 @@ export function Provider({children}: React.PropsWithChildren) { ax.metric('translate:result', { method: 'on-device', os: Platform.OS, + sourceSelection, sourceLanguage: result.sourceLanguage, targetLanguage: result.targetLanguage, + postLanguages: postLangCodes, }) if (!IS_ANDROID) { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) @@ -253,17 +253,20 @@ export function Provider({children}: React.PropsWithChildren) { translatedText: result.translatedText, sourceLanguage: result.sourceLanguage, targetLanguage: result.targetLanguage, + postLanguages: postLangCodes, }, })) } catch (e) { - logger.error('Failed to translate post on device', {safeMessage: e}) + logger.error('Failed to translate text on device', {safeMessage: e}) // On-device translation failed (language pack missing or user - // dismissed the download prompt). Fall back to Google Translate. + // dismissed the download prompt). ax.metric('translate:result', { method: 'fallback-alert', os: Platform.OS, + sourceSelection, sourceLanguage: sourceLangCode ?? null, targetLanguage: targetLangCode, + postLanguages: postLangCodes, }) let errorMessage = l`Device failed to translate :(` if (!IS_ANDROID) { diff --git a/src/lib/translation/index.web.tsx b/src/lib/translation/index.web.tsx index 8fc46175fa..034a38f9b6 100644 --- a/src/lib/translation/index.web.tsx +++ b/src/lib/translation/index.web.tsx @@ -3,7 +3,12 @@ import {useCallback, useContext, useMemo} from 'react' import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate' import {useAnalytics} from '#/analytics' import {Context} from './context' -import {type TranslationFunctionParams, type TranslationState} from './types' +import { + type ContextType, + type TranslationFunctionParams, + type TranslationOptions, + type TranslationState, +} from './types' export * from './types' export * from './utils' @@ -17,12 +22,7 @@ const clearTranslation = (_key: string) => {} /** * Web always opens Google Translate. */ -export function useTranslate({ - key, -}: { - key: string - forceGoogleTranslate?: boolean -}) { +export function useTranslate({key, postLangCodes}: TranslationOptions) { const context = useContext(Context) if (!context) { throw new Error( @@ -33,9 +33,14 @@ export function useTranslate({ // Always call hooks in consistent order const translate = useCallback( async (params: TranslationFunctionParams) => { - return context.translate({...params, key, forceGoogleTranslate: true}) + return context.translate({ + ...params, + key, + forceGoogleTranslate: true, + postLangCodes, + }) }, - [key, context], + [key, context, postLangCodes], ) const clearTranslation = useCallback(() => { @@ -55,23 +60,8 @@ export function Provider({children}: React.PropsWithChildren) { const ax = useAnalytics() const googleTranslate = useGoogleTranslate() - const translate = useCallback( - async ({ - text, - targetLangCode, - sourceLangCode, - }: { - key: string - text: string - targetLangCode: string - sourceLangCode?: string - }) => { - ax.metric('translate:result', { - method: 'google-translate', - os: 'web', - sourceLanguage: sourceLangCode ?? null, - targetLanguage: targetLangCode, - }) + const translate = useCallback( + async ({text, targetLangCode, sourceLangCode}) => { await googleTranslate(text, targetLangCode, sourceLangCode) }, [ax, googleTranslate], diff --git a/src/lib/translation/types.ts b/src/lib/translation/types.ts index 4bf2d021e7..352df445f6 100644 --- a/src/lib/translation/types.ts +++ b/src/lib/translation/types.ts @@ -27,8 +27,34 @@ export type TranslationFunctionParams = { * The source language of the text. Will auto-detect if not provided. */ sourceLangCode?: string + /** + * Whether we auto-detected the language or it was selected manually. Defaults to 'automatic'. + */ + sourceSelection?: 'automatic' | 'manual' +} + +export type TranslationOptions = { + key: string + forceGoogleTranslate?: boolean + /** + * The language(s) of the post being translated. Used for analytics purposes + * to understand translation usage patterns better. Optional because it may + * not always be available (e.g. if the post text is empty or if the + * translation is triggered from a non-post + * context). + */ + postLangCodes?: string[] } export type TranslationFunction = ( parameters: TranslationFunctionParams, ) => Promise + +export type ContextType = { + translationState: Record + translate: ( + parameters: TranslationFunctionParams & TranslationOptions, + ) => Promise + clearTranslation: (key: string) => void + acquireTranslation: (key: string) => () => void +} diff --git a/src/locale/helpers.ts b/src/locale/helpers.ts index eb384dfecf..18b378c263 100644 --- a/src/locale/helpers.ts +++ b/src/locale/helpers.ts @@ -61,6 +61,14 @@ function getLocalizedLanguage( } } +export function getPostLanguageTags(post: AppBskyFeedDefs.PostView) { + return AppBskyFeedPost.isRecord(post.record) && + hasProp(post.record, 'langs') && + Array.isArray(post.record.langs) + ? post.record.langs + : [] +} + export function languageName(language: Language, appLang: string): string { // if Intl.DisplayNames is unavailable on the target, display the English name if (!Intl.DisplayNames) { @@ -80,22 +88,14 @@ export function codeToLanguageName(lang2or3: string, appLang: string): string { export function getPostLanguage( post: AppBskyFeedDefs.PostView, ): string | undefined { - let candidates: string[] = [] + let candidates: string[] = getPostLanguageTags(post) let postText: string = '' if (hasProp(post.record, 'text') && typeof post.record.text === 'string') { postText = post.record.text } - if ( - AppBskyFeedPost.isRecord(post.record) && - hasProp(post.record, 'langs') && - Array.isArray(post.record.langs) - ) { - candidates = post.record.langs - } - // if there's only one declared language, use that - if (candidates?.length === 1) { + if (candidates.length === 1) { return candidates[0] } @@ -108,11 +108,10 @@ export function getPostLanguage( let langsProbabilityMap = lande(postText) // filter down using declared languages - if (candidates?.length) { + if (candidates.length) { langsProbabilityMap = langsProbabilityMap.filter( - ([lang, _probability]: [string, number]) => { - return candidates.includes(code3ToCode2(lang)) - }, + ([lang, _probability]: [string, number]) => + candidates.includes(code3ToCode2(lang)), ) } diff --git a/yarn.lock b/yarn.lock index af7fd4c64a..5d9f53db16 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2403,10 +2403,10 @@ resolved "https://registry.yarnpkg.com/@bsky.app/expo-image-crop-tool/-/expo-image-crop-tool-0.5.0.tgz#4308fbde5c15e6be9122601797bc3d9549c95e31" integrity sha512-gmhQr2HWTRFyPO00fn5OmtiEVtikXusHMrN5Zoq26pu1VZX3zVE+aoc668etTqrvsQcm2Qu8fo96k5F3Wu+6wg== -"@bsky.app/expo-translate-text@^0.2.7": - version "0.2.7" - resolved "https://registry.yarnpkg.com/@bsky.app/expo-translate-text/-/expo-translate-text-0.2.7.tgz#e34811d0f0300f8808762e5676aa50790ca5d5e8" - integrity sha512-J9zctP9hLxX0eustTKk5CBnCkk6cEdlu1s7GzUnpT65qkCSNbYqbbUCpcU2Z2S2dN/1+w6L/iHb+vmCEbZMOaQ== +"@bsky.app/expo-translate-text@^0.2.9": + version "0.2.9" + resolved "https://registry.yarnpkg.com/@bsky.app/expo-translate-text/-/expo-translate-text-0.2.9.tgz#4ed4552cd50bca7d02d14e706e419bd728d4ab51" + integrity sha512-VmqMhc/YavjgkGhxT/fB8mGSi+VZHJET1tsbpTg8peqKRXFSju2F294NsRxH/4aaMQFlt5oRfPCRnLm1H5o3lA== "@bsky.app/react-native-mmkv@2.12.5": version "2.12.5"