about summary refs log tree commit diff
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2024-01-23 14:14:46 -0800
committerPaul Frazee <pfrazee@gmail.com>2024-01-23 14:15:47 -0800
commita2b58852e75c7d8f18b65aab77b057dcb4693a21 (patch)
tree24898c7b00a750f103c253d90adced5f76e45c18
parentcda4fe4a7f4f9fe162b88bcf6638064226ab909a (diff)
downloadvoidsky-a2b58852e75c7d8f18b65aab77b057dcb4693a21.tar.zst
Merge PR #2495 by haileyok
Squashed commit of the following:

commit 9d9c46ced116079add8ae1beaed854b38962d608
Author: Paul Frazee <pfrazee@gmail.com>
Date:   Tue Jan 23 14:12:32 2024 -0800

    Fix reference error on the web build

commit 1981621c5b6f2b63b3e3875b68721161487d7df0
Merge: cda4fe4a 0d9b6954
Author: Paul Frazee <pfrazee@gmail.com>
Date:   Tue Jan 23 12:43:51 2024 -0800

    Merge branch 'feat/selectable-text' of https://github.com/haileyok/social-app into haileyok-feat/selectable-text

commit 0d9b6954472bb89f63be479d79986bb6d8b7e735
Merge: 3c381f94 f1a7a571
Author: Hailey <153161762+haileyok@users.noreply.github.com>
Date:   Fri Jan 19 16:42:13 2024 -0800

    Merge branch 'main' into feat/selectable-text

commit 3c381f94700167367b8519cb5d56360c51cea131
Merge: f9510156 fb596e7f
Author: Hailey <153161762+haileyok@users.noreply.github.com>
Date:   Thu Jan 18 23:48:10 2024 -0800

    Merge branch 'main' into feat/selectable-text

commit f951015637132d99d3523c1d93279b6b0b728293
Author: Hailey <me@haileyok.com>
Date:   Thu Jan 18 23:46:25 2024 -0800

    update readme

commit aa9b8b06eda6c4a00f7e4b0bcd5f7e5205c9b166
Author: Hailey <me@haileyok.com>
Date:   Thu Jan 18 23:37:49 2024 -0800

    calculate line height

commit 9fe479630c763fe3fe5dd7b8a5a6d82803f1ad06
Author: Hailey <me@haileyok.com>
Date:   Thu Jan 18 23:19:31 2024 -0800

    improve height calculation, render on prop changes

commit 209caffa7df30af933eff10ab16bf32d53b26df4
Author: Hailey <me@haileyok.com>
Date:   Thu Jan 18 22:53:08 2024 -0800

    presses

commit 384c8ec3a8774b075d0dca665d01de82ff9d19bd
Author: Hailey <me@haileyok.com>
Date:   Thu Jan 18 21:57:56 2024 -0800

    line break mode

commit adfcf05fe498b5ab6554e9b3fd399d7dd3ade79b
Author: Hailey <me@haileyok.com>
Date:   Thu Jan 18 21:50:21 2024 -0800

    onTextLayout event

commit e9ba104e6f12eb8144ee752335cdeecdfbf3d8e5
Author: Hailey <me@haileyok.com>
Date:   Thu Jan 18 21:34:35 2024 -0800

    better naming

commit e335f5ab7f813ec0d458476eeb91d0070fde0933
Author: Hailey <me@haileyok.com>
Date:   Thu Jan 18 21:31:38 2024 -0800

    remove android

commit 9e197934ba996a422ab03a204255a1b0b40d2d25
Author: Hailey <me@haileyok.com>
Date:   Thu Jan 18 21:28:28 2024 -0800

    remove expo module

commit 99882c7e3976a0cb59648e67f0eb4916f93f6830
Author: Hailey <me@haileyok.com>
Date:   Thu Jan 18 21:27:43 2024 -0800

    handle presses

commit 18f818649efcd1e18c810aaf4ea1a4cb93ddd111
Author: Hailey <me@haileyok.com>
Date:   Thu Jan 18 21:14:38 2024 -0800

    make use of rctshadowview

commit 7134e1106e338013555c984607d51124727b9264
Author: Hailey <me@haileyok.com>
Date:   Wed Jan 17 20:38:39 2024 -0800

    stop unnecessary layouts, resize container before setting text

commit 340b84f053d48e45a5e4e9648ac4f87fc00e5f4a
Author: Hailey <me@haileyok.com>
Date:   Wed Jan 17 11:17:36 2024 -0800

    handle prop changes for both children and root views

commit d906fe4fcfa4a919dbb66f4ec3f17e8f8be8bf02
Author: Hailey <me@haileyok.com>
Date:   Tue Jan 16 18:42:22 2024 -0800

    handle onpress better

commit b6b096416894893973be54793f4d3e3f08974293
Author: Hailey <me@haileyok.com>
Date:   Tue Jan 16 16:57:31 2024 -0800

    resolve animation issue, animate alt text expansion

commit daedd1f671fc933af27e2953b52b3a08eddb7c92
Author: Hailey <me@haileyok.com>
Date:   Tue Jan 16 15:47:24 2024 -0800

    move getChildren to didMoveToWindow

commit 87d44e4b576cce56a12a1f887e1b9605db1427aa
Author: Hailey <me@haileyok.com>
Date:   Mon Jan 15 18:48:36 2024 -0800

    simplify getPressed

commit d92584bad7db7179d95f155bd480854df8fae17f
Author: Hailey <me@haileyok.com>
Date:   Mon Jan 15 17:56:43 2024 -0800

    just more cleanup

commit d39f7a937dc8b47b98d120469db35d697bcf74be
Author: Hailey <me@haileyok.com>
Date:   Mon Jan 15 17:03:19 2024 -0800

    remove unnecessary property for gesture recognizer

