Native translation expo module (#4098)

* translation expo module

* add `onClose` and `onReplacementAction`

* rm onReplacementAction

* make all props published

* make translation api available globally w/o wrapper (#4110)

* conditionally import the translation module

* only use native translation if language is probably supported

* open native translation via dropdown menu

---------

Co-authored-by: Hailey <me@haileyok.com>
Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
zio/stable
Samuel Newman 2024-05-29 07:08:46 +03:00 committed by GitHub
parent a60f9933d8
commit b59c8e22af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 232 additions and 8 deletions

View File

@ -0,0 +1,6 @@
{
"platforms": ["ios"],
"ios": {
"modules": ["ExpoBlueskyTranslateModule"]
}
}

View File

@ -0,0 +1,6 @@
export {
isAvailable,
isLanguageSupported,
NativeTranslationModule,
NativeTranslationView,
} from './src/ExpoBlueskyTranslateView'

View File

@ -0,0 +1,20 @@
import ExpoModulesCore
import SwiftUI
// Thanks to Andrew Levy for this code snippet
// https://github.com/andrew-levy/swiftui-react-native/blob/d3fbb2abf07601ff0d4b83055e7717bb980910d6/ios/Common/ExpoView%2BUIHostingController.swift
extension ExpoView {
func setupHostingController(_ hostingController: UIHostingController<some View>) {
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
hostingController.view.backgroundColor = .clear
addSubview(hostingController.view)
NSLayoutConstraint.activate([
hostingController.view.topAnchor.constraint(equalTo: self.topAnchor),
hostingController.view.bottomAnchor.constraint(equalTo: self.bottomAnchor),
hostingController.view.leftAnchor.constraint(equalTo: self.leftAnchor),
hostingController.view.rightAnchor.constraint(equalTo: self.rightAnchor),
])
}
}

View File

@ -0,0 +1,21 @@
Pod::Spec.new do |s|
s.name = 'ExpoBlueskyTranslate'
s.version = '1.0.0'
s.summary = 'Uses SwiftUI translation to translate text.'
s.description = 'Uses SwiftUI translation to translate text.'
s.author = ''
s.homepage = 'https://docs.expo.dev/modules/'
s.platforms = { :ios => '13.4' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
end

View File

@ -0,0 +1,18 @@
import ExpoModulesCore
import Foundation
import SwiftUI
public class ExpoBlueskyTranslateModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoBlueskyTranslate")
AsyncFunction("presentAsync") { (text: String) in
DispatchQueue.main.async { [weak state = TranslateViewState.shared] in
state?.isPresented = true
state?.text = text
}
}
View(ExpoBlueskyTranslateView.self) {}
}
}

View File

@ -0,0 +1,22 @@
import ExpoModulesCore
import Foundation
import SwiftUI
class TranslateViewState: ObservableObject {
static var shared = TranslateViewState()
@Published var isPresented = false
@Published var text = ""
}
class ExpoBlueskyTranslateView: ExpoView {
required init(appContext: AppContext? = nil) {
if #available(iOS 14.0, *) {
let hostingController = UIHostingController(rootView: TranslateView())
super.init(appContext: appContext)
setupHostingController(hostingController)
} else {
super.init(appContext: appContext)
}
}
}

View File

@ -0,0 +1,31 @@
import SwiftUI
// conditionally import the Translation module
#if canImport(Translation)
import Translation
#endif
struct TranslateView: View {
@ObservedObject var state = TranslateViewState.shared
var body: some View {
if #available(iOS 17.4, *) {
VStack {
UIViewRepresentableWrapper(view: UIView(frame: .zero))
}
.translationPresentation(
isPresented: $state.isPresented,
text: state.text
)
}
}
}
struct UIViewRepresentableWrapper: UIViewRepresentable {
let view: UIView
func makeUIView(context: Context) -> UIView {
return view
}
func updateUIView(_ uiView: UIView, context: Context) {}
}

View File

@ -0,0 +1,3 @@
export type ExpoBlueskyTranslateModule = {
presentAsync: (text: string) => Promise<void>
}

View File

@ -0,0 +1,48 @@
import React from 'react'
import {Platform} from 'react-native'
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
import {ExpoBlueskyTranslateModule} from './ExpoBlueskyTranslate.types'
export const NativeTranslationModule =
requireNativeModule<ExpoBlueskyTranslateModule>('ExpoBlueskyTranslate')
const NativeView: React.ComponentType = requireNativeViewManager(
'ExpoBlueskyTranslate',
)
export function NativeTranslationView() {
return <NativeView />
}
export const isAvailable = Number(Platform.Version) >= 17.4
// https://en.wikipedia.org/wiki/Translate_(Apple)#Languages
const SUPPORTED_LANGUAGES = [
'ar',
'zh',
'zh',
'nl',
'en',
'en',
'fr',
'de',
'id',
'it',
'ja',
'ko',
'pl',
'pt',
'ru',
'es',
'th',
'tr',
'uk',
'vi',
]
export function isLanguageSupported(lang?: string) {
// If the language is not provided, we assume it is supported
if (!lang) return true
return SUPPORTED_LANGUAGES.includes(lang)
}

View File

@ -0,0 +1,13 @@
export const NativeTranslationModule = {
presentAsync: async (_: string) => {},
}
export function NativeTranslationView() {
return null
}
export const isAvailable = false
export function isLanguageSupported(_lang?: string) {
return false
}

View File

