diff options
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" |