commit a35513a1d236bcd94aab0e7c5ac1cd0907f61762
Author: Hailey <me@haileyok.com>
Date:   Mon Jan 15 16:55:36 2024 -0800

    remove debug line

commit 788956aa01d2b46783ad0d0a45949fc5ca9e0aab
Author: Hailey <me@haileyok.com>
Date:   Mon Jan 15 16:33:44 2024 -0800

    typo

commit a3ba6e782542a8e9ca09b5b49b1043ba046dcc70
Author: Hailey <me@haileyok.com>
Date:   Mon Jan 15 13:42:25 2024 -0800

    make alt text selectable

commit e5472a13da277ef7cccb870d62197dd86b9c3e86
Author: Hailey <me@haileyok.com>
Date:   Mon Jan 15 05:27:15 2024 -0800

    re-render on numberOfLines change

commit 9f5b7602c11a92cb83704feb3946fe6b4f584fa5
Author: Hailey <me@haileyok.com>
Date:   Mon Jan 15 04:57:35 2024 -0800

    more implementations

commit aa96bba0664d14f12ee742739c70847407062f35
Author: Hailey <me@haileyok.com>
Date:   Mon Jan 15 03:12:43 2024 -0800

    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

commit ec8c05f3f05949b6e3ae8be2e4d153d7d51b18f9
Merge: 2435a252 12a0ceee
Author: Hailey <me@haileyok.com>
Date:   Fri Jan 12 23:41:10 2024 -0800

    Merge branch 'main' into feat/selectable-text

    # Conflicts:
    #	src/view/com/util/Link.tsx

commit 2435a25257c4a3b12c38949b1928848a0acf1a97
Author: Hailey <me@haileyok.com>
Date:   Fri Jan 12 23:30:13 2024 -0800

    cleanup

commit fdf75927f6fc176a390a11cba56e462c6fe48bdf
Author: Hailey <me@haileyok.com>
Date:   Fri Jan 12 23:25:23 2024 -0800

    remove debug

commit 36d8cd82ef57483dcf3740c803c6524bc76e87c9
Author: Hailey <me@haileyok.com>
Date:   Fri Jan 12 23:25:17 2024 -0800

    reset text selection on text update

commit b8f7bc23c2df8532941af8b62a4d36a4814c5965
Author: Hailey <me@haileyok.com>
Date:   Fri Jan 12 23:24:43 2024 -0800

    use textkit 1

commit 5216464458f4ffd1d6384a1d15ca7be5e8a96d5d
Author: Hailey <me@haileyok.com>
Date:   Fri Jan 12 22:50:15 2024 -0800

    properly handle link press events

commit 2802902c69f5d68140c3b573115e8e73638ce9b5
Author: Hailey <me@haileyok.com>
Date:   Fri Jan 12 22:49:47 2024 -0800

    modify Link so that we can create the TextLink press handler outside

commit 860610e63ab15cfa9b18da317243137b35a6bf6d
Author: Hailey <me@haileyok.com>
Date:   Fri Jan 12 19:17:51 2024 -0800

    always make sure we use the latest styles

commit 7f05d0141b6355aa4f521f91056edc06ffc2f5ba
Author: Hailey <me@haileyok.com>
Date:   Fri Jan 12 16:57:08 2024 -0800

    update readme with tech info

commit b8318446a34d07fb0fc37029c3143d0b81eb2b29
Author: Hailey <me@haileyok.com>
Date:   Fri Jan 12 16:34:35 2024 -0800

    remove all uitextview padding

commit 0f0b6aa131a1e68e0e4eeb456157c866ebc85de3
Author: Hailey <me@haileyok.com>
Date:   Fri Jan 12 16:34:28 2024 -0800

    cleanup imports

commit c9f0064836d5fe26c55ce571b5d1abf5678ca3a5
Author: Hailey <me@haileyok.com>
Date:   Fri Jan 12 16:18:08 2024 -0800

    update interface

commit 7dcac644baeedb506f91f1f4dcaf80dbfb46f610
Author: Hailey <me@haileyok.com>
Date:   Fri Jan 12 16:13:49 2024 -0800

    remove useless struct

commit 5174744213c97cb74ca7fe3a513a3abc108fe83d
Author: Hailey <me@haileyok.com>
Date:   Fri Jan 12 16:13:34 2024 -0800

    adjust deps

commit ce8b9ed62bcf484ad498b0de05998d8986b132ac
Author: Hailey <me@haileyok.com>
Date:   Thu Jan 11 22:15:50 2024 -0800

    add readme, update info

commit 33c6e3b15c64bcb952b62d1f5c3100c517a64c57
Author: Hailey <me@haileyok.com>
Date:   Thu Jan 11 22:04:53 2024 -0800

    remove unnecessary android/web stuff

commit fbca531bdfeff90bd2a99214482e102f2601c453
Author: Hailey <me@haileyok.com>
Date:   Thu Jan 11 22:02:30 2024 -0800

    simplify cast of string.index to int before i forget

commit 648552eafbc3bf861567ca160c6e84295eec26f8
Author: Hailey <me@haileyok.com>
Date:   Thu Jan 11 02:01:20 2024 -0800

    wip

commit c6d2e54923e779180f456bef3ba275dcb2f74d5d
Author: Hailey <me@haileyok.com>
Date:   Thu Jan 11 00:38:47 2024 -0800

    selectable text experiment
