about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.gitignore4
-rw-r--r--bskyweb/cmd/bskyweb/formating.go57
-rw-r--r--bskyweb/cmd/bskyweb/formatting_test.go39
-rw-r--r--bskyweb/cmd/bskyweb/rss.go7
-rw-r--r--bskyweb/cmd/bskyweb/server.go26
-rw-r--r--bskyweb/cmd/bskyweb/testdata/atproto_embed_post.json60
-rw-r--r--bskyweb/templates/post.html8
-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/lib/link-meta/bsky.ts13
-rw-r--r--src/lib/strings/helpers.ts24
-rw-r--r--src/lib/strings/rich-text-helpers.ts4
-rw-r--r--src/state/queries/list.ts15
-rw-r--r--src/view/com/composer/text-input/TextInput.tsx1
-rw-r--r--src/view/com/composer/useExternalLinkFetch.ts8
-rw-r--r--src/view/com/lightbox/Lightbox.tsx30
-rw-r--r--src/view/com/modals/CreateOrEditList.tsx113
-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/forms/PostDropdownBtn.tsx2
-rw-r--r--src/view/com/util/text/RichText.tsx16
-rw-r--r--src/view/com/util/text/Text.tsx16
-rw-r--r--src/view/screens/Search/Search.tsx146
-rw-r--r--yarn.lock4
35 files changed, 1167 insertions, 74 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/bskyweb/cmd/bskyweb/formating.go b/bskyweb/cmd/bskyweb/formating.go
new file mode 100644
index 000000000..023ba3f51
--- /dev/null
+++ b/bskyweb/cmd/bskyweb/formating.go
@@ -0,0 +1,57 @@
+package main
+
+import (
+	"fmt"
+	"slices"
+	"strings"
+
+	appbsky "github.com/bluesky-social/indigo/api/bsky"
+)
+
+// Function to expand shortened links in rich text back to full urls, replacing shortened urls in social card meta tags and the noscript output.
+//
+// This essentially reverses the effect of the typescript function `shortenLinks()` in `src/lib/strings/rich-text-manip.ts`
+func ExpandPostText(post *appbsky.FeedPost) string {
+	postText := post.Text
+	var charsAdded int = 0
+	// iterate over facets, check if they're link facets, and if found, grab the uri
+	for _, facet := range post.Facets {
+		linkUri := ""
+		if slices.ContainsFunc(facet.Features, func(feat *appbsky.RichtextFacet_Features_Elem) bool {
+			if feat.RichtextFacet_Link == nil || feat.RichtextFacet_Link.LexiconTypeID != "app.bsky.richtext.facet#link" {
+				return false
+			}
+
+			// bail out if bounds checks fail
+			if int(facet.Index.ByteStart)+charsAdded > len(postText) || int(facet.Index.ByteEnd)+charsAdded > len(postText) {
+				return false
+			}
+			linkText := postText[int(facet.Index.ByteStart)+charsAdded : int(facet.Index.ByteEnd)+charsAdded]
+			linkUri = feat.RichtextFacet_Link.Uri
+
+			// only expand uris that have been shortened (as opposed to those with non-uri anchor text)
+			if strings.HasSuffix(linkText, "...") && strings.Contains(linkUri, linkText[0:len(linkText)-3]) {
+				return true
+			}
+			return false
+		}) {
+			// replace the shortened uri with the full length one from the facet using utf8 byte offsets
+			// NOTE: we already did bounds check above
+			postText = postText[0:int(facet.Index.ByteStart)+charsAdded] + linkUri + postText[int(facet.Index.ByteEnd)+charsAdded:]
+			charsAdded += len(linkUri) - int(facet.Index.ByteEnd-facet.Index.ByteStart)
+		}
+	}
+	// if the post has an embeded link and its url doesn't already appear in postText, append it to
+	// the end to avoid social cards with missing links
+	if post.Embed != nil && post.Embed.EmbedExternal != nil && post.Embed.EmbedExternal.External != nil {
+		externalURI := post.Embed.EmbedExternal.External.Uri
+		if !strings.Contains(postText, externalURI) {
+			postText = fmt.Sprintf("%s\n%s", postText, externalURI)
+		}
+	}
+	// TODO: could embed the actual post text?
+	if post.Embed != nil && (post.Embed.EmbedRecord != nil || post.Embed.EmbedRecordWithMedia != nil) {
+		postText = fmt.Sprintf("%s\n\n[contains quote post or other embeded content]", postText)
+	}
+	return postText
+}
diff --git a/bskyweb/cmd/bskyweb/formatting_test.go b/bskyweb/cmd/bskyweb/formatting_test.go
new file mode 100644
index 000000000..1fbf8d5ee
--- /dev/null
+++ b/bskyweb/cmd/bskyweb/formatting_test.go
@@ -0,0 +1,39 @@
+package main
+
+import (
+	"encoding/json"
+	"io"
+	"os"
+	"strings"
+	"testing"
+
+	appbsky "github.com/bluesky-social/indigo/api/bsky"
+)
+
+func loadPost(t *testing.T, p string) appbsky.FeedPost {
+
+	f, err := os.Open(p)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer func() { _ = f.Close() }()
+
+	postBytes, err := io.ReadAll(f)
+	if err != nil {
+		t.Fatal(err)
+	}
+	var post appbsky.FeedPost
+	if err := json.Unmarshal(postBytes, &post); err != nil {
+		t.Fatal(err)
+	}
+	return post
+}
+
+func TestExpandPostText(t *testing.T) {
+	post := loadPost(t, "testdata/atproto_embed_post.json")
+
+	text := ExpandPostText(&post)
+	if !strings.Contains(text, "https://github.com/snarfed/bridgy-fed") {
+		t.Fail()
+	}
+}
diff --git a/bskyweb/cmd/bskyweb/rss.go b/bskyweb/cmd/bskyweb/rss.go
index b756b90e6..76689abb5 100644
--- a/bskyweb/cmd/bskyweb/rss.go
+++ b/bskyweb/cmd/bskyweb/rss.go
@@ -96,7 +96,10 @@ func (srv *Server) WebProfileRSS(c echo.Context) error {
 		if err != nil {
 			return err
 		}
-		rec := p.Post.Record.Val.(*appbsky.FeedPost)
+		rec, ok := p.Post.Record.Val.(*appbsky.FeedPost)
+		if !ok {
+			continue
+		}
 		// only top-level posts in RSS (no replies)
 		if rec.Reply != nil {
 			continue
@@ -108,7 +111,7 @@ func (srv *Server) WebProfileRSS(c echo.Context) error {
 		}
 		posts = append(posts, Item{
 			Link:        fmt.Sprintf("https://%s/profile/%s/post/%s", req.Host, pv.Handle, aturi.RecordKey().String()),
-			Description: rec.Text,
+			Description: ExpandPostText(rec),
 			PubDate:     pubDate,
 			GUID: ItemGUID{
 				Value:   aturi.String(),
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index 94bba231a..8e7d618c2 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -336,13 +336,29 @@ func (srv *Server) WebPost(c echo.Context) error {
 	postView := tpv.Thread.FeedDefs_ThreadViewPost.Post
 	data["postView"] = postView
 	data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
-	if postView.Embed != nil && postView.Embed.EmbedImages_View != nil {
-		var thumbUrls []string
-		for i := range postView.Embed.EmbedImages_View.Images {
-			thumbUrls = append(thumbUrls, postView.Embed.EmbedImages_View.Images[i].Thumb)
+	if postView.Embed != nil {
+		if postView.Embed.EmbedImages_View != nil {
+			var thumbUrls []string
+			for i := range postView.Embed.EmbedImages_View.Images {
+				thumbUrls = append(thumbUrls, postView.Embed.EmbedImages_View.Images[i].Thumb)
+			}
+			data["imgThumbUrls"] = thumbUrls
+		} else if postView.Embed.EmbedRecordWithMedia_View != nil && postView.Embed.EmbedRecordWithMedia_View.Media != nil && postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View != nil {
+			var thumbUrls []string
+			for i := range postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images {
+				thumbUrls = append(thumbUrls, postView.Embed.EmbedRecordWithMedia_View.Media.EmbedImages_View.Images[i].Thumb)
+			}
+			data["imgThumbUrls"] = thumbUrls
+		}
+	}
+
+	if postView.Record != nil {
+		postRecord, ok := postView.Record.Val.(*appbsky.FeedPost)
+		if ok {
+			data["postText"] = ExpandPostText(postRecord)
 		}
-		data["imgThumbUrls"] = thumbUrls
 	}
+
 	return c.Render(http.StatusOK, "post.html", data)
 }
 
diff --git a/bskyweb/cmd/bskyweb/testdata/atproto_embed_post.json b/bskyweb/cmd/bskyweb/testdata/atproto_embed_post.json
new file mode 100644
index 000000000..2e54854ee
--- /dev/null
+++ b/bskyweb/cmd/bskyweb/testdata/atproto_embed_post.json
@@ -0,0 +1,60 @@
+{
+    "$type": "app.bsky.feed.post",
+    "createdAt": "2023-12-04T19:30:03.024Z",
+    "embed": {
+        "$type": "app.bsky.embed.external",
+        "external": {
+        "description": "🕸 Bridges the IndieWeb to Mastodon and the fediverse via ActivityPub. - GitHub - snarfed/bridgy-fed: 🕸 Bridges the IndieWeb to Mastodon and the fediverse via ActivityPub.",
+        "thumb": {
+            "$type": "blob",
+            "ref": {
+            "$link": "bafkreidplhjcnrl2c74r3xs7nh7k7q3ny6ul7cgxr2fophblvdeky6t64e"
+            },
+            "mimeType": "image/jpeg",
+            "size": 347998
+        },
+        "title": "GitHub - snarfed/bridgy-fed: 🕸 Bridges the IndieWeb to Mastodon and the fediverse via ActivityPub...",
+        "uri": "https://github.com/snarfed/bridgy-fed"
+        }
+    },
+    "facets": [
+        {
+        "features": [
+            {
+            "$type": "app.bsky.richtext.facet#link",
+            "uri": "https://github.com/snarfed/bridgy-fed"
+            }
+        ],
+        "index": {
+            "byteEnd": 92,
+            "byteStart": 66
+        }
+        },
+        {
+        "features": [
+            {
+            "$type": "app.bsky.richtext.facet#mention",
+            "did": "did:plc:fdme4gb7mu7zrie7peay7tst"
+            }
+        ],
+        "index": {
+            "byteEnd": 149,
+            "byteStart": 137
+        }
+        }
+    ],
+    "langs": [
+        "en"
+    ],
+    "reply": {
+        "parent": {
+        "cid": "bafyreifaidyl62p4snkdwsygviemsxyidi3cd7dxvjomh5644sovxhsppa",
+        "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kfqklhpalh2c"
+        },
+        "root": {
+        "cid": "bafyreibiimdwmsp5mqpm7utqcdmvo6fdqmofblp5obs3h7ub6652zyooci",
+        "uri": "at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3kfqkkjdkic2e"
+        }
+    },
+    "text": "Bridgy Fed is an open-source project — check out the code here: github.com/snarfed/brid...\n\nStay updated with the project by following @snarfed.org!"
+}
diff --git a/bskyweb/templates/post.html b/bskyweb/templates/post.html
index 307f80bbb..b6688e35b 100644
--- a/bskyweb/templates/post.html
+++ b/bskyweb/templates/post.html
@@ -21,9 +21,9 @@
   {% else %}
   <meta property="og:title" content="@{{ postView.Author.Handle }}">
   {% endif -%}
-  {%- if postView.Record.Val.Text %}
-  <meta name="description" content="{{ postView.Record.Val.Text }}">
-  <meta property="og:description" content="{{ postView.Record.Val.Text }}">
+  {%- if postText %}
+  <meta name="description" content="{{ postText }}">
+  <meta property="og:description" content="{{ postText }}">
   {% endif -%}
   {%- if imgThumbUrls %}
   {% for imgThumbUrl in imgThumbUrls %}
@@ -47,7 +47,7 @@
   <p id="bsky_display_name">{{ postView.Author.DisplayName }}</p>
   <p id="bsky_handle">{{ postView.Author.Handle }}</p>
   <p id="bsky_did">{{ postView.Author.Did }}</p>
-  <p id="bsky_post_text">{{ postView.Record.Val.Text }}</p>
+  <p id="bsky_post_text">{{ postText }}</p>
   <p id="bsky_post_indexedat">{{ postView.IndexedAt }}</p>
 </div>
 {% endif -%}
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/lib/link-meta/bsky.ts b/src/lib/link-meta/bsky.ts
index 322b02332..c1fbb34b3 100644
--- a/src/lib/link-meta/bsky.ts
+++ b/src/lib/link-meta/bsky.ts
@@ -5,6 +5,7 @@ import {LikelyType, LinkMeta} from './link-meta'
 import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
 import {ComposerOptsQuote} from 'state/shell/composer'
 import {useGetPost} from '#/state/queries/post'
+import {useFetchDid} from '#/state/queries/handle'
 
 // TODO
 // import {Home} from 'view/screens/Home'
@@ -120,11 +121,13 @@ export async function getPostAsQuote(
 
 export async function getFeedAsEmbed(
   agent: BskyAgent,
+  fetchDid: ReturnType<typeof useFetchDid>,
   url: string,
 ): Promise<apilib.ExternalEmbedDraft> {
   url = convertBskyAppUrlIfNeeded(url)
-  const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
-  const feed = makeRecordUri(user, 'app.bsky.feed.generator', rkey)
+  const [_0, handleOrDid, _1, rkey] = url.split('/').filter(Boolean)
+  const did = await fetchDid(handleOrDid)
+  const feed = makeRecordUri(did, 'app.bsky.feed.generator', rkey)
   const res = await agent.app.bsky.feed.getFeedGenerator({feed})
   return {
     isLoading: false,
@@ -146,11 +149,13 @@ export async function getFeedAsEmbed(
 
 export async function getListAsEmbed(
   agent: BskyAgent,
+  fetchDid: ReturnType<typeof useFetchDid>,
   url: string,
 ): Promise<apilib.ExternalEmbedDraft> {
   url = convertBskyAppUrlIfNeeded(url)
-  const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
-  const list = makeRecordUri(user, 'app.bsky.graph.list', rkey)
+  const [_0, handleOrDid, _1, rkey] = url.split('/').filter(Boolean)
+  const did = await fetchDid(handleOrDid)
+  const list = makeRecordUri(did, 'app.bsky.graph.list', rkey)
   const res = await agent.app.bsky.graph.getList({list})
   return {
     isLoading: false,
diff --git a/src/lib/strings/helpers.ts b/src/lib/strings/helpers.ts
index 381ae32f3..e2abe9019 100644
--- a/src/lib/strings/helpers.ts
+++ b/src/lib/strings/helpers.ts
@@ -37,3 +37,27 @@ export function countLines(str: string | undefined): number {
   if (!str) return 0
   return str.match(/\n/g)?.length ?? 0
 }
+
+// Augments search query with additional syntax like `from:me`
+export function augmentSearchQuery(query: string, {did}: {did?: string}) {
+  // Don't do anything if there's no DID
+  if (!did) {
+    return query
+  }
+
+  // We don't want to replace substrings that are being "quoted" because those
+  // are exact string matches, so what we'll do here is to split them apart
+
+  // Even-indexed strings are unquoted, odd-indexed strings are quoted
+  const splits = query.split(/("(?:[^"\\]|\\.)*")/g)
+
+  return splits
+    .map((str, idx) => {
+      if (idx % 2 === 0) {
+        return str.replaceAll(/(^|\s)from:me(\s|$)/g, `$1${did}$2`)
+      }
+
+      return str
+    })
+    .join('')
+}
diff --git a/src/lib/strings/rich-text-helpers.ts b/src/lib/strings/rich-text-helpers.ts
index 08971ca03..662004599 100644
--- a/src/lib/strings/rich-text-helpers.ts
+++ b/src/lib/strings/rich-text-helpers.ts
@@ -1,7 +1,7 @@
 import {AppBskyRichtextFacet, RichText} from '@atproto/api'
 import {linkRequiresWarning} from './url-helpers'
 
-export function richTextToString(rt: RichText): string {
+export function richTextToString(rt: RichText, loose: boolean): string {
   const {text, facets} = rt
 
   if (!facets?.length) {
@@ -19,7 +19,7 @@ export function richTextToString(rt: RichText): string {
 
       const requiresWarning = linkRequiresWarning(href, text)
 
-      result += !requiresWarning ? href : `[${text}](${href})`
+      result += !requiresWarning ? href : loose ? `[${text}](${href})` : text
     } else {
       result += segment.text
     }
diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts
index 013a69076..845658a27 100644
--- a/src/state/queries/list.ts
+++ b/src/state/queries/list.ts
@@ -3,6 +3,7 @@ import {
   AppBskyGraphGetList,
   AppBskyGraphList,
   AppBskyGraphDefs,
+  Facet,
 } from '@atproto/api'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
@@ -38,6 +39,7 @@ export interface ListCreateMutateParams {
   purpose: string
   name: string
   description: string
+  descriptionFacets: Facet[] | undefined
   avatar: RNImage | null | undefined
 }
 export function useListCreateMutation() {
@@ -45,7 +47,13 @@ export function useListCreateMutation() {
   const queryClient = useQueryClient()
   return useMutation<{uri: string; cid: string}, Error, ListCreateMutateParams>(
     {
-      async mutationFn({purpose, name, description, avatar}) {
+      async mutationFn({
+        purpose,
+        name,
+        description,
+        descriptionFacets,
+        avatar,
+      }) {
         if (!currentAccount) {
           throw new Error('Not logged in')
         }
@@ -59,6 +67,7 @@ export function useListCreateMutation() {
           purpose,
           name,
           description,
+          descriptionFacets,
           avatar: undefined,
           createdAt: new Date().toISOString(),
         }
@@ -93,6 +102,7 @@ export interface ListMetadataMutateParams {
   uri: string
   name: string
   description: string
+  descriptionFacets: Facet[] | undefined
   avatar: RNImage | null | undefined
 }
 export function useListMetadataMutation() {
@@ -103,7 +113,7 @@ export function useListMetadataMutation() {
     Error,
     ListMetadataMutateParams
   >({
-    async mutationFn({uri, name, description, avatar}) {
+    async mutationFn({uri, name, description, descriptionFacets, avatar}) {
       const {hostname, rkey} = new AtUri(uri)
       if (!currentAccount) {
         throw new Error('Not logged in')
@@ -121,6 +131,7 @@ export function useListMetadataMutation() {
       // update the fields
       record.name = name
       record.description = description
+      record.descriptionFacets = descriptionFacets
       if (avatar) {
         const blobRes = await uploadBlob(getAgent(), avatar.path, avatar.mime)
         record.avatar = blobRes.data.blob
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx
index 57bfd0a88..3d0d5ab8d 100644
--- a/src/view/com/composer/text-input/TextInput.tsx
+++ b/src/view/com/composer/text-input/TextInput.tsx
@@ -217,6 +217,7 @@ export const TextInput = forwardRef(function TextInputImpl(
         autoFocus={true}
         allowFontScaling
         multiline
+        scrollEnabled={false}
         numberOfLines={4}
         style={[
           pal.text,
diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts
index ef3958c9d..fc7218d5d 100644
--- a/src/view/com/composer/useExternalLinkFetch.ts
+++ b/src/view/com/composer/useExternalLinkFetch.ts
@@ -18,6 +18,7 @@ import {POST_IMG_MAX} from 'lib/constants'
 import {logger} from '#/logger'
 import {getAgent} from '#/state/session'
 import {useGetPost} from '#/state/queries/post'
+import {useFetchDid} from '#/state/queries/handle'
 
 export function useExternalLinkFetch({
   setQuote,
@@ -28,6 +29,7 @@ export function useExternalLinkFetch({
     undefined,
   )
   const getPost = useGetPost()
+  const fetchDid = useFetchDid()
 
   useEffect(() => {
     let aborted = false
@@ -55,7 +57,7 @@ export function useExternalLinkFetch({
           },
         )
       } else if (isBskyCustomFeedUrl(extLink.uri)) {
-        getFeedAsEmbed(getAgent(), extLink.uri).then(
+        getFeedAsEmbed(getAgent(), fetchDid, extLink.uri).then(
           ({embed, meta}) => {
             if (aborted) {
               return
@@ -73,7 +75,7 @@ export function useExternalLinkFetch({
           },
         )
       } else if (isBskyListUrl(extLink.uri)) {
-        getListAsEmbed(getAgent(), extLink.uri).then(
+        getListAsEmbed(getAgent(), fetchDid, extLink.uri).then(
           ({embed, meta}) => {
             if (aborted) {
               return
@@ -133,7 +135,7 @@ export function useExternalLinkFetch({
       })
     }
     return cleanup
-  }, [extLink, setQuote, getPost])
+  }, [extLink, setQuote, getPost, fetchDid])
 
   return {extLink, setExtLink}
 }
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/modals/CreateOrEditList.tsx b/src/view/com/modals/CreateOrEditList.tsx
index 77a1debec..0e11fcffd 100644
--- a/src/view/com/modals/CreateOrEditList.tsx
+++ b/src/view/com/modals/CreateOrEditList.tsx
@@ -8,7 +8,11 @@ import {
   TouchableOpacity,
   View,
 } from 'react-native'
-import {AppBskyGraphDefs} from '@atproto/api'
+import {
+  AppBskyGraphDefs,
+  AppBskyRichtextFacet,
+  RichText as RichTextAPI,
+} from '@atproto/api'
 import LinearGradient from 'react-native-linear-gradient'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import {Text} from '../util/text/Text'
@@ -30,6 +34,9 @@ import {
   useListCreateMutation,
   useListMetadataMutation,
 } from '#/state/queries/list'
+import {richTextToString} from '#/lib/strings/rich-text-helpers'
+import {shortenLinks} from '#/lib/strings/rich-text-manip'
+import {getAgent} from '#/state/session'
 
 const MAX_NAME = 64 // todo
 const MAX_DESCRIPTION = 300 // todo
@@ -68,12 +75,42 @@ export function Component({
 
   const [isProcessing, setProcessing] = useState<boolean>(false)
   const [name, setName] = useState<string>(list?.name || '')
-  const [description, setDescription] = useState<string>(
-    list?.description || '',
-  )
+
+  const [descriptionRt, setDescriptionRt] = useState<RichTextAPI>(() => {
+    const text = list?.description
+    const facets = list?.descriptionFacets
+
+    if (!text || !facets) {
+      return new RichTextAPI({text: text || ''})
+    }
+
+    // We want to be working with a blank state here, so let's get the
+    // serialized version and turn it back into a RichText
+    const serialized = richTextToString(new RichTextAPI({text, facets}), false)
+
+    const richText = new RichTextAPI({text: serialized})
+    richText.detectFacetsWithoutResolution()
+
+    return richText
+  })
+  const graphemeLength = useMemo(() => {
+    return shortenLinks(descriptionRt).graphemeLength
+  }, [descriptionRt])
+  const isDescriptionOver = graphemeLength > MAX_DESCRIPTION
+
   const [avatar, setAvatar] = useState<string | undefined>(list?.avatar)
   const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>()
 
+  const onDescriptionChange = useCallback(
+    (newText: string) => {
+      const richText = new RichTextAPI({text: newText})
+      richText.detectFacetsWithoutResolution()
+
+      setDescriptionRt(richText)
+    },
+    [setDescriptionRt],
+  )
+
   const onPressCancel = useCallback(() => {
     closeModal()
   }, [closeModal])
@@ -113,11 +150,31 @@ export function Component({
       setError('')
     }
     try {
+      let richText = new RichTextAPI(
+        {text: descriptionRt.text.trimEnd()},
+        {cleanNewlines: true},
+      )
+
+      await richText.detectFacets(getAgent())
+      richText = shortenLinks(richText)
+
+      // filter out any mention facets that didn't map to a user
+      richText.facets = richText.facets?.filter(facet => {
+        const mention = facet.features.find(feature =>
+          AppBskyRichtextFacet.isMention(feature),
+        )
+        if (mention && !mention.did) {
+          return false
+        }
+        return true
+      })
+
       if (list) {
         await listMetadataMutation.mutateAsync({
           uri: list.uri,
           name: nameTrimmed,
-          description: description.trim(),
+          description: richText.text,
+          descriptionFacets: richText.facets,
           avatar: newAvatar,
         })
         Toast.show(
@@ -130,7 +187,8 @@ export function Component({
         const res = await listCreateMutation.mutateAsync({
           purpose: activePurpose,
           name,
-          description,
+          description: richText.text,
+          descriptionFacets: richText.facets,
           avatar: newAvatar,
         })
         Toast.show(
@@ -163,7 +221,7 @@ export function Component({
     activePurpose,
     isCurateList,
     name,
-    description,
+    descriptionRt,
     newAvatar,
     list,
     listMetadataMutation,
@@ -212,9 +270,11 @@ export function Component({
         </View>
         <View style={styles.form}>
           <View>
-            <Text style={[styles.label, pal.text]} nativeID="list-name">
-              <Trans>List Name</Trans>
-            </Text>
+            <View style={styles.labelWrapper}>
+              <Text style={[styles.label, pal.text]} nativeID="list-name">
+                <Trans>List Name</Trans>
+              </Text>
+            </View>
             <TextInput
               testID="editNameInput"
               style={[styles.textInput, pal.border, pal.text]}
@@ -233,9 +293,17 @@ export function Component({
             />
           </View>
           <View style={s.pb10}>
-            <Text style={[styles.label, pal.text]} nativeID="list-description">
-              <Trans>Description</Trans>
-            </Text>
+            <View style={styles.labelWrapper}>
+              <Text
+                style={[styles.label, pal.text]}
+                nativeID="list-description">
+                <Trans>Description</Trans>
+              </Text>
+              <Text
+                style={[!isDescriptionOver ? pal.textLight : s.red3, s.f13]}>
+                {graphemeLength}/{MAX_DESCRIPTION}
+              </Text>
+            </View>
             <TextInput
               testID="editDescriptionInput"
               style={[styles.textArea, pal.border, pal.text]}
@@ -247,8 +315,8 @@ export function Component({
               placeholderTextColor={colors.gray4}
               keyboardAppearance={theme.colorScheme}
               multiline
-              value={description}
-              onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))}
+              value={descriptionRt.text}
+              onChangeText={onDescriptionChange}
               accessible={true}
               accessibilityLabel={_(msg`Description`)}
               accessibilityHint=""
@@ -262,7 +330,8 @@ export function Component({
           ) : (
             <TouchableOpacity
               testID="saveBtn"
-              style={s.mt10}
+              style={[s.mt10, isDescriptionOver && s.dimmed]}
+              disabled={isDescriptionOver}
               onPress={onPressSave}
               accessibilityRole="button"
               accessibilityLabel={_(msg`Save`)}
@@ -271,7 +340,7 @@ export function Component({
                 colors={[gradients.blueLight.start, gradients.blueLight.end]}
                 start={{x: 0, y: 0}}
                 end={{x: 1, y: 1}}
-                style={[styles.btn]}>
+                style={styles.btn}>
                 <Text style={[s.white, s.bold]}>
                   <Trans context="action">Save</Trans>
                 </Text>
@@ -305,12 +374,18 @@ const styles = StyleSheet.create({
     fontSize: 24,
     marginBottom: 18,
   },
-  label: {
-    fontWeight: 'bold',
+  labelWrapper: {
+    flexDirection: 'row',
+    gap: 8,
+    alignItems: 'center',
+    justifyContent: 'space-between',
     paddingHorizontal: 4,
     paddingBottom: 4,
     marginTop: 20,
   },
+  label: {
+    fontWeight: 'bold',
+  },
   form: {
     paddingHorizontal: 6,
   },
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/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index 8e31c9e63..b21caf2e7 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -104,7 +104,7 @@ let PostDropdownBtn = ({
   }, [rootUri, toggleThreadMute, _])
 
   const onCopyPostText = React.useCallback(() => {
-    const str = richTextToString(richText)
+    const str = richTextToString(richText, true)
 
     Clipboard.setString(str)
     Toast.show(_(msg`Copied to clipboard`))
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/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx
index bfa8e1b28..df64cc5aa 100644
--- a/src/view/screens/Search/Search.tsx
+++ b/src/view/screens/Search/Search.tsx
@@ -51,6 +51,8 @@ import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell'
 import {isNative, isWeb} from '#/platform/detection'
 import {listenSoftReset} from '#/state/events'
 import {s} from '#/lib/styles'
+import AsyncStorage from '@react-native-async-storage/async-storage'
+import {augmentSearchQuery} from '#/lib/strings/helpers'
 
 function Loader() {
   const pal = usePalette('default')
@@ -317,9 +319,13 @@ export function SearchScreenInner({
   const pal = usePalette('default')
   const setMinimalShellMode = useSetMinimalShellMode()
   const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
-  const {hasSession} = useSession()
+  const {hasSession, currentAccount} = useSession()
   const {isDesktop} = useWebMediaQueries()
 
+  const augmentedQuery = React.useMemo(() => {
+    return augmentSearchQuery(query || '', {did: currentAccount?.did})
+  }, [query, currentAccount])
+
   const onPageSelected = React.useCallback(
     (index: number) => {
       setMinimalShellMode(false)
@@ -342,7 +348,7 @@ export function SearchScreenInner({
         )}
         initialPage={0}>
         <View>
-          <SearchScreenPostResults query={query} />
+          <SearchScreenPostResults query={augmentedQuery} />
         </View>
         <View>
           <SearchScreenUserResults query={query} />
@@ -464,11 +470,28 @@ export function SearchScreen(
   const [inputIsFocused, setInputIsFocused] = React.useState(false)
   const [showAutocompleteResults, setShowAutocompleteResults] =
     React.useState(false)
+  const [searchHistory, setSearchHistory] = React.useState<string[]>([])
+
+  React.useEffect(() => {
+    const loadSearchHistory = async () => {
+      try {
+        const history = await AsyncStorage.getItem('searchHistory')
+        if (history !== null) {
+          setSearchHistory(JSON.parse(history))
+        }
+      } catch (e: any) {
+        logger.error('Failed to load search history', e)
+      }
+    }
+
+    loadSearchHistory()
+  }, [])
 
   const onPressMenu = React.useCallback(() => {
     track('ViewHeader:MenuButtonClicked')
     setDrawerOpen(true)
   }, [track, setDrawerOpen])
+
   const onPressCancelSearch = React.useCallback(() => {
     scrollToTopWeb()
     textInput.current?.blur()
@@ -477,22 +500,26 @@ export function SearchScreen(
     if (searchDebounceTimeout.current)
       clearTimeout(searchDebounceTimeout.current)
   }, [textInput])
+
   const onPressClearQuery = React.useCallback(() => {
     scrollToTopWeb()
     setQuery('')
     setShowAutocompleteResults(false)
   }, [setQuery])
+
   const onChangeText = React.useCallback(
     async (text: string) => {
       scrollToTopWeb()
+
       setQuery(text)
 
       if (text.length > 0) {
         setIsFetching(true)
         setShowAutocompleteResults(true)
 
-        if (searchDebounceTimeout.current)
+        if (searchDebounceTimeout.current) {
           clearTimeout(searchDebounceTimeout.current)
+        }
 
         searchDebounceTimeout.current = setTimeout(async () => {
           const results = await search({query: text, limit: 30})
@@ -503,8 +530,9 @@ export function SearchScreen(
           }
         }, 300)
       } else {
-        if (searchDebounceTimeout.current)
+        if (searchDebounceTimeout.current) {
           clearTimeout(searchDebounceTimeout.current)
+        }
         setSearchResults([])
         setIsFetching(false)
         setShowAutocompleteResults(false)
@@ -512,10 +540,36 @@ export function SearchScreen(
     },
     [setQuery, search, setSearchResults],
   )
+
+  const updateSearchHistory = React.useCallback(
+    async (newQuery: string) => {
+      newQuery = newQuery.trim()
+      if (newQuery && !searchHistory.includes(newQuery)) {
+        let newHistory = [newQuery, ...searchHistory]
+
+        if (newHistory.length > 5) {
+          newHistory = newHistory.slice(0, 5)
+        }
+
+        setSearchHistory(newHistory)
+        try {
+          await AsyncStorage.setItem(
+            'searchHistory',
+            JSON.stringify(newHistory),
+          )
+        } catch (e: any) {
+          logger.error('Failed to save search history', e)
+        }
+      }
+    },
+    [searchHistory, setSearchHistory],
+  )
+
   const onSubmit = React.useCallback(() => {
     scrollToTopWeb()
     setShowAutocompleteResults(false)
-  }, [setShowAutocompleteResults])
+    updateSearchHistory(query)
+  }, [query, setShowAutocompleteResults, updateSearchHistory])
 
   const onSoftReset = React.useCallback(() => {
     scrollToTopWeb()
@@ -534,6 +588,21 @@ export function SearchScreen(
     }, [onSoftReset, setMinimalShellMode]),
   )
 
+  const handleHistoryItemClick = (item: React.SetStateAction<string>) => {
+    setQuery(item)
+    onSubmit()
+  }
+
+  const handleRemoveHistoryItem = (itemToRemove: string) => {
+    const updatedHistory = searchHistory.filter(item => item !== itemToRemove)
+    setSearchHistory(updatedHistory)
+    AsyncStorage.setItem('searchHistory', JSON.stringify(updatedHistory)).catch(
+      e => {
+        logger.error('Failed to update search history', e)
+      },
+    )
+  }
+
   return (
     <View style={isWeb ? null : {flex: 1}}>
       <CenteredView
@@ -581,7 +650,12 @@ export function SearchScreen(
             style={[pal.text, styles.headerSearchInput]}
             keyboardAppearance={theme.colorScheme}
             onFocus={() => setInputIsFocused(true)}
-            onBlur={() => setInputIsFocused(false)}
+            onBlur={() => {
+              // HACK
+              // give 100ms to not stop click handlers in the search history
+              // -prf
+              setTimeout(() => setInputIsFocused(false), 100)
+            }}
             onChangeText={onChangeText}
             onSubmitEditing={onSubmit}
             autoFocus={false}
@@ -623,9 +697,9 @@ export function SearchScreen(
         ) : undefined}
       </CenteredView>
 
-      {showAutocompleteResults && moderationOpts ? (
+      {showAutocompleteResults ? (
         <>
-          {isFetching ? (
+          {isFetching || !moderationOpts ? (
             <Loader />
           ) : (
             <ScrollView
@@ -664,6 +738,42 @@ export function SearchScreen(
             </ScrollView>
           )}
         </>
+      ) : !query && inputIsFocused ? (
+        <CenteredView
+          sideBorders={isTabletOrDesktop}
+          // @ts-ignore web only -prf
+          style={{
+            height: isWeb ? '100vh' : undefined,
+          }}>
+          <View style={styles.searchHistoryContainer}>
+            {searchHistory.length > 0 && (
+              <View style={styles.searchHistoryContent}>
+                <Text style={[pal.text, styles.searchHistoryTitle]}>
+                  Recent Searches
+                </Text>
+                {searchHistory.map((historyItem, index) => (
+                  <View key={index} style={styles.historyItemContainer}>
+                    <Pressable
+                      accessibilityRole="button"
+                      onPress={() => handleHistoryItemClick(historyItem)}
+                      style={styles.historyItem}>
+                      <Text style={pal.text}>{historyItem}</Text>
+                    </Pressable>
+                    <Pressable
+                      accessibilityRole="button"
+                      onPress={() => handleRemoveHistoryItem(historyItem)}>
+                      <FontAwesomeIcon
+                        icon="xmark"
+                        size={16}
+                        style={pal.textLight as FontAwesomeIconStyle}
+                      />
+                    </Pressable>
+                  </View>
+                ))}
+              </View>
+            )}
+          </View>
+        </CenteredView>
       ) : (
         <SearchScreenInner query={query} />
       )}
@@ -725,4 +835,24 @@ const styles = StyleSheet.create({
     top: isWeb ? HEADER_HEIGHT : 0,
     zIndex: 1,
   },
+  searchHistoryContainer: {
+    width: '100%',
+    paddingHorizontal: 12,
+  },
+  searchHistoryContent: {
+    padding: 10,
+    borderRadius: 8,
+  },
+  searchHistoryTitle: {
+    fontWeight: 'bold',
+  },
+  historyItem: {
+    paddingVertical: 8,
+  },
+  historyItemContainer: {
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    alignItems: 'center',
+    paddingVertical: 8,
+  },
 })
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"