merge main in

what are you doing there? go away

fix recognizer to clear selected text on tap

remove jank/hacks

update readme

remove android stuff

(?) don't remove clipped subview on android for selection
enable selection of alt text

add numberOfLines
properly apply container styles

handle both selection and expand press events in alt text

far better implementation

revert link changes

revert lightbox changes for now

fix file name
This commit is contained in:
Hailey
2024-01-15 03:12:43 -08:00
parent ec8c05f3f0
commit aa96bba066
23 changed files with 413 additions and 490 deletions
+1 -9
View File
@@ -35,11 +35,7 @@ podspec inside of this directory.
## Technical ## Technical
Whenever we use the SelectableText component, we then loop over each of the children (or just the text if the child 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. is typeof Text). We create "segments" of text based on this.
/* 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.
The native side renders a *single* UITextView and adds each of the text segments to the view. Using NSAttributedStrings, 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). 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 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`) 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. 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.
@@ -1,6 +1,6 @@
{ {
"platforms": ["ios"], "platforms": ["ios"],
"ios": { "ios": {
"modules": ["ExpoSelectableTextModule"] "modules": ["ExpoUITextViewModule", "ExpoUITextViewChildModule"]
} }
} }
+2 -2
View File
@@ -1,4 +1,4 @@
import ExpoSelectableTextView from './src/ExpoSelectableTextView' import ExpoSelectableTextView from './src/ExpoUITextView'
import {ExpoProTextViewProps} from './src/ExpoSelectableText.types' import {ExpoProTextViewProps} from './src/ExpoUITextView.types'
export {ExpoSelectableTextView as SelectableText, type ExpoProTextViewProps} export {ExpoSelectableTextView as SelectableText, type ExpoProTextViewProps}
@@ -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)
}
}
}
}
@@ -1,147 +0,0 @@
import ExpoModulesCore
class ExpoSelectableTextView: ExpoView {
var textView: UITextView
var segments: Array<TextSegment> = [] {
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
}
}
@@ -1,8 +1,8 @@
Pod::Spec.new do |s| Pod::Spec.new do |s|
s.name = 'ExpoSelectableText' s.name = 'ExpoUITextView'
s.version = '1.0.0' s.version = '1.0.0'
s.summary = 'Simple wrapper for RN Text to use UITextView instead of UILabel' s.summary = 'Drop in replacement for RN Text that uses UITextView'
s.description = 'Simple wrapper for RN Text to use UITextView instead of UILabel' s.description = 'Drop in replacement for RN Text that uses UITextView'
s.author = '' s.author = ''
s.homepage = 'https://github.com/bluesky-social/social-app/modules/expo-selectable-text' s.homepage = 'https://github.com/bluesky-social/social-app/modules/expo-selectable-text'
s.platform = :ios, '13.0' s.platform = :ios, '13.0'
@@ -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()
}
}
@@ -0,0 +1,7 @@
import ExpoModulesCore
class ExpoUITextViewChild: ExpoView {
var text: String?
var style: TextStyle?
let onTextPress = EventDispatcher()
}
@@ -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
}
}
}
}
@@ -1,13 +1,15 @@
import ExpoModulesCore import ExpoModulesCore
struct TextSegments: Decodable { struct TextSegments: Record {
let segments: Array<TextSegment> @Field
var segments: Array<TextSegment>
} }
struct TextSegment: Decodable { struct TextSegment: Record {
let index: Int @Field
let text: String var index: Int
let style: TextStyle? @Field
let handlePress: Bool var text: String
let handleLongPress: Bool @Field
var style: TextStyle?
} }
@@ -1,16 +1,29 @@
struct TextStyle: Decodable { import ExpoModulesCore
struct TextStyle: Record {
@Field
var color: String? = "black" var color: String? = "black"
@Field
var fontSize: CGFloat? = 12 var fontSize: CGFloat? = 12
@Field
var fontStyle: String? = "normal" var fontStyle: String? = "normal"
@Field
var fontWeight: FontWeight? var fontWeight: FontWeight?
@Field
var letterSpacing: Double? var letterSpacing: Double?
@Field
var textAlign: String? = "auto" var textAlign: String? = "auto"
@Field
var lineHeight: Double? var lineHeight: Double?
@Field
var textDecorationLine: TextDecorationLine? var textDecorationLine: TextDecorationLine?
@Field
var flex: Int? var flex: Int?
@Field
var pointerEvents: String?
} }
enum FontWeight: String, Decodable { enum FontWeight: String, Enumerable {
case bold case bold
case normal case normal
case one = "100" case one = "100"
@@ -51,10 +64,9 @@ enum FontWeight: String, Decodable {
} }
} }
enum TextDecorationLine: String, Decodable { enum TextDecorationLine: String, Enumerable {
case underline case underline
case lineThrough = "line-through" case lineThrough = "line-through"
case underlineLineThrough = "underline line-through" case underlineLineThrough = "underline line-through"
case normal case normal
} }
@@ -1,4 +1,4 @@
public class ExpoSelectableTextUtil { public class TextUtil {
public static func hexToUIColor(hex: String?) -> UIColor { public static func hexToUIColor(hex: String?) -> UIColor {
guard let hex else { guard let hex else {
return UIColor.black return UIColor.black
@@ -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
}
@@ -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<ExpoProTextNativeViewProps> =
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<NavigationProp>()
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 <Text> or a <TextLink>. Check if the child is a string or a <TextLink>
// If it's a <TextLink> 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<any>).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 (
<View style={[dims, {width: '100%'}]}>
<NativeView
segments={segmentsJson}
selectable={selectable}
onTextPress={onTextPress}
onTextLongPress={onLongPress}
onTextLayout={onTextLayout}
disableLongPress={onLongPress !== undefined}
style={{flex: 1}}
rootStyle={rootStyle}
/>
</View>
)
}
@@ -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<ExpoUITextViewNativeProps> =
requireNativeViewManager('ExpoSelectableText')
const NativeViewChild: React.ComponentType<ExpoUITextViewChildNativeProps> =
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 (
<TextAncestorContext.Provider value={[true, flattenedStyle]}>
<NativeView style={{flex: 1}}>
{React.Children.toArray(children).map((c, index) => {
if (React.isValidElement(c)) {
return c
} else if (typeof c === 'string') {
return (
<NativeViewChild
key={index}
textStyle={flattenedStyle}
text={c}
onTextPress={onPress}
{...rest}
/>
)
}
})}
</NativeView>
</TextAncestorContext.Provider>
)
} else {
return (
<>
{React.Children.toArray(children).map((c, index) => {
if (React.isValidElement(c)) {
return c
} else if (typeof c === 'string') {
return (
<NativeViewChild
key={index}
textStyle={flattenedStyle}
text={c}
onTextPress={onPress}
{...rest}
/>
)
}
})}
</>
)
}
}
@@ -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
}
+2 -1
View File
@@ -110,7 +110,8 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) {
accessibilityRole="button"> accessibilityRole="button">
<Text <Text
style={[s.gray3, styles.footerText]} style={[s.gray3, styles.footerText]}
numberOfLines={isAltExpanded ? undefined : 3}> numberOfLines={isAltExpanded ? undefined : 3}
selectable>
{altText} {altText}
</Text> </Text>
</Pressable> </Pressable>
+2 -1
View File
@@ -39,7 +39,7 @@ import {
usePreferencesQuery, usePreferencesQuery,
} from '#/state/queries/preferences' } from '#/state/queries/preferences'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {isNative} from '#/platform/detection' import {isAndroid, isNative} from '#/platform/detection'
import {logger} from '#/logger' import {logger} from '#/logger'
const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2} const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2}
@@ -358,6 +358,7 @@ function PostThreadLoaded({
style={s.hContentRegion} style={s.hContentRegion}
// @ts-ignore our .web version only -prf // @ts-ignore our .web version only -prf
desktopFixedHeight desktopFixedHeight
removeClippedSubviews={isAndroid ? false : undefined}
/> />
) )
} }
+2 -3
View File
@@ -248,10 +248,9 @@ let PostThreadItemLoaded = ({
</View> </View>
)} )}
<Link <View
testID={`postThreadItem-by-${post.author.handle}`} testID={`postThreadItem-by-${post.author.handle}`}
style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]} style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
noFeedback
accessible={false}> accessible={false}>
<PostSandboxWarning /> <PostSandboxWarning />
<View style={styles.layout}> <View style={styles.layout}>
@@ -446,7 +445,7 @@ let PostThreadItemLoaded = ({
/> />
</View> </View>
</View> </View>
</Link> </View>
<WhoCanReply post={post} /> <WhoCanReply post={post} />
</> </>
) )
+33 -57
View File
@@ -70,14 +70,14 @@ export const Link = memo(function Link({
const onPress = React.useCallback( const onPress = React.useCallback(
(e?: Event) => { (e?: Event) => {
if (typeof href === 'string') { if (typeof href === 'string') {
return onTextLinkPress({ return onPressInner(
closeModal, closeModal,
navigation, navigation,
href: sanitizeUrl(href), sanitizeUrl(href),
navigationAction, navigationAction,
openLink, openLink,
e, e,
}) )
} }
}, },
[closeModal, navigation, navigationAction, href, openLink], [closeModal, navigation, navigationAction, href, openLink],
@@ -182,28 +182,40 @@ export const TextLink = memo(function TextLink({
props.onPress = React.useCallback( props.onPress = React.useCallback(
(e?: Event) => { (e?: Event) => {
onTextLinkPress({ const requiresWarning =
onPress, warnOnMismatchingLabel &&
e, linkRequiresWarning(href, typeof text === 'string' ? text : '')
openModal, 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, closeModal,
navigation, navigation,
href, sanitizeUrl(href),
text,
navigationAction, navigationAction,
warnOnMismatchingLabel,
openLink, openLink,
}) e,
)
}, },
[ [
onPress, onPress,
openModal,
closeModal, closeModal,
openModal,
navigation, navigation,
href, href,
text, text,
navigationAction,
warnOnMismatchingLabel, warnOnMismatchingLabel,
navigationAction,
openLink, 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 // NOTE
// we can't use the onPress given by useLinkProps because it will // we can't use the onPress given by useLinkProps because it will
// match most paths to the HomeTab routes while we actually want to // 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 // this method copies from the onPress implementation but adds our
// needed customizations // needed customizations
// -prf // -prf
export const onTextLinkPress = ({ function onPressInner(
onPress, closeModal = () => {},
e, navigation: NavigationProp,
openModal, href: string,
closeModal, navigationAction: 'push' | 'replace' | 'navigate' = 'push',
navigation, openLink: (href: string) => void,
href, e?: Event,
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
}
let shouldHandle = false let shouldHandle = false
const isLeftClick = const isLeftClick =
// @ts-ignore Web only -prf // @ts-ignore Web only -prf
+7 -1
View File
@@ -44,7 +44,11 @@ export function RichText({
} }
return ( return (
// @ts-ignore web only -prf // @ts-ignore web only -prf
<Text testID={testID} style={[style, pal.text]} dataSet={WORD_WRAP}> <Text
testID={testID}
style={[style, pal.text]}
dataSet={WORD_WRAP}
selectable={selectable}>
{text} {text}
</Text> </Text>
) )
@@ -82,6 +86,7 @@ export function RichText({
href={`/profile/${mention.did}`} href={`/profile/${mention.did}`}
style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]} style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}
dataSet={WORD_WRAP} dataSet={WORD_WRAP}
selectable={selectable}
/>, />,
) )
} else if (link && AppBskyRichtextFacet.validateLink(link).success) { } else if (link && AppBskyRichtextFacet.validateLink(link).success) {
@@ -94,6 +99,7 @@ export function RichText({
style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]} style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}
dataSet={WORD_WRAP} dataSet={WORD_WRAP}
warnOnMismatchingLabel warnOnMismatchingLabel
selectable={selectable}
/>, />,
) )
} else { } else {
+7 -13
View File
@@ -1,9 +1,9 @@
import React from 'react' 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 {s, lh} from 'lib/styles'
import {useTheme, TypographyVariant} from 'lib/ThemeContext' import {useTheme, TypographyVariant} from 'lib/ThemeContext'
import {SelectableText} from '../../../../../modules/expo-selectable-text'
import {isIOS} from 'platform/detection' import {isIOS} from 'platform/detection'
import ExpoSelectableTextView from '../../../../../modules/expo-selectable-text/src/ExpoUITextView'
export type CustomTextProps = TextProps & { export type CustomTextProps = TextProps & {
type?: TypographyVariant type?: TypographyVariant
@@ -27,20 +27,13 @@ export function Text({
const typography = theme.typography[type] const typography = theme.typography[type]
const lineHeightStyle = lineHeight ? lh(theme, type, lineHeight) : undefined const lineHeightStyle = lineHeight ? lh(theme, type, lineHeight) : undefined
// if (false) {
// TODO remove
if (selectable && isIOS) { if (selectable && isIOS) {
return ( return (
<SelectableText <ExpoSelectableTextView
selectable style={[s.black, typography, lineHeightStyle, style]}
style={StyleSheet.flatten([ {...props}>
s.black,
typography,
lineHeightStyle,
style,
])}>
{children} {children}
</SelectableText> </ExpoSelectableTextView>
) )
} }
@@ -49,6 +42,7 @@ export function Text({
style={[s.black, typography, lineHeightStyle, style]} style={[s.black, typography, lineHeightStyle, style]}
// @ts-ignore web only -esb // @ts-ignore web only -esb
dataSet={Object.assign({tooltip: title}, dataSet || {})} dataSet={Object.assign({tooltip: title}, dataSet || {})}
selectable={selectable}
{...props}> {...props}>
{children} {children}
</RNText> </RNText>