-rw-r--r--.gitignore4
-rw-r--r--modules/react-native-ui-text-view/README.md61
-rw-r--r--modules/react-native-ui-text-view/ios/RNUITextView-Bridging-Header.h3
-rw-r--r--modules/react-native-ui-text-view/ios/RNUITextView.swift141
-rw-r--r--modules/react-native-ui-text-view/ios/RNUITextViewChild.swift4
-rw-r--r--modules/react-native-ui-text-view/ios/RNUITextViewChildShadow.swift56
-rw-r--r--modules/react-native-ui-text-view/ios/RNUITextViewManager.m25
-rw-r--r--modules/react-native-ui-text-view/ios/RNUITextViewManager.swift30
-rw-r--r--modules/react-native-ui-text-view/ios/RNUITextViewShadow.swift147
-rw-r--r--modules/react-native-ui-text-view/package.json9
-rw-r--r--modules/react-native-ui-text-view/react-native-ui-text-view.podspec42
-rw-r--r--modules/react-native-ui-text-view/src/UITextView.tsx76
-rw-r--r--modules/react-native-ui-text-view/src/index.tsx42
-rw-r--r--package.json3
-rw-r--r--src/view/com/lightbox/Lightbox.tsx30
-rw-r--r--src/view/com/post-thread/PostThread.tsx3
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx6
-rw-r--r--src/view/com/util/text/RichText.tsx16
-rw-r--r--src/view/com/util/text/Text.tsx16
-rw-r--r--yarn.lock4
20 files changed, 694 insertions, 24 deletions
diff --git a/.gitignore b/.gitignore
index 7a6137b19..f96d0d5ff 100644
--- a/.gitignore
+++ b/.gitignore
@@ -91,8 +91,8 @@ web-build/
 
 
 # Android & iOS folders
-android/
-ios/
+/android/
+/ios/
 
 # environment variables
 .env