@ -1,5 +1,7 @@
import React from 'react' import React from 'react'
import {ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types' import {ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
export function ExpoScrollForwarderView({ export function ExpoScrollForwarderView({
children, children,
}: React.PropsWithChildren<ExpoScrollForwarderViewProps>) { }: React.PropsWithChildren<ExpoScrollForwarderViewProps>) {

View File

@ -30,6 +30,11 @@ import {useSession} from 'state/session'
import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn' import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {RichText} from '#/components/RichText' import {RichText} from '#/components/RichText'
import {
isAvailable as isNativeTranslationAvailable,
isLanguageSupported,
NativeTranslationModule,
} from '../../../../modules/expo-bluesky-translate'
import {ContentHider} from '../../../components/moderation/ContentHider' import {ContentHider} from '../../../components/moderation/ContentHider'
import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe' import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
import {PostAlerts} from '../../../components/moderation/PostAlerts' import {PostAlerts} from '../../../components/moderation/PostAlerts'
@ -317,6 +322,7 @@ let PostThreadItemLoaded = ({
</ContentHider> </ContentHider>
<ExpandedPostDetails <ExpandedPostDetails
post={post} post={post}
record={record}
translatorUrl={translatorUrl} translatorUrl={translatorUrl}
needsTranslation={needsTranslation} needsTranslation={needsTranslation}
/> />
@ -620,26 +626,39 @@ function PostOuterWrapper({
function ExpandedPostDetails({ function ExpandedPostDetails({
post, post,
record,
needsTranslation, needsTranslation,
translatorUrl, translatorUrl,
}: { }: {
post: AppBskyFeedDefs.PostView post: AppBskyFeedDefs.PostView
record?: AppBskyFeedPost.Record
needsTranslation: boolean needsTranslation: boolean
translatorUrl: string translatorUrl: string
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const openLink = useOpenLink() const openLink = useOpenLink()
const onTranslatePress = React.useCallback(
() => openLink(translatorUrl), const text = record?.text || ''
[openLink, translatorUrl],
) const onTranslatePress = React.useCallback(() => {
if (
isNativeTranslationAvailable &&
isLanguageSupported(record?.langs?.at(0))
) {
NativeTranslationModule.presentAsync(text)
} else {
openLink(translatorUrl)
}
}, [openLink, text, translatorUrl, record])
return ( return (
<View style={[s.flexRow, s.mt2, s.mb10]}> <View style={[s.flexRow, s.mt2, s.mb10]}>
<Text style={pal.textLight}>{niceDate(post.indexedAt)}</Text> <Text style={pal.textLight}>{niceDate(post.indexedAt)}</Text>
{needsTranslation && ( {needsTranslation && (
<> <>
<Text style={pal.textLight}> &middot; </Text> <Text style={pal.textLight}> &middot; </Text>
<Text <Text
style={pal.link} style={pal.link}
title={_(msg`Translate`)} title={_(msg`Translate`)}

View File

@ -50,6 +50,11 @@ import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/War
import * as Menu from '#/components/Menu' import * as Menu from '#/components/Menu'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
import {
isAvailable as isNativeTranslationAvailable,
isLanguageSupported,
NativeTranslationModule,
} from '../../../../../modules/expo-bluesky-translate'
import {EventStopper} from '../EventStopper' import {EventStopper} from '../EventStopper'
import * as Toast from '../Toast' import * as Toast from '../Toast'
@ -172,9 +177,17 @@ let PostDropdownBtn = ({
Toast.show(_(msg`Copied to clipboard`)) Toast.show(_(msg`Copied to clipboard`))
}, [_, richText]) }, [_, richText])
const onOpenTranslate = React.useCallback(() => { const onPressTranslate = React.useCallback(() => {
if (
isNativeTranslationAvailable &&
isLanguageSupported(record?.langs?.at(0))
) {
const text = richTextToString(richText, true)
NativeTranslationModule.presentAsync(text)
} else {
openLink(translatorUrl) openLink(translatorUrl)
}, [openLink, translatorUrl]) }
}, [openLink, record?.langs, richText, translatorUrl])
const onHidePost = React.useCallback(() => { const onHidePost = React.useCallback(() => {
hidePost({uri: postUri}) hidePost({uri: postUri})
@ -246,7 +259,7 @@ let PostDropdownBtn = ({
<Menu.Item <Menu.Item
testID="postDropdownTranslateBtn" testID="postDropdownTranslateBtn"
label={_(msg`Translate`)} label={_(msg`Translate`)}
onPress={onOpenTranslate}> onPress={onPressTranslate}>
<Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText> <Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText>
<Menu.ItemIcon icon={Translate} position="right" /> <Menu.ItemIcon icon={Translate} position="right" />
</Menu.Item> </Menu.Item>

View File

@ -33,6 +33,7 @@ import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
import {MutedWordsDialog} from '#/components/dialogs/MutedWords' import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
import {SigninDialog} from '#/components/dialogs/Signin' import {SigninDialog} from '#/components/dialogs/Signin'
import {Outlet as PortalOutlet} from '#/components/Portal' import {Outlet as PortalOutlet} from '#/components/Portal'
import {NativeTranslationView} from '../../../modules/expo-bluesky-translate'
import {RoutesContainer, TabsNavigator} from '../../Navigation' import {RoutesContainer, TabsNavigator} from '../../Navigation'
import {Composer} from './Composer' import {Composer} from './Composer'
import {DrawerContent} from './Drawer' import {DrawerContent} from './Drawer'
@ -93,6 +94,7 @@ function ShellInner() {
</Drawer> </Drawer>
</ErrorBoundary> </ErrorBoundary>
</Animated.View> </Animated.View>
<NativeTranslationView />
<Composer winHeight={winDim.height} /> <Composer winHeight={winDim.height} />
<ModalsContainer /> <ModalsContainer />
<MutedWordsDialog /> <MutedWordsDialog />