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
parent
a60f9933d8
commit
b59c8e22af
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"platforms": ["ios"],
|
||||||
|
"ios": {
|
||||||
|
"modules": ["ExpoBlueskyTranslateModule"]
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
export {
|
||||||
|
isAvailable,
|
||||||
|
isLanguageSupported,
|
||||||
|
NativeTranslationModule,
|
||||||
|
NativeTranslationView,
|
||||||
|
} from './src/ExpoBlueskyTranslateView'
|
|
@ -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),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
|
@ -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) {}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) {}
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
export type ExpoBlueskyTranslateModule = {
|
||||||
|
presentAsync: (text: string) => Promise<void>
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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>) {
|
||||||
|
|
|
@ -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}> · </Text>
|
<Text style={pal.textLight}> · </Text>
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
style={pal.link}
|
style={pal.link}
|
||||||
title={_(msg`Translate`)}
|
title={_(msg`Translate`)}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 />
|
||||||
|
|
Loading…
Reference in New Issue