diff --git a/modules/react-native-ui-text-view/README.md b/modules/react-native-ui-text-view/README.md
new file mode 100644
index 000000000..b19ac8967
--- /dev/null
+++ b/modules/react-native-ui-text-view/README.md
@@ -0,0 +1,61 @@
+# React Native UITextView
+
+Drop in replacement for `<Text>` that renders a `UITextView`, support selection and native translation features on iOS.
+
+## Installation
+
+In this project, no installation is required. The pod will be installed automatically during a `pod install`.
+
+In another project, clone the repo and copy the `modules/react-native-ui-text-view` directory to your own project
+directory. Afterward, run `pod install`.
+
+## Usage
+
+Replace the outermost `<Text>` with `<UITextView>`. Styles and press events should be handled the same way they would
+with `<Text>`. Both `<UITextView>` and `<Text>` are supported as children of the root `<UITextView>`.
+
+## Technical
+
+React Native's `Text` component allows for "infinite" nesting of further `Text` components. To make a true "drop-in",
+we want to do the same thing.
+
+To achieve this, we first need to handle determining if we are dealing with an ancestor or root `UITextView` component.
+We can implement similar logic to the `Text` component [see Text.js](https://github.com/facebook/react-native/blob/7f2529de7bc9ab1617eaf571e950d0717c3102a6/packages/react-native/Libraries/Text/Text.js).
+
+We create a context that contains a boolean to tell us if we have already rendered the root `UITextView`. We also store
+the root styles so that we can apply those styles if the ancestor `UITextView`s have not overwritten those styles.
+
+All of our children are placed into `RNUITextView`, which is the main native view that will display the iOS `UITextView`.
+
+We next map each child into the view. We have to be careful here to check if the child's `children` prop is a string. If
+it is, that means we have encountered what was once an RN `Text` component. RN doesn't let us pass plain text as
+children outside of `Text`, so we instead just pass the text into the `text` prop on `RNUITextViewChild`. We continue 
+down the tree, until we run out of children.
+
+On the native side, we make use of the shadow view to calculate text container dimensions before the views are mounted.
+We cannot simply set the `UITextView` text first, since React will not have properly measured the layout before this
+occurs.
+
+
+As for `Text` props, the following props are implemented:
+
+- All accessibility props
+- `allowFontScaling`
+- `adjustsFontSizeToFit`
+- `ellipsizeMode`
+- `numberOfLines`
+- `onLayout`
+- `onPress`
+- `onTextLayout`
+- `selectable`
+
+All `ViewStyle` props will apply to the root `UITextView`. Individual children will respect these `TextStyle` styles:
+
+- `color`
+- `fontSize`
+- `fontStyle`
+- `fontWeight`
+- `fontVariant`
+- `letterSpacing`
+- `lineHeight`
+- `textDecorationLine`
diff --git a/modules/react-native-ui-text-view/ios/RNUITextView-Bridging-Header.h b/modules/react-native-ui-text-view/ios/RNUITextView-Bridging-Header.h
new file mode 100644
index 000000000..e669b47eb
--- /dev/null
+++ b/modules/react-native-ui-text-view/ios/RNUITextView-Bridging-Header.h
@@ -0,0 +1,3 @@
+#import <React/RCTViewManager.h>
+#import <React/RCTBridge.h>
+#import <React/RCTBridge+Private.h>
diff --git a/modules/react-native-ui-text-view/ios/RNUITextView.swift b/modules/react-native-ui-text-view/ios/RNUITextView.swift
new file mode 100644
index 000000000..9c21d45b5
--- /dev/null
+++ b/modules/react-native-ui-text-view/ios/RNUITextView.swift
@@ -0,0 +1,141 @@
+class RNUITextView: UIView {
+  var textView: UITextView
+
+  @objc var numberOfLines: Int = 0 {
+    didSet {
+      textView.textContainer.maximumNumberOfLines = numberOfLines
+    }
+  }
+  @objc var selectable: Bool = true {
+    didSet {
+      textView.isSelectable = selectable
+    }
+  }
+  @objc var ellipsizeMode: String = "tail" {
+    didSet {
+      textView.textContainer.lineBreakMode = self.getLineBreakMode()
+    }
+  }
+  @objc var onTextLayout: RCTDirectEventBlock?
+
+  override init(frame: CGRect) {
+    if #available(iOS 16.0, *) {
+      textView = UITextView(usingTextLayoutManager: false)
+    } else {
+      textView = UITextView()
+    }
+
+    // Disable scrolling
+    textView.isScrollEnabled = false
+    // Remove all the padding
+    textView.textContainerInset = .zero
+    textView.textContainer.lineFragmentPadding = 0
+
+    // Remove other properties
+    textView.isEditable = false
+    textView.backgroundColor = .clear
+
+    // Init
+    super.init(frame: frame)
+    self.clipsToBounds = true
+
+    // Add the view
+    addSubview(textView)
+
+    let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
+    tapGestureRecognizer.isEnabled = true
+    textView.addGestureRecognizer(tapGestureRecognizer)
+  }
+
+  required init?(coder: NSCoder) {
+    fatalError("init(coder:) has not been implemented")
+  }
+
+  // Resolves some animation issues
+  override func reactSetFrame(_ frame: CGRect) {
+    UIView.performWithoutAnimation {
+      super.reactSetFrame(frame)
+    }
+  }
+
+  func setText(string: NSAttributedString, size: CGSize, numberOfLines: Int) -> Void {
+    self.textView.frame.size = size
+    self.textView.textContainer.maximumNumberOfLines = numberOfLines
+    self.textView.attributedText = string
+    self.textView.selectedTextRange = nil
+
+    if let onTextLayout = self.onTextLayout {
+      var lines: [String] = []
+      textView.layoutManager.enumerateLineFragments(
+        forGlyphRange: NSRange(location: 0, length: textView.attributedText.length))
+      { (rect, usedRect, textContainer, glyphRange, stop) in
+        let characterRange = self.textView.layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil)
+        let line = (self.textView.text as NSString).substring(with: characterRange)
+        lines.append(line)
+      }
+
+      onTextLayout([
+        "lines": lines
+      ])
+    }
+  }
+
+  @IBAction func callOnPress(_ sender: UITapGestureRecognizer) -> Void {
+    // If we find a child, then call onPress
+    if let child = getPressed(sender) {
+      if textView.selectedTextRange == nil, let onPress = child.onPress {
+        onPress(["": ""])
+      } 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) -> RNUITextViewChild? {
+    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
+    )
+
+    for child in self.reactSubviews() {
+      if let child = child as? RNUITextViewChild, let childText = child.text {
+        let fullText = self.textView.attributedText.string
+        let range = fullText.range(of: childText)
+
+        if let lowerBound = range?.lowerBound, let upperBound = range?.upperBound {
+          if charIndex >= lowerBound.utf16Offset(in: fullText) && charIndex <= upperBound.utf16Offset(in: fullText) {
+            return child
+          }
+        }
+      }
+    }
+
+    return nil
+  }
+
+  func getLineBreakMode() -> NSLineBreakMode {
+    switch self.ellipsizeMode {
+    case "head":
+      return .byTruncatingHead
+    case "middle":
+      return .byTruncatingMiddle
+    case "tail":
+      return .byTruncatingTail
+    case "clip":
+      return .byClipping
+    default:
+      return .byTruncatingTail
+    }
+  }
+}
diff --git a/modules/react-native-ui-text-view/ios/RNUITextViewChild.swift b/modules/react-native-ui-text-view/ios/RNUITextViewChild.swift
new file mode 100644
index 000000000..c341c46e4
--- /dev/null
+++ b/modules/react-native-ui-text-view/ios/RNUITextViewChild.swift
@@ -0,0 +1,4 @@
+class RNUITextViewChild: UIView {
+  @objc var text: String?
+  @objc var onPress: RCTDirectEventBlock?
+}
diff --git a/modules/react-native-ui-text-view/ios/RNUITextViewChildShadow.swift b/modules/react-native-ui-text-view/ios/RNUITextViewChildShadow.swift
new file mode 100644
index 000000000..09119a369
--- /dev/null
+++ b/modules/react-native-ui-text-view/ios/RNUITextViewChildShadow.swift
@@ -0,0 +1,56 @@
+// We want all of our props to be available in the child's shadow view so we
+// can create the attributed text before mount and calculate the needed size
+// for the view.
+class RNUITextViewChildShadow: RCTShadowView {
+  @objc var text: String = ""
+  @objc var color: UIColor = .black
+  @objc var fontSize: CGFloat = 16.0
+  @objc var fontStyle: String = "normal"
+  @objc var fontWeight: String = "normal"
+  @objc var letterSpacing: CGFloat = 0.0
+  @objc var lineHeight: CGFloat = 0.0
+  @objc var pointerEvents: NSString?
+
+  override func isYogaLeafNode() -> Bool {
+    return true
+  }
+
+  override func didSetProps(_ changedProps: [String]!) {
+    guard let superview = self.superview as? RNUITextViewShadow else {
+      return
+    }
+
+    if !YGNodeIsDirty(superview.yogaNode) {
+      superview.setAttributedText()
+    }
+  }
+
+  func getFontWeight() -> UIFont.Weight {
+    switch self.fontWeight {
+    case "bold":
+      return .bold
+    case "normal":
+      return .regular
+    case "100":
+      return .ultraLight
+    case "200":
+      return .ultraLight
+    case "300":
+      return .light
+    case "400":
+      return .regular
+    case "500":
+      return .medium
+    case "600":
+      return .semibold
+    case "700":
+      return .semibold
+    case "800":
+      return .bold
+    case "900":
+      return .heavy
+    default:
+      return .regular
+    }
+  }
+}
diff --git a/modules/react-native-ui-text-view/ios/RNUITextViewManager.m b/modules/react-native-ui-text-view/ios/RNUITextViewManager.m
new file mode 100644
index 000000000..9a6f0285c
--- /dev/null
+++ b/modules/react-native-ui-text-view/ios/RNUITextViewManager.m
@@ -0,0 +1,25 @@
+#import <React/RCTViewManager.h>
+
+@interface RCT_EXTERN_MODULE(RNUITextViewManager, RCTViewManager)
+RCT_REMAP_SHADOW_PROPERTY(numberOfLines, numberOfLines, NSInteger)
+RCT_REMAP_SHADOW_PROPERTY(allowsFontScaling, allowsFontScaling, BOOL)
+
+RCT_EXPORT_VIEW_PROPERTY(onTextLayout, RCTDirectEventBlock)
+RCT_EXPORT_VIEW_PROPERTY(ellipsizeMode, NSString)
+RCT_EXPORT_VIEW_PROPERTY(selectable, BOOL)
+
+@end
+
+@interface RCT_EXTERN_MODULE(RNUITextViewChildManager, RCTViewManager)
+RCT_REMAP_SHADOW_PROPERTY(text, text, NSString)
+RCT_REMAP_SHADOW_PROPERTY(color, color, UIColor)
+RCT_REMAP_SHADOW_PROPERTY(fontSize, fontSize, CGFloat)
+RCT_REMAP_SHADOW_PROPERTY(fontStyle, fontStyle, NSString)
+RCT_REMAP_SHADOW_PROPERTY(fontWeight, fontWeight, NSString)
+RCT_REMAP_SHADOW_PROPERTY(letterSpacing, letterSpacing, CGFloat)
+RCT_REMAP_SHADOW_PROPERTY(lineHeight, lineHeight, CGFloat)
+RCT_REMAP_SHADOW_PROPERTY(pointerEvents, pointerEvents, NSString)
+
+RCT_EXPORT_VIEW_PROPERTY(text, NSString)
+RCT_EXPORT_VIEW_PROPERTY(onPress, RCTBubblingEventBlock)
+@end
diff --git a/modules/react-native-ui-text-view/ios/RNUITextViewManager.swift b/modules/react-native-ui-text-view/ios/RNUITextViewManager.swift
new file mode 100644
index 000000000..297bcbbb2
--- /dev/null
+++ b/modules/react-native-ui-text-view/ios/RNUITextViewManager.swift
@@ -0,0 +1,30 @@
+@objc(RNUITextViewManager)
+class RNUITextViewManager: RCTViewManager {
+  override func view() -> (RNUITextView) {
+    return RNUITextView()
+  }
+
+  @objc override static func requiresMainQueueSetup() -> Bool {
+    return true
+  }
+
+  override func shadowView() -> RCTShadowView {
+    // Pass the bridge to the shadow view
+    return RNUITextViewShadow(bridge: self.bridge)
+  }
+}
+
+@objc(RNUITextViewChildManager)
+class RNUITextViewChildManager: RCTViewManager {
+  override func view() -> (RNUITextViewChild) {
+    return RNUITextViewChild()
+  }
+
+  @objc override static func requiresMainQueueSetup() -> Bool {
+    return true
+  }
+
+  override func shadowView() -> RCTShadowView {
+    return RNUITextViewChildShadow()
+  }
+}
diff --git a/modules/react-native-ui-text-view/ios/RNUITextViewShadow.swift b/modules/react-native-ui-text-view/ios/RNUITextViewShadow.swift
new file mode 100644
index 000000000..4f3eda43c
--- /dev/null
+++ b/modules/react-native-ui-text-view/ios/RNUITextViewShadow.swift
@@ -0,0 +1,147 @@
+class RNUITextViewShadow: RCTShadowView {
+  // Props
+  @objc var numberOfLines: Int = 0 {
+    didSet {
+      if !YGNodeIsDirty(self.yogaNode) {
+        self.setAttributedText()
+      }
+    }
+  }
+  @objc var allowsFontScaling: Bool = true
+
+  var attributedText: NSAttributedString = NSAttributedString()
+  var frameSize: CGSize = CGSize()
+
+  var lineHeight: CGFloat = 0
+
+  var bridge: RCTBridge
+
+  init(bridge: RCTBridge) {
+    self.bridge = bridge
+    super.init()
+
+    // We need to set a custom measure func here to calculate the height correctly
+    YGNodeSetMeasureFunc(self.yogaNode) { node, width, widthMode, height, heightMode in
+      // Get the shadowview and determine the needed size to set
+      let shadowView = Unmanaged<RNUITextViewShadow>.fromOpaque(YGNodeGetContext(node)).takeUnretainedValue()
+      return shadowView.getNeededSize(maxWidth: width)
+    }
+
+    // Subscribe to ynamic type size changes
+    NotificationCenter.default.addObserver(
+      self,
+      selector: #selector(preferredContentSizeChanged(_:)),
+      name: UIContentSizeCategory.didChangeNotification,
+      object: nil
+    )
+  }
+
+  @objc func preferredContentSizeChanged(_ notification: Notification) {
+    self.setAttributedText()
+  }
+
+  // Tell yoga not to use flexbox
+  override func isYogaLeafNode() -> Bool {
+    return true
+  }
+
+  // We only need to insert text children
+  override func insertReactSubview(_ subview: RCTShadowView!, at atIndex: Int) {
+    if subview.isKind(of: RNUITextViewChildShadow.self) {
+      super.insertReactSubview(subview, at: atIndex)
+    }
+  }
+
+  // Whenever the subvies update, set the text
+  override func didUpdateReactSubviews() {
+    self.setAttributedText()
+  }
+
+  // Whenever we layout, update the UI
+  override func layoutSubviews(with layoutContext: RCTLayoutContext) {
+    // Don't do anything if the layout is dirty
+    if(YGNodeIsDirty(self.yogaNode)) {
+      return
+    }
+
+    // Update the text
+    self.bridge.uiManager.addUIBlock { uiManager, viewRegistry in
+      guard let textView = viewRegistry?[self.reactTag] as? RNUITextView else {
+        return
+      }
+      textView.setText(string: self.attributedText, size: self.frameSize, numberOfLines: self.numberOfLines)
+    }
+  }
+
+  override func dirtyLayout() {
+    super.dirtyLayout()
+    YGNodeMarkDirty(self.yogaNode)
+  }
+
+  // Update the attributed text whenever changes are made to the subviews.
+  func setAttributedText() -> Void {
+    // Create an attributed string to store each of the segments
+    let finalAttributedString = NSMutableAttributedString()
+
+    self.reactSubviews().forEach { child in
+      guard let child = child as? RNUITextViewChildShadow else {
+        return
+      }
+      let scaledFontSize = self.allowsFontScaling ?
+        UIFontMetrics.default.scaledValue(for: child.fontSize) : child.fontSize
+      let font = UIFont.systemFont(ofSize: scaledFontSize, weight: child.getFontWeight())
+
+      // Set some generic attributes that don't need ranges
+      let attributes: [NSAttributedString.Key:Any] = [
+        .font: font,
+        .foregroundColor: child.color,
+      ]
+
+      // Create the attributed string with the generic attributes
+      let string = NSMutableAttributedString(string: child.text, attributes: attributes)
+
+      // Set the paragraph style attributes if necessary
+      let paragraphStyle = NSMutableParagraphStyle()
+      if child.lineHeight != 0.0 {
+        paragraphStyle.minimumLineHeight = child.lineHeight
+        paragraphStyle.maximumLineHeight = child.lineHeight
+        string.addAttribute(
+          NSAttributedString.Key.paragraphStyle,
+          value: paragraphStyle,
+          range: NSMakeRange(0, string.length)
+        )
+
+        // Store that height
+        self.lineHeight = child.lineHeight
+      } else {
+        self.lineHeight = font.lineHeight
+      }
+
+      finalAttributedString.append(string)
+    }
+
+    self.attributedText = finalAttributedString
+    self.dirtyLayout()
+  }
+
+  // Create a YGSize based on the max width
+  func getNeededSize(maxWidth: Float) -> YGSize {
+    // Create the max size and figure out the size of the entire text
+    let maxSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(MAXFLOAT))
+    let textSize = self.attributedText.boundingRect(with: maxSize, options: .usesLineFragmentOrigin, context: nil)
+
+    // Figure out how many total lines there are
+    let totalLines = Int(ceil(textSize.height / self.lineHeight))
+
+    // Default to the text size
+    var neededSize: CGSize = textSize.size
+
+    // If the total lines > max number, return size with the max
+    if self.numberOfLines != 0, totalLines > self.numberOfLines {
+      neededSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(CGFloat(self.numberOfLines) * self.lineHeight))
+    }
+
+    self.frameSize = neededSize
+    return YGSize(width: Float(neededSize.width), height: Float(neededSize.height))
+  }
+}
diff --git a/modules/react-native-ui-text-view/package.json b/modules/react-native-ui-text-view/package.json
new file mode 100644
index 000000000..184a9014e
--- /dev/null
+++ b/modules/react-native-ui-text-view/package.json
@@ -0,0 +1,9 @@
+{
+  "name": "react-native-ui-text-view",
+  "version": "0.1.0",
+  "description": "UITextView in React Native on iOS",
+  "main": "src/index",
+  "author": "haileyok",
+  "license": "MIT",
+  "homepage": "https://github.com/bluesky-social/social-app/modules/react-native-ui-text-view"
+}
diff --git a/modules/react-native-ui-text-view/react-native-ui-text-view.podspec b/modules/react-native-ui-text-view/react-native-ui-text-view.podspec
new file mode 100644
index 000000000..1e0dee93f
--- /dev/null
+++ b/modules/react-native-ui-text-view/react-native-ui-text-view.podspec
@@ -0,0 +1,42 @@
+require "json"
+
+package = JSON.parse(File.read(File.join(__dir__, "package.json")))
+folly_compiler_flags = '-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1 -Wno-comma -Wno-shorten-64-to-32'
+
+Pod::Spec.new do |s|
+  s.name         = "react-native-ui-text-view"
+  s.version      = package["version"]
+  s.summary      = package["description"]
+  s.homepage     = package["homepage"]
+  s.license      = package["license"]
+  s.authors      = package["author"]
+
+  s.platforms    = { :ios => "11.0" }
+  s.source       = { :git => ".git", :tag => "#{s.version}" }
+
+  s.source_files = "ios/**/*.{h,m,mm,swift}"
+
+  # Use install_modules_dependencies helper to install the dependencies if React Native version >=0.71.0.
+  # See https://github.com/facebook/react-native/blob/febf6b7f33fdb4904669f99d795eba4c0f95d7bf/scripts/cocoapods/new_architecture.rb#L79.
+  if respond_to?(:install_modules_dependencies, true)
+    install_modules_dependencies(s)
+  else
+  s.dependency "React-Core"
+
+  # Don't install the dependencies when we run `pod install` in the old architecture.
+  if ENV['RCT_NEW_ARCH_ENABLED'] == '1' then
+    s.compiler_flags = folly_compiler_flags + " -DRCT_NEW_ARCH_ENABLED=1"
+    s.pod_target_xcconfig    = {
+        "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/boost\"",
+        "OTHER_CPLUSPLUSFLAGS" => "-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1",
+        "CLANG_CXX_LANGUAGE_STANDARD" => "c++17"
+    }
+    s.dependency "React-RCTFabric"
+    s.dependency "React-Codegen"
+    s.dependency "RCT-Folly"
+    s.dependency "RCTRequired"
+    s.dependency "RCTTypeSafety"
+    s.dependency "ReactCommon/turbomodule/core"
+   end
+  end    
+end
diff --git a/modules/react-native-ui-text-view/src/UITextView.tsx b/modules/react-native-ui-text-view/src/UITextView.tsx
new file mode 100644
index 000000000..bbb45dccc
--- /dev/null
+++ b/modules/react-native-ui-text-view/src/UITextView.tsx
@@ -0,0 +1,76 @@
+import React from 'react'
+import {Platform, StyleSheet, TextProps, ViewStyle} from 'react-native'
+import {RNUITextView, RNUITextViewChild} from './index'
+
+const TextAncestorContext = React.createContext<[boolean, ViewStyle]>([
+  false,
+  StyleSheet.create({}),
+])
+const useTextAncestorContext = () => React.useContext(TextAncestorContext)
+
+const textDefaults: TextProps = {
+  allowFontScaling: true,
+  selectable: true,
+}
+
+export function UITextView({style, children, ...rest}: TextProps) {
+  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 (Platform.OS !== 'ios') {
+    throw new Error('UITextView is only available on iOS')
+  }
+
+  if (!isAncestor) {
+    return (
+      <TextAncestorContext.Provider value={[true, flattenedStyle]}>
+        <RNUITextView
+          {...textDefaults}
+          {...rest}
+          ellipsizeMode={rest.ellipsizeMode ?? rest.lineBreakMode ?? 'tail'}
+          style={[{flex: 1}, flattenedStyle]}
+          onPress={undefined} // We want these to go to the children only
+          onLongPress={undefined}>
+          {React.Children.toArray(children).map((c, index) => {
+            if (React.isValidElement(c)) {
+              return c
+            } else if (typeof c === 'string') {
+              return (
+                <RNUITextViewChild
+                  key={index}
+                  style={flattenedStyle}
+                  text={c}
+                  {...rest}
+                />
+              )
+            }
+          })}
+        </RNUITextView>
+      </TextAncestorContext.Provider>
+    )
+  } else {
+    return (
+      <>
+        {React.Children.toArray(children).map((c, index) => {
+          if (React.isValidElement(c)) {
+            return c
+          } else if (typeof c === 'string') {
+            return (
+              <RNUITextViewChild
+                key={index}
+                style={flattenedStyle}
+                text={c}
+                {...rest}
+              />
+            )
+          }
+        })}
+      </>
+    )
+  }
+}
diff --git a/modules/react-native-ui-text-view/src/index.tsx b/modules/react-native-ui-text-view/src/index.tsx
new file mode 100644
index 000000000..d5bde136f
--- /dev/null
+++ b/modules/react-native-ui-text-view/src/index.tsx
@@ -0,0 +1,42 @@
+import {
+  requireNativeComponent,
+  UIManager,
+  Platform,
+  type ViewStyle,
+  TextProps,
+} from 'react-native'
+
+const LINKING_ERROR =
+  `The package 'react-native-ui-text-view' doesn't seem to be linked. Make sure: \n\n` +
+  Platform.select({ios: "- You have run 'pod install'\n", default: ''}) +
+  '- You rebuilt the app after installing the package\n' +
+  '- You are not using Expo Go\n'
+
+export interface RNUITextViewProps extends TextProps {
+  children: React.ReactNode
+  style: ViewStyle[]
+}
+
+export interface RNUITextViewChildProps extends TextProps {
+  text: string
+  onTextPress?: (...args: any[]) => void
+  onTextLongPress?: (...args: any[]) => void
+}
+
+export const RNUITextView =
+  UIManager.getViewManagerConfig &&
+  UIManager.getViewManagerConfig('RNUITextView') != null
+    ? requireNativeComponent<RNUITextViewProps>('RNUITextView')
+    : () => {
+        throw new Error(LINKING_ERROR)
+      }
+
+export const RNUITextViewChild =
+  UIManager.getViewManagerConfig &&
+  UIManager.getViewManagerConfig('RNUITextViewChild') != null
+    ? requireNativeComponent<RNUITextViewChildProps>('RNUITextViewChild')
+    : () => {
+        throw new Error(LINKING_ERROR)
+      }
+
+export * from './UITextView'
diff --git a/package.json b/package.json
index 17677fb97..258f66b93 100644
--- a/package.json
+++ b/package.json
@@ -175,7 +175,8 @@
     "tlds": "^1.234.0",
     "use-deep-compare": "^1.1.0",
     "zeego": "^1.6.2",
