diff --git a/modules/expo-selectable-text/README.md b/modules/expo-selectable-text/README.md index c63594630d..a100867e51 100644 --- a/modules/expo-selectable-text/README.md +++ b/modules/expo-selectable-text/README.md @@ -35,11 +35,7 @@ podspec inside of this directory. ## Technical Whenever we use the SelectableText component, we then loop over each of the children (or just the text if the child -is typeof Text). We create "segments" of text based on this and encode them to JSON* to pass to the native side. - -/* Expo Modules doesn't currently support passing an array over the bridge, but even if it did, it would be parsing -sending JSON over the bridge anyway, so this is not an issue. I want to make sure this is the case (on expo support) but -regardless there's no real perf concern. +is typeof Text). We create "segments" of text based on this. The native side renders a *single* UITextView and adds each of the text segments to the view. Using NSAttributedStrings, we can add the styles for each segment individually (falling back to the root styles). @@ -54,7 +50,3 @@ As such, we create a gesture recognizer for the entire UITextView. The recognize the press, determines which segment it is, and sends an `onTextPress` event to the JS side along with the index of the segment. On the JS side, we have our array of segments (that included the `onPress` event from the `Text`/`SelectableText`) that we can now call based on the index from the native event. - -We can't reliably modify the size of the view's container on the native side (*don't take my word for this, I might be - -and likely am - wrong about this). Therefore, we send an `onTextLayout` event to the JS thread that we can use to resize -the container's height based on the required height to display the text. This only gets called once per text update. diff --git a/modules/expo-selectable-text/expo-module.config.json b/modules/expo-selectable-text/expo-module.config.json index 343fbb79cb..5a6335eb82 100644 --- a/modules/expo-selectable-text/expo-module.config.json +++ b/modules/expo-selectable-text/expo-module.config.json @@ -1,6 +1,6 @@ { "platforms": ["ios"], "ios": { - "modules": ["ExpoSelectableTextModule"] + "modules": ["ExpoUITextViewModule", "ExpoUITextViewChildModule"] } } diff --git a/modules/expo-selectable-text/index.ts b/modules/expo-selectable-text/index.ts index 01075393ee..b3e74bda79 100644 --- a/modules/expo-selectable-text/index.ts +++ b/modules/expo-selectable-text/index.ts @@ -1,4 +1,4 @@ -import ExpoSelectableTextView from './src/ExpoSelectableTextView' -import {ExpoProTextViewProps} from './src/ExpoSelectableText.types' +import ExpoSelectableTextView from './src/ExpoUITextView' +import {ExpoProTextViewProps} from './src/ExpoUITextView.types' export {ExpoSelectableTextView as SelectableText, type ExpoProTextViewProps} diff --git a/modules/expo-selectable-text/ios/ExpoSelectableTextModule.swift b/modules/expo-selectable-text/ios/ExpoSelectableTextModule.swift deleted file mode 100644 index 2050e3e9c4..0000000000 --- a/modules/expo-selectable-text/ios/ExpoSelectableTextModule.swift +++ /dev/null @@ -1,36 +0,0 @@ -import ExpoModulesCore - -public class ExpoSelectableTextModule: Module { - public func definition() -> ModuleDefinition { - Name("ExpoSelectableText") - - View(ExpoSelectableTextView.self) { - Events("onTextPress", "onTextLongPress", "onTextLayout") - - Prop("segments") { (view: ExpoSelectableTextView, prop: String) in - // Convert the JSON to segments - if let data = prop.data(using: .utf8) { - if let segments = try? JSONDecoder().decode(TextSegments.self, from: data) { - view.segments = segments.segments - } - } - } - - Prop("rootStyle") { (view: ExpoSelectableTextView, prop: String) in - if let data = prop.data(using: .utf8) { - if let style = try? JSONDecoder().decode(TextStyle.self, from: data) { - view.style = style - } - } - } - - Prop("selectable") { (view: ExpoSelectableTextView, prop: Bool) in - view.textView.isSelectable = prop - } - - Prop("children") { (view: ExpoSelectableTextView, prop: JavaScriptValue) in - print(prop) - } - } - } -} diff --git a/modules/expo-selectable-text/ios/ExpoSelectableTextView.swift b/modules/expo-selectable-text/ios/ExpoSelectableTextView.swift deleted file mode 100644 index 0fbc4dd441..0000000000 --- a/modules/expo-selectable-text/ios/ExpoSelectableTextView.swift +++ /dev/null @@ -1,147 +0,0 @@ -import ExpoModulesCore - -class ExpoSelectableTextView: ExpoView { - var textView: UITextView - var segments: Array = [] { - didSet { - // We don't want to set the text if the root style has not been set yet - self.setText() - } - } - var style: TextStyle? { - didSet { - // If the text has not been set and there are segments, set the text - self.setText() - } - } - - let onTextPress = EventDispatcher() - let onTextLongPress = EventDispatcher() - let onTextLayout = EventDispatcher() - - public required init(appContext: AppContext? = nil) { - if #available(iOS 16.0, *) { - textView = UITextView(usingTextLayoutManager: false) - } else { - textView = UITextView() - } - - super.init(appContext: appContext) - - // Configure default appearance - textView.scrollsToTop = false - textView.isEditable = false - textView.isScrollEnabled = false - textView.backgroundColor = .clear - - // Remove all of the padding from the view - textView.textContainerInset = UIEdgeInsets.zero - textView.textContainer.lineFragmentPadding = 0 - - // Add the text view to the root view - self.addSubview(textView) - - // Configure the press recognizer - let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(callOnPress(_:))) - textView.addGestureRecognizer(tapGestureRecognizer) - } - - override func layoutSubviews() -> Void { - // Set the textView's frame on layout - self.setSize() - } - - @IBAction func callOnPress(_ sender: UITapGestureRecognizer) -> Void { - if let segment = getPressedSegment(sender), segment.handlePress { - if textView.selectedTextRange == nil { - onTextPress([ - "index": segment.index - ]) - } else { - // Clear the selected text range if we are not pressing on a link - textView.selectedTextRange = nil - } - } - } - - func setSize() -> Void { - // Figure out the height of our text and create a CGRect - let maxWidth = bounds.width - let sizeThatFits = textView.sizeThatFits(CGSize(width: maxWidth, height: CGFloat(MAXFLOAT))) - let size = CGSize(width: maxWidth, height: sizeThatFits.height) - textView.frame.size = size - - self.onTextLayout([ - "height": sizeThatFits.height, - "width": maxWidth - ]) - } - - func setText() -> Void { - let finalAttributedString = NSMutableAttributedString() - - self.segments.forEach { segment in - // Set some generic attributes that don't need ranges - let attributes: [NSAttributedString.Key:Any] = [ - .font: UIFont.systemFont(ofSize: segment.style?.fontSize ?? self.style?.fontSize ?? 12.0, weight: segment.style?.fontWeight?.toFontWeight() ?? self.style?.fontWeight?.toFontWeight() ?? .regular), - .foregroundColor: ExpoSelectableTextUtil.hexToUIColor(hex: segment.style?.color), - ] - - // Create the attributed string with the generic attributes - let string = NSMutableAttributedString(string: segment.text, attributes: attributes) - - // Set the paragraph style attributes if necessary - if let lineHeight = segment.style?.lineHeight { - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.minimumLineHeight = lineHeight - paragraphStyle.maximumLineHeight = lineHeight - string.addAttribute(NSAttributedString.Key.paragraphStyle, value: paragraphStyle, range: NSMakeRange(0, string.length)) - } - - if let textDecorationLine = segment.style?.textDecorationLine { - if textDecorationLine == .underline || textDecorationLine == .underlineLineThrough { - string.addAttribute(NSAttributedString.Key.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: NSMakeRange(0, string.length)) - } - - if textDecorationLine == .lineThrough || textDecorationLine == .underlineLineThrough { - string.addAttribute(NSAttributedString.Key.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: NSMakeRange(0, string.length)) - } - } - - finalAttributedString.append(string) - } - - textView.attributedText = finalAttributedString - textView.selectedTextRange = nil - - self.setNeedsLayout() - } - - func getPressedSegment(_ sender: UITapGestureRecognizer) -> TextSegment? { - let layoutManager = textView.layoutManager - var location = sender.location(in: textView) - - // Remove the padding - location.x -= textView.textContainerInset.left - location.y -= textView.textContainerInset.top - - // Get the index of the char - let charIndex = layoutManager.characterIndex(for: location, in: textView.textContainer, fractionOfDistanceBetweenInsertionPoints: nil) - - let text = textView.attributedText.string - var foundSegment: TextSegment? - - // Check each segment - self.segments.forEach { segment in - let range = text.range(of: segment.text) - // Figure out the bounds - if let lowerBound = range?.lowerBound, let upperBound = range?.upperBound { - if charIndex >= lowerBound.utf16Offset(in: text), charIndex <= upperBound.utf16Offset(in: text) { - foundSegment = segment - } - } - } - - return foundSegment - } -} diff --git a/modules/expo-selectable-text/ios/ExpoSelectableText.podspec b/modules/expo-selectable-text/ios/ExpoUITextView.podspec similarity index 69% rename from modules/expo-selectable-text/ios/ExpoSelectableText.podspec rename to modules/expo-selectable-text/ios/ExpoUITextView.podspec index dd25f6c62f..89c0fc9db9 100644 --- a/modules/expo-selectable-text/ios/ExpoSelectableText.podspec +++ b/modules/expo-selectable-text/ios/ExpoUITextView.podspec @@ -1,8 +1,8 @@ Pod::Spec.new do |s| - s.name = 'ExpoSelectableText' + s.name = 'ExpoUITextView' s.version = '1.0.0' - s.summary = 'Simple wrapper for RN Text to use UITextView instead of UILabel' - s.description = 'Simple wrapper for RN Text to use UITextView instead of UILabel' + s.summary = 'Drop in replacement for RN Text that uses UITextView' + s.description = 'Drop in replacement for RN Text that uses UITextView' s.author = '' s.homepage = 'https://github.com/bluesky-social/social-app/modules/expo-selectable-text' s.platform = :ios, '13.0' diff --git a/modules/expo-selectable-text/ios/ExpoUITextView.swift b/modules/expo-selectable-text/ios/ExpoUITextView.swift new file mode 100644 index 0000000000..000b14d8ef --- /dev/null +++ b/modules/expo-selectable-text/ios/ExpoUITextView.swift @@ -0,0 +1,192 @@ +import ExpoModulesCore + +class ExpoUITextView: ExpoView { + var textView: UITextView + var textChildren: [ExpoUITextViewChild] = [] + + var tapGestureRecognizer: UITapGestureRecognizer? + + public required init(appContext: AppContext? = nil) { + if #available(iOS 16.0, *) { + textView = UITextView(usingTextLayoutManager: false) + } else { + textView = UITextView() + } + + // Configure default appearance + textView.scrollsToTop = false + textView.isEditable = false + textView.isScrollEnabled = false + textView.backgroundColor = .clear + textView.textContainer.lineBreakMode = .byTruncatingTail + textView.isSelectable = true + + // Remove all of the padding from the view + textView.textContainerInset = UIEdgeInsets.zero + textView.textContainer.lineFragmentPadding = 0 + + super.init(appContext: appContext) + + addSubview(textView) + + // Configure the tap gesture recognizer + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(callOnPress(_:))) + tapGestureRecognizer.isEnabled = true + self.tapGestureRecognizer = tapGestureRecognizer + textView.addGestureRecognizer(tapGestureRecognizer) + } + + // Update children whenever new react subviews are added + override func insertReactSubview(_ subview: UIView!, at atIndex: Int) { + if subview.isKind(of: ExpoUITextViewChild.self) { + insertSubview(subview, at: atIndex) + self.getTextChildren() + } + } + + // Do the same whenever subviews are removed + override func removeReactSubview(_ subview: UIView!) { + if subview.isKind(of: ExpoUITextViewChild.self) { + subview.removeFromSuperview() + } + } + + override func reactSubviews() -> [UIView]! { + return subviews + } + + override func layoutSubviews() { + // Get the width from the bounds + let maxWidth = bounds.width + // Calculate the size of the text + let sizeThatFits = textView.sizeThatFits(CGSize(width: maxWidth, height: CGFloat(MAXFLOAT))) + let size = CGSize(width: maxWidth, height: sizeThatFits.height) + + // Set the textview's frame + textView.frame.size = size + self.appContext?.reactBridge?.uiManager.setSize(size, for: self) + + } + + @IBAction func callOnPress(_ sender: UITapGestureRecognizer) -> Void { + // If we find a child, then call onPress + if let child = getPressed(sender) { + if textView.selectedTextRange == nil { + child.onTextPress() + } else { + // Clear the selected text range if we are not pressing on a link + textView.selectedTextRange = nil + } + } + } + + // Try to get the pressed segment + func getPressed(_ sender: UITapGestureRecognizer) -> ExpoUITextViewChild? { + let layoutManager = textView.layoutManager + var location = sender.location(in: textView) + + // Remove the padding + location.x -= textView.textContainerInset.left + location.y -= textView.textContainerInset.top + + // Get the index of the char + let charIndex = layoutManager.characterIndex( + for: location, + in: textView.textContainer, + fractionOfDistanceBetweenInsertionPoints: nil + ) + + let text = textView.attributedText.string + var foundChild: ExpoUITextViewChild? + + // Check each segment + self.textChildren.forEach { child in + let range = text.range(of: child.text ?? "") + // Figure out the bounds + if let lowerBound = range?.lowerBound, let upperBound = range?.upperBound { + if charIndex >= lowerBound.utf16Offset(in: text), charIndex <= upperBound.utf16Offset(in: text) { + foundChild = child + } + } + } + + return foundChild + } + + // Get the children. Always use getTextChildren() so that we ensure the correct order of views + func getTextChildren() -> Void { + var children: [ExpoUITextViewChild] = [] + + self.reactSubviews().forEach { view in + if view.isKind(of: ExpoUITextViewChild.self) { + children.append(view as! ExpoUITextViewChild) + } + } + + // Save the children for our onPress handler + self.textChildren = children + // Update the UITextView with the styled text + self.setText() + } + + func setText() -> Void { + // Create an attributed string to store each of the segments + let finalAttributedString = NSMutableAttributedString() + + self.textChildren.forEach { child in + // If we don't have any text in this child, move to the next one + guard let text = child.text else { + return + } + + // Set some generic attributes that don't need ranges + let attributes: [NSAttributedString.Key:Any] = [ + .font: UIFont.systemFont( + ofSize: child.style?.fontSize ?? 12.0, + weight: child.style?.fontWeight?.toFontWeight() ?? .regular + ), + .foregroundColor: TextUtil.hexToUIColor(hex: child.style?.color), + ] + + // Create the attributed string with the generic attributes + let string = NSMutableAttributedString(string: text, attributes: attributes) + + // Set the paragraph style attributes if necessary + if let lineHeight = child.style?.lineHeight { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.minimumLineHeight = lineHeight + paragraphStyle.maximumLineHeight = lineHeight + string.addAttribute( + NSAttributedString.Key.paragraphStyle, + value: paragraphStyle, + range: NSMakeRange(0, string.length) + ) + } + + if let textDecorationLine = child.style?.textDecorationLine { + if textDecorationLine == .underline || textDecorationLine == .underlineLineThrough { + string.addAttribute( + NSAttributedString.Key.underlineStyle, + value: NSUnderlineStyle.single.rawValue, + range: NSMakeRange(0, string.length) + ) + } + + if textDecorationLine == .lineThrough || textDecorationLine == .underlineLineThrough { + string.addAttribute( + NSAttributedString.Key.strikethroughStyle, + value: NSUnderlineStyle.single.rawValue, + range: NSMakeRange(0, string.length) + ) + } + } + + finalAttributedString.append(string) + } + + textView.attributedText = finalAttributedString + textView.selectedTextRange = nil + + self.setNeedsLayout() + } +} diff --git a/modules/expo-selectable-text/ios/ExpoUITextViewChild.swift b/modules/expo-selectable-text/ios/ExpoUITextViewChild.swift new file mode 100644 index 0000000000..d24c5b46b1 --- /dev/null +++ b/modules/expo-selectable-text/ios/ExpoUITextViewChild.swift @@ -0,0 +1,7 @@ +import ExpoModulesCore + +class ExpoUITextViewChild: ExpoView { + var text: String? + var style: TextStyle? + let onTextPress = EventDispatcher() +} diff --git a/modules/expo-selectable-text/ios/ExpoUITextViewModule.swift b/modules/expo-selectable-text/ios/ExpoUITextViewModule.swift new file mode 100644 index 0000000000..8e1ef09825 --- /dev/null +++ b/modules/expo-selectable-text/ios/ExpoUITextViewModule.swift @@ -0,0 +1,32 @@ +import ExpoModulesCore + +public class ExpoUITextViewModule: Module { + public func definition() -> ModuleDefinition { + Name("ExpoSelectableText") + + View(ExpoUITextView.self) { + } + } +} + + +/** + Children should have parity with React Native text props. The difference is that we will use the text prop for the text rather than getting it from children. + */ +public class ExpoUITextViewChildModule: Module { + public func definition() -> ModuleDefinition { + Name("ExpoTextChild") + + View(ExpoUITextViewChild.self) { + Events("onTextPress") + + Prop("text") { (view: ExpoUITextViewChild, prop: String) in + view.text = prop + } + + Prop("textStyle") { (view: ExpoUITextViewChild, prop: TextStyle) in + view.style = prop + } + } + } +} diff --git a/modules/expo-selectable-text/ios/TextSegment.swift b/modules/expo-selectable-text/ios/TextSegment.swift index 8c410aaaab..3542cf73ea 100644 --- a/modules/expo-selectable-text/ios/TextSegment.swift +++ b/modules/expo-selectable-text/ios/TextSegment.swift @@ -1,13 +1,15 @@ import ExpoModulesCore -struct TextSegments: Decodable { - let segments: Array +struct TextSegments: Record { + @Field + var segments: Array } -struct TextSegment: Decodable { - let index: Int - let text: String - let style: TextStyle? - let handlePress: Bool - let handleLongPress: Bool +struct TextSegment: Record { + @Field + var index: Int + @Field + var text: String + @Field + var style: TextStyle? } diff --git a/modules/expo-selectable-text/ios/TextStyle.swift b/modules/expo-selectable-text/ios/TextStyle.swift index e3ec6a01fa..9f751bccd6 100644 --- a/modules/expo-selectable-text/ios/TextStyle.swift +++ b/modules/expo-selectable-text/ios/TextStyle.swift @@ -1,16 +1,29 @@ -struct TextStyle: Decodable { +import ExpoModulesCore + +struct TextStyle: Record { + @Field var color: String? = "black" + @Field var fontSize: CGFloat? = 12 + @Field var fontStyle: String? = "normal" + @Field var fontWeight: FontWeight? + @Field var letterSpacing: Double? + @Field var textAlign: String? = "auto" + @Field var lineHeight: Double? + @Field var textDecorationLine: TextDecorationLine? + @Field var flex: Int? + @Field + var pointerEvents: String? } -enum FontWeight: String, Decodable { +enum FontWeight: String, Enumerable { case bold case normal case one = "100" @@ -51,10 +64,9 @@ enum FontWeight: String, Decodable { } } -enum TextDecorationLine: String, Decodable { +enum TextDecorationLine: String, Enumerable { case underline case lineThrough = "line-through" case underlineLineThrough = "underline line-through" - case normal } diff --git a/modules/expo-selectable-text/ios/ExpoSelectableTextUtil.swift b/modules/expo-selectable-text/ios/TextUtil.swift similarity index 99% rename from modules/expo-selectable-text/ios/ExpoSelectableTextUtil.swift rename to modules/expo-selectable-text/ios/TextUtil.swift index ad06434639..d83a6e4413 100644 --- a/modules/expo-selectable-text/ios/ExpoSelectableTextUtil.swift +++ b/modules/expo-selectable-text/ios/TextUtil.swift @@ -1,4 +1,4 @@ -public class ExpoSelectableTextUtil { +public class TextUtil { public static func hexToUIColor(hex: String?) -> UIColor { guard let hex else { return UIColor.black diff --git a/modules/expo-selectable-text/src/ExpoSelectableText.types.ts b/modules/expo-selectable-text/src/ExpoSelectableText.types.ts deleted file mode 100644 index 8dce1fd910..0000000000 --- a/modules/expo-selectable-text/src/ExpoSelectableText.types.ts +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react' -import {TextStyle, ViewStyle} from 'react-native' - -interface ExpoProTextViewCommonProps { - selectable?: boolean -} - -export interface ExpoProTextViewProps extends ExpoProTextViewCommonProps { - children: React.ReactNode - style?: TextStyle - onPress?: () => void - onLongPress?: () => void -} - -export interface ExpoProTextNativeViewProps extends ExpoProTextViewCommonProps { - segments: string - textStyle?: TextStyle - onTextPress?: (event: ExpoProTextPressEvent) => void - onTextLongPress?: (event: ExpoProTextPressEvent) => void - onTextLayout?: (event: ExpoProTextLayoutEvent) => void - disableLongPress: boolean - style: ViewStyle - rootStyle?: string -} - -export interface ExpoProTextLayoutEvent { - nativeEvent: { - height: number - } -} - -export interface ExpoProTextPressEvent { - nativeEvent: { - index: number - } -} - -export interface ExpoProTextSegment { - index: number - text: string - style?: TextStyle - handlePress?: boolean - handleLongPress?: boolean -} diff --git a/modules/expo-selectable-text/src/ExpoSelectableTextView.tsx b/modules/expo-selectable-text/src/ExpoSelectableTextView.tsx deleted file mode 100644 index c2df72c98d..0000000000 --- a/modules/expo-selectable-text/src/ExpoSelectableTextView.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import {requireNativeViewManager} from 'expo-modules-core' -import * as React from 'react' - -import { - ExpoProTextLayoutEvent, - ExpoProTextNativeViewProps, - ExpoProTextPressEvent, - ExpoProTextSegment, - ExpoProTextViewProps, -} from './ExpoSelectableText.types' -import {StyleSheet, View} from 'react-native' -import {onTextLinkPress, TextLink} from 'view/com/util/Link' -import {useNavigation} from '@react-navigation/native' -import {NavigationProp} from 'lib/routes/types' -import {useModalControls} from 'state/modals' - -const NativeView: React.ComponentType = - requireNativeViewManager('ExpoSelectableText') - -export default function ExpoSelectableTextView({ - style, - children, - selectable = true, - onPress, - onLongPress, -}: ExpoProTextViewProps) { - // Dimensions based on the native view's text height - const [dims, setDims] = React.useState({height: 0}) - - // Needed for navigation on link presses - const navigation = useNavigation() - const {openModal, closeModal} = useModalControls() - - // Store the callbacks for onPress and onLongPress events - const segmentPressCallbacks = React.useRef< - Array<{index: number; onPress: () => void}> - >([]) - const segmentLongPressCallbacks = React.useRef< - Array<{index: number; onLongPress: () => void}> - >([]) - - // The root style, stringified - const rootStyle = React.useMemo(() => { - return style ? JSON.stringify(style) : undefined - }, [style]) - - // The text segments, stringified - const textSegments = React.useMemo(() => { - const segments: ExpoProTextSegment[] = [] - - for (const [index, child] of React.Children.toArray(children).entries()) { - // Most of our children will be strings. Simply add them to the segments array. - if (typeof child === 'string') { - segments.push({ - index, - text: child, - style: style, - handlePress: onPress !== undefined, - handleLongPress: onLongPress !== undefined, - }) - } else if (React.isValidElement(child)) { - // If it is a child, it is either a nested or a . Check if the child is a string or a - // If it's a we need to create on the onPress handler (it won't be created in the component since the - // component never actually gets rendered) - - const { - children: innerChildren, - onLongPress: innerOnLongPress, - style: innerStyle, - text: innerText, - href, - navigationAction, - warnOnMismatchingLabel, - } = child.props - let innerOnPress = child.props.onPress - - const type = (child as React.ReactElement).type - - if (typeof innerChildren === 'string' || type === TextLink) { - if (type === TextLink) { - // Set the onPress handler - innerOnPress = () => { - onTextLinkPress({ - openModal, - closeModal, - text: innerText, - navigation, - href, - navigationAction, - warnOnMismatchingLabel, - }) - } - } - - // Add the segment to the array - segments.push({ - index, - text: innerText ?? innerChildren, - style: StyleSheet.flatten(innerStyle), - handlePress: innerOnPress !== undefined, - handleLongPress: innerOnLongPress !== undefined, - }) - - // If we have press events, push them in - if (innerOnPress !== undefined) { - segmentPressCallbacks.current.push({ - index, - onPress: innerOnPress, - }) - } - if (onLongPress !== undefined) { - segmentLongPressCallbacks.current.push({ - index, - onLongPress: innerOnLongPress, - }) - } - } - } - } - - return segments - }, [children, closeModal, navigation, onLongPress, onPress, openModal, style]) - - const segmentsJson = React.useMemo(() => { - return JSON.stringify({segments: textSegments}) - }, [textSegments]) - - const onTextLayout = React.useCallback((e: ExpoProTextLayoutEvent) => { - setDims({ - height: e.nativeEvent.height, - }) - }, []) - - const onTextPress = React.useCallback( - (e: ExpoProTextPressEvent) => { - const onPressSegment = - segmentPressCallbacks.current.find(s => s.index === e.nativeEvent.index) - ?.onPress ?? onPress - - onPressSegment?.() - }, - [onPress], - ) - - return ( - - - - ) -} diff --git a/modules/expo-selectable-text/src/ExpoUITextView.tsx b/modules/expo-selectable-text/src/ExpoUITextView.tsx new file mode 100644 index 0000000000..2201fffcd2 --- /dev/null +++ b/modules/expo-selectable-text/src/ExpoUITextView.tsx @@ -0,0 +1,79 @@ +import React from 'react' +import {requireNativeViewManager} from 'expo-modules-core' +import {StyleSheet, ViewStyle} from 'react-native' +import { + ExpoUITextViewChildNativeProps, + ExpoUITextViewNativeProps, + ExpoUITextViewProps, +} from './ExpoUITextView.types' + +const NativeView: React.ComponentType = + requireNativeViewManager('ExpoSelectableText') + +const NativeViewChild: React.ComponentType = + requireNativeViewManager('ExpoTextChild') + +const TextAncestorContext = React.createContext<[boolean, ViewStyle]>([ + false, + StyleSheet.create({}), +]) +const useTextAncestorContext = () => React.useContext(TextAncestorContext) + +export default function ExpoUITextView({ + style, + children, + onPress, + ...rest +}: ExpoUITextViewProps) { + const [isAncestor, rootStyle] = useTextAncestorContext() + + // Flatten the styles, and apply the root styles when needed + const flattenedStyle = React.useMemo( + () => StyleSheet.flatten([rootStyle, style]), + [rootStyle, style], + ) + + if (!isAncestor) { + return ( + + + {React.Children.toArray(children).map((c, index) => { + if (React.isValidElement(c)) { + return c + } else if (typeof c === 'string') { + return ( + + ) + } + })} + + + ) + } else { + return ( + <> + {React.Children.toArray(children).map((c, index) => { + if (React.isValidElement(c)) { + return c + } else if (typeof c === 'string') { + return ( + + ) + } + })} + + ) + } +} diff --git a/modules/expo-selectable-text/src/ExpoUITextView.types.ts b/modules/expo-selectable-text/src/ExpoUITextView.types.ts new file mode 100644 index 0000000000..437283578b --- /dev/null +++ b/modules/expo-selectable-text/src/ExpoUITextView.types.ts @@ -0,0 +1,16 @@ +import React from 'react' +import {TextProps, TextStyle, ViewStyle} from 'react-native' + +export interface ExpoUITextViewProps extends TextProps {} + +export interface ExpoUITextViewNativeProps { + children: React.ReactNode + style: ViewStyle +} + +export interface ExpoUITextViewChildNativeProps extends ExpoUITextViewProps { + text: string + textStyle: TextStyle + onTextPress?: (...args: any[]) => void + onTextLongPress?: (...args: any[]) => void +} diff --git a/modules/expo-selectable-text/src/ExpoSelectableTextModule.ts b/modules/expo-selectable-text/src/ExpoUITextViewModule.ts similarity index 100% rename from modules/expo-selectable-text/src/ExpoSelectableTextModule.ts rename to modules/expo-selectable-text/src/ExpoUITextViewModule.ts diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx index 2271bb9fb1..988c445676 100644 --- a/src/view/com/lightbox/Lightbox.tsx +++ b/src/view/com/lightbox/Lightbox.tsx @@ -110,7 +110,8 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) { accessibilityRole="button"> + numberOfLines={isAltExpanded ? undefined : 3} + selectable> {altText} diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index cb7fd3f410..3f4c14d319 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -39,7 +39,7 @@ import { usePreferencesQuery, } from '#/state/queries/preferences' import {useSession} from '#/state/session' -import {isNative} from '#/platform/detection' +import {isAndroid, isNative} from '#/platform/detection' import {logger} from '#/logger' const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2} @@ -358,6 +358,7 @@ function PostThreadLoaded({ style={s.hContentRegion} // @ts-ignore our .web version only -prf desktopFixedHeight + removeClippedSubviews={isAndroid ? false : undefined} /> ) } diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 31f8cbbe91..5487f536d6 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -248,10 +248,9 @@ let PostThreadItemLoaded = ({ )} - @@ -446,7 +445,7 @@ let PostThreadItemLoaded = ({ /> - + ) diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index 1c9e73812c..4f898767d1 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -70,14 +70,14 @@ export const Link = memo(function Link({ const onPress = React.useCallback( (e?: Event) => { if (typeof href === 'string') { - return onTextLinkPress({ + return onPressInner( closeModal, navigation, - href: sanitizeUrl(href), + sanitizeUrl(href), navigationAction, openLink, e, - }) + ) } }, [closeModal, navigation, navigationAction, href, openLink], @@ -182,28 +182,40 @@ export const TextLink = memo(function TextLink({ props.onPress = React.useCallback( (e?: Event) => { - onTextLinkPress({ - onPress, - e, - openModal, + const requiresWarning = + warnOnMismatchingLabel && + linkRequiresWarning(href, typeof text === 'string' ? text : '') + if (requiresWarning) { + e?.preventDefault?.() + openModal({ + name: 'link-warning', + text: typeof text === 'string' ? text : '', + href, + }) + } + if (onPress) { + e?.preventDefault?.() + // @ts-ignore function signature differs by platform -prf + return onPress() + } + return onPressInner( closeModal, navigation, - href, - text, + sanitizeUrl(href), navigationAction, - warnOnMismatchingLabel, openLink, - }) + e, + ) }, [ onPress, - openModal, closeModal, + openModal, navigation, href, text, - navigationAction, warnOnMismatchingLabel, + navigationAction, openLink, ], ) @@ -294,9 +306,6 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({ ) }) -// Moving all of this logic into a separate function. Becuase we need to be able to use this function from -// SelectableText, it's easier to move it outside the component instead of duplicating logic - // NOTE // we can't use the onPress given by useLinkProps because it will // match most paths to the HomeTab routes while we actually want to @@ -308,47 +317,14 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({ // this method copies from the onPress implementation but adds our // needed customizations // -prf -export const onTextLinkPress = ({ - onPress, - e, - openModal, - closeModal, - navigation, - href, - text, - navigationAction = 'push', - warnOnMismatchingLabel, - openLink, -}: { - onPress?: (e: GestureResponderEvent) => void - e?: Event - openModal?: any - closeModal: any - navigation: NavigationProp - href: string - text?: any - navigationAction?: 'push' | 'replace' | 'navigate' - warnOnMismatchingLabel?: boolean - openLink: (href: string) => void -}) => { - const requiresWarning = - warnOnMismatchingLabel && - linkRequiresWarning(href, typeof text === 'string' ? text : '') - if (requiresWarning) { - e?.preventDefault?.() - openModal({ - name: 'link-warning', - text: typeof text === 'string' ? text : '', - href, - }) - } - if (onPress) { - e?.preventDefault?.() - // @ts-ignore function signature differs by platform -prf - onPress() - return - } - +function onPressInner( + closeModal = () => {}, + navigation: NavigationProp, + href: string, + navigationAction: 'push' | 'replace' | 'navigate' = 'push', + openLink: (href: string) => void, + e?: Event, +) { let shouldHandle = false const isLeftClick = // @ts-ignore Web only -prf diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx index 0a8d479936..910f5774be 100644 --- a/src/view/com/util/text/RichText.tsx +++ b/src/view/com/util/text/RichText.tsx @@ -44,7 +44,11 @@ export function RichText({ } return ( // @ts-ignore web only -prf - + {text} ) @@ -82,6 +86,7 @@ export function RichText({ href={`/profile/${mention.did}`} style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]} dataSet={WORD_WRAP} + selectable={selectable} />, ) } else if (link && AppBskyRichtextFacet.validateLink(link).success) { @@ -94,6 +99,7 @@ export function RichText({ style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]} dataSet={WORD_WRAP} warnOnMismatchingLabel + selectable={selectable} />, ) } else { diff --git a/src/view/com/util/text/Text.tsx b/src/view/com/util/text/Text.tsx index 91b960c57f..fc8ba5fe5f 100644 --- a/src/view/com/util/text/Text.tsx +++ b/src/view/com/util/text/Text.tsx @@ -1,9 +1,9 @@ import React from 'react' -import {StyleSheet, Text as RNText, TextProps} from 'react-native' +import {Text as RNText, TextProps} from 'react-native' import {s, lh} from 'lib/styles' import {useTheme, TypographyVariant} from 'lib/ThemeContext' -import {SelectableText} from '../../../../../modules/expo-selectable-text' import {isIOS} from 'platform/detection' +import ExpoSelectableTextView from '../../../../../modules/expo-selectable-text/src/ExpoUITextView' export type CustomTextProps = TextProps & { type?: TypographyVariant @@ -27,20 +27,13 @@ export function Text({ const typography = theme.typography[type] const lineHeightStyle = lineHeight ? lh(theme, type, lineHeight) : undefined - // if (false) { - // TODO remove if (selectable && isIOS) { return ( - + {children} - + ) } @@ -49,6 +42,7 @@ export function Text({ style={[s.black, typography, lineHeightStyle, style]} // @ts-ignore web only -esb dataSet={Object.assign({tooltip: title}, dataSet || {})} + selectable={selectable} {...props}> {children}