-    "zod": "^3.20.2"
+    "zod": "^3.20.2",
+    "react-native-ui-text-view": "link:./modules/react-native-ui-text-view"
   },
   "devDependencies": {
     "@atproto/dev-env": "^0.2.19",
diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx
index ee096b0d2..38f2c89c9 100644
--- a/src/view/com/lightbox/Lightbox.tsx
+++ b/src/view/com/lightbox/Lightbox.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {StyleSheet, View, Pressable} from 'react-native'
+import {LayoutAnimation, StyleSheet, View} from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import ImageView from './ImageViewing'
 import {shareImageModal, saveImageToMediaLibrary} from 'lib/media/manip'
@@ -105,19 +105,21 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) {
   return (
     <View style={[styles.footer]}>
       {altText ? (
-        <Pressable
-          onPress={() => setAltExpanded(!isAltExpanded)}
-          onLongPress={() => {}}
-          accessibilityRole="button">
-          <View>
-            <Text
-              selectable
-              style={[s.gray3, styles.footerText]}
-              numberOfLines={isAltExpanded ? undefined : 3}>
-              {altText}
-            </Text>
-          </View>
-        </Pressable>
+        <View accessibilityRole="button" style={styles.footerText}>
+          <Text
+            style={[s.gray3]}
+            numberOfLines={isAltExpanded ? undefined : 3}
+            selectable
+            onPress={() => {
+              LayoutAnimation.configureNext({
+                duration: 300,
+                update: {type: 'spring', springDamping: 0.7},
+              })
+              setAltExpanded(prev => !prev)
+            }}>
+            {altText}
+          </Text>
+        </View>
       ) : null}
       <View style={styles.footerBtns}>
         <Button
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index 6ffede9a5..072ef7e33 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -40,7 +40,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'
 import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
 
@@ -400,6 +400,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 c811cd12b..a27ee0a58 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -248,10 +248,9 @@ let PostThreadItemLoaded = ({
           </View>
         )}
 
-        <Link
+        <View
           testID={`postThreadItem-by-${post.author.handle}`}
           style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]}
-          noFeedback
           accessible={false}>
           <PostSandboxWarning />
           <View style={styles.layout}>
@@ -370,6 +369,7 @@ let PostThreadItemLoaded = ({
                     richText={richText}
                     lineHeight={1.3}
                     style={s.flex1}
+                    selectable
                   />
                 </View>
               ) : undefined}
@@ -445,7 +445,7 @@ let PostThreadItemLoaded = ({
               />
             </View>
           </View>
-        </Link>
+        </View>
         <WhoCanReply post={post} />
       </>
     )
diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx
index da473d929..e910127fe 100644
--- a/src/view/com/util/text/RichText.tsx
+++ b/src/view/com/util/text/RichText.tsx
@@ -17,6 +17,7 @@ export function RichText({
   lineHeight = 1.2,
   style,
   numberOfLines,
+  selectable,
   noLinks,
 }: {
   testID?: string
@@ -25,6 +26,7 @@ export function RichText({
   lineHeight?: number
   style?: StyleProp<TextStyle>
   numberOfLines?: number
+  selectable?: boolean
   noLinks?: boolean
 }) {
   const theme = useTheme()
@@ -44,7 +46,11 @@ export function RichText({
       }
       return (
         // @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>
       )
@@ -56,7 +62,8 @@ export function RichText({
         style={[style, pal.text, lineHeightStyle]}
         numberOfLines={numberOfLines}
         // @ts-ignore web only -prf
-        dataSet={WORD_WRAP}>
+        dataSet={WORD_WRAP}
+        selectable={selectable}>
         {text}
       </Text>
     )
@@ -85,6 +92,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) {
@@ -100,6 +108,7 @@ export function RichText({
             style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}
             dataSet={WORD_WRAP}
             warnOnMismatchingLabel
+            selectable={selectable}
           />,
         )
       }
@@ -115,7 +124,8 @@ export function RichText({
       style={[style, pal.text, lineHeightStyle]}
       numberOfLines={numberOfLines}
       // @ts-ignore web only -prf
-      dataSet={WORD_WRAP}>
+      dataSet={WORD_WRAP}
+      selectable={selectable}>
       {els}
     </Text>
   )
diff --git a/src/view/com/util/text/Text.tsx b/src/view/com/util/text/Text.tsx
index ea97d59fe..ccb51bfca 100644
--- a/src/view/com/util/text/Text.tsx
+++ b/src/view/com/util/text/Text.tsx
@@ -2,12 +2,15 @@ import React from 'react'
 import {Text as RNText, TextProps} from 'react-native'
 import {s, lh} from 'lib/styles'
 import {useTheme, TypographyVariant} from 'lib/ThemeContext'
+import {isIOS} from 'platform/detection'
+import {UITextView} from 'react-native-ui-text-view'
 
 export type CustomTextProps = TextProps & {
   type?: TypographyVariant
   lineHeight?: number
   title?: string
   dataSet?: Record<string, string | number>
+  selectable?: boolean
 }
 
 export function Text({
@@ -17,16 +20,29 @@ export function Text({
   style,
   title,
   dataSet,
+  selectable,
   ...props
 }: React.PropsWithChildren<CustomTextProps>) {
   const theme = useTheme()
   const typography = theme.typography[type]
   const lineHeightStyle = lineHeight ? lh(theme, type, lineHeight) : undefined
+
+  if (selectable && isIOS) {
+    return (
+      <UITextView
+        style={[s.black, typography, lineHeightStyle, style]}
+        {...props}>
+        {children}
+      </UITextView>
+    )
+  }
+
   return (
     <RNText
       style={[s.black, typography, lineHeightStyle, style]}
       // @ts-ignore web only -esb
       dataSet={Object.assign({tooltip: title}, dataSet || {})}
+      selectable={selectable}
       {...props}>
       {children}
     </RNText>
diff --git a/yarn.lock b/yarn.lock
index 3e6ae4cf9..1075743d8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -18370,6 +18370,10 @@ react-native-svg@14.1.0:
     css-select "^5.1.0"
     css-tree "^1.1.3"
 
+"react-native-ui-text-view@link:./modules/react-native-ui-text-view":
+  version "0.0.0"
+  uid ""
+
 react-native-url-polyfill@^1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/react-native-url-polyfill/-/react-native-url-polyfill-1.3.0.tgz#c1763de0f2a8c22cc3e959b654c8790622b6ef6a"