Compare commits

..

1 Commits

Author SHA1 Message Date
Eric Bailey 90dc3edc5b Use Inter variable font 2024-09-30 12:40:16 -05:00
342 changed files with 36082 additions and 51264 deletions
+1 -10
View File
@@ -23,16 +23,7 @@ module.exports = {
'bsky-internal/avoid-unwrapped-text': [
'error',
{
impliedTextComponents: [
'H1',
'H2',
'H3',
'H4',
'H5',
'H6',
'P',
'Admonition',
],
impliedTextComponents: ['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'P'],
impliedTextProps: [],
suggestedTextWrappers: {
Button: 'ButtonText',
+35
View File
@@ -0,0 +1,35 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
<!-- A clear and concise description of what the bug is. -->
**To Reproduce**
Steps to reproduce the behavior:
1.
**Expected behavior**
<!-- A clear and concise description of what you expected to happen. -->
**Screenshots**
<!-- If applicable, add screenshots to help explain your problem. -->
**Details**
- Platform: <!-- desktop chrome windows, mobile safari, iOS, Android -->
- Platform version:
- App version:
**Additional context**
<!-- Add any other context about the problem here. -->
-64
View File
@@ -1,64 +0,0 @@
name: "Bug Report"
description: "Create a report for an issue you have experience in the app."
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report an issue you've found in the app! Before you submit this issue, please make sure you've searched for similar issues that are already open and being tracked. If you find an open issue that seems relevant to yours, it is best to leave a response there with your information instead of opening a new issue, since it helps to consolidate the info in one place.
- type: textarea
attributes:
label: Steps to Reproduce
description: |
Please provide the steps that may reproduce the issue that you are experiencing. The more concise of a
reproduction that you provide, the easier it will be for us to identify the issue and fix it in a timely manner.
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
attributes:
label: Attachments
description: |
If possible, please provide any images or videos that may help us understand the issue you are experiencing.
validations:
required: false
- type: dropdown
attributes:
label: What platform(s) does this occur on?
multiple: true
options:
- iOS
- Android
- Web (Desktop)
- Web (Mobile)
validations:
required: true
- type: input
attributes:
label: Device Info
description: |
Please provide the device information that you are using when experiencing this issue. This can include the
device model, operating system version, and any other relevant information.
validations:
required: false
- type: input
attributes:
label: What version of the app are you using?
description: |
You can easily find this information by visting the Settings screen in the app, and tapping on the version info
at the bottom of the screen. All the relevant information will be copied to your clipboard, and you will be
able to share it here.
validations:
required: true
- type: textarea
attributes:
label: Additional Information
description: |
Please provide any additional information that you think may be helpful in identifying the issue you are
experiencing. This can include any error messages, logs, or other information that you think may be relevant.
validations:
required: false
@@ -1,69 +0,0 @@
name: "Bug Report - Fabric/New Architecture"
description: "Create a bug report for builds using the New Architecture. If you are not sure what that means, don't use this template, and instead use the default bug report template."
labels: ["bug", "fabric"]
body:
- type: checkboxes
attributes:
label: You're running a build that uses the React Native New Architecture, right?
description: This template is specifically for issues related to builds that use the React Native New Architecture. If you are not sure what that means, this template is probably not relevant to you, and you should use the default Bug Report template.
options:
- label: I am running a build that uses the New Architecture
required: true
- type: markdown
attributes:
value: |
Thanks for taking the time to report an issue you've found in the app! Before you submit this issue, please make sure you've searched for similar issues that are already open and being tracked. If you find an open issue that seems relevant to yours, it is best to leave a response there with your information instead of opening a new issue, since it helps to consolidate the info in one place.
- type: textarea
attributes:
label: Steps to Reproduce
description: |
Please provide the steps that may reproduce the issue that you are experiencing. The more concise of a
reproduction that you provide, the easier it will be for us to identify the issue and fix it in a timely manner.
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
attributes:
label: Attachments
description: |
If possible, please provide any images or videos that may help us understand the issue you are experiencing.
validations:
required: false
- type: dropdown
attributes:
label: What platform(s) does this occur on?
multiple: true
options:
- iOS
- Android
validations:
required: true
- type: input
attributes:
label: Device Info
description: |
Please provide the device information that you are using when experiencing this issue. This can include the
device model, operating system version, and any other relevant information.
validations:
required: false
- type: input
attributes:
label: What version of the app are you using?
description: |
You can easily find this information by visting the Settings screen in the app, and tapping on the version info
at the bottom of the screen. All the relevant information will be copied to your clipboard, and you will be
able to share it here.
validations:
required: true
- type: textarea
attributes:
label: Additional Information
description: |
Please provide any additional information that you think may be helpful in identifying the issue you are
experiencing. This can include any error messages, logs, or other information that you think may be relevant.
validations:
required: false
+24
View File
@@ -0,0 +1,24 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: feature-request
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->
**Describe the solution you'd like**
<!-- A clear and concise description of what you want to happen. -->
**Describe alternatives you've considered**
<!-- A clear and concise description of any alternative solutions or features you've considered. -->
**Additional context**
<!-- Add any other context or screenshots about the feature request here. -->
@@ -1,42 +0,0 @@
name: "Feature Request"
description: "Suggest an idea for the Bluesky app."
labels: ["feature-request"]
body:
- type: markdown
attributes:
value: |
Thanks for submitting a feature request! Before you submit this request, please make sure you've searched for similar requests that are already open and being tracked. If you find an open request that seems relevant to yours, it is best to leave a response there with your information instead of opening a new request, since it helps to consolidate the info in one place.
- type: textarea
attributes:
label: Describe the Feature
description: |
Please provide a detailed description of the feature you would like to see implemented in the app. The more
information you provide, the easier it will be for us to understand the feature you are requesting and to
implement it in a timely manner.
validations:
required: true
- type: textarea
attributes:
label: Attachments
description: |
If helpful, provide some images or videos of similar features in other apps that you think would be helpful
for us to understand the feature you are requesting, or places within the app that you think the feature belongs
in or is missing from.
validations:
required: false
- type: textarea
attributes:
label: Describe Alternatives
description: |
If there are any alternative solutions or features that you think would be helpful to implement in place of the
feature you are requesting, please provide a description of those as well.
validations:
required: false
- type: textarea
attributes:
label: Additional Context
description: |
If there is any additional context or information that you think would be helpful for us to know about the
feature you are requesting, please provide that information here.
validations:
required: false
-3
View File
@@ -15,6 +15,3 @@ android
ios
src/locale/locales
lib/react-compiler-runtime
bskyweb/static
coverage
web-build
+2 -1
View File
@@ -163,7 +163,8 @@ appId: xyz.blueskyweb.app
- tapOn:
label: "Adds and removes users on curatelists from the profile"
id: "bottomBarSearchBtn"
- tapOn: "Search"
- tapOn:
id: "searchTextInput"
- inputText: "bob"
- tapOn:
id: "searchAutoCompleteResult-bob.test"
+4
View File
@@ -17,6 +17,10 @@ appId: xyz.blueskyweb.app
id: "viewHeaderDrawerBtn"
- tapOn:
id: "profileCardButton"
- swipe:
from:
id: "profilePager-selector"
direction: LEFT
- tapOn:
id: "profilePager-selector-4"
- tapOn: "alice-favs"
+4
View File
@@ -20,6 +20,10 @@ appId: xyz.blueskyweb.app
- tapOn:
label: "Feeds button disappears after pinning a feed"
id: "bottomBarProfileBtn"
- swipe:
from:
id: "profilePager-selector"
direction: LEFT
- tapOn:
id: "profilePager-selector-4"
- tapOn: "alice-favs"
+15
View File
@@ -17,6 +17,21 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "bottomBarProfileBtn"
# Can see feeds
- swipe:
from:
id: "profilePager-selector"
direction: LEFT
- tapOn:
id: "profilePager-selector-4"
- assertVisible: "alice-favs"
- swipe:
from:
id: "profilePager-selector"
direction: RIGHT
- tapOn:
id: "profilePager-selector-0"
# Open and close edit profile modal
- tapOn:
id: "profileHeaderEditProfileButton"
+2 -1
View File
@@ -15,7 +15,8 @@ appId: xyz.blueskyweb.app
id: "bottomBarSearchBtn"
- tapOn:
id: "bottomBarSearchBtn"
- tapOn: "Search"
- tapOn:
id: "searchTextInput"
- inputText: "b"
- tapOn:
id: "searchAutoCompleteResult-bob.test"
+2 -1
View File
@@ -12,7 +12,8 @@ appId: xyz.blueskyweb.app
# Navigate to another user profile via autocomplete
- tapOn:
id: "bottomBarSearchBtn"
- tapOn: "Search"
- tapOn:
id: "searchTextInput"
- inputText: "b"
- tapOn:
id: "searchAutoCompleteResult-bob.test"
-7
View File
@@ -236,13 +236,6 @@ module.exports = function (config) {
fonts: [
'./assets/fonts/inter/InterVariable.ttf',
'./assets/fonts/inter/InterVariable-Italic.ttf',
// Android only
'./assets/fonts/inter/Inter-Regular.otf',
'./assets/fonts/inter/Inter-Italic.otf',
'./assets/fonts/inter/Inter-SemiBold.otf',
'./assets/fonts/inter/Inter-SemiBoldItalic.otf',
'./assets/fonts/inter/Inter-ExtraBold.otf',
'./assets/fonts/inter/Inter-ExtraBoldItalic.otf',
],
},
],
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" fill-rule="evenodd" d="M4 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v6.386c1.064-.002 2 .86 2 2.001V19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-6.613c0-1.142.936-2.003 2-2.001V4Zm2 6.946 6 2 6-2V4H6v6.946ZM9 8a1 1 0 0 1 1-1h4a1 1 0 1 1 0 2h-4a1 1 0 0 1-1-1Zm2.367 6.843L4 12.387V19h16v-6.613l-7.367 2.456a2 2 0 0 1-1.265 0Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 426 B

-7
View File
@@ -87,13 +87,6 @@ func run(args []string) {
Value: cli.NewStringSlice("https://bsky.app", "https://main.bsky.dev", "https://app.staging.bsky.dev"),
EnvVars: []string{"CORS_ALLOWED_ORIGINS"},
},
&cli.StringFlag{
Name: "static-cdn-host",
Usage: "scheme, hostname, and port of static content CDN, don't end with a slash",
Required: false,
Value: "",
EnvVars: []string{"STATIC_CDN_HOST"},
},
},
},
}
+36 -44
View File
@@ -38,18 +38,15 @@ type Server struct {
httpd *http.Server
xrpcc *xrpc.Client
cfg *Config
ipccClient http.Client
}
type Config struct {
debug bool
httpAddress string
appviewHost string
ogcardHost string
linkHost string
ipccHost string
staticCDNHost string
debug bool
httpAddress string
appviewHost string
ogcardHost string
linkHost string
ipccHost string
}
func serve(cctx *cli.Context) error {
@@ -61,8 +58,6 @@ func serve(cctx *cli.Context) error {
ipccHost := cctx.String("ipcc-host")
basicAuthPassword := cctx.String("basic-auth-password")
corsOrigins := cctx.StringSlice("cors-allowed-origins")
staticCDNHost := cctx.String("static-cdn-host")
staticCDNHost = strings.TrimSuffix(staticCDNHost, "/")
// Echo
e := echo.New()
@@ -99,20 +94,12 @@ func serve(cctx *cli.Context) error {
echo: e,
xrpcc: xrpcc,
cfg: &Config{
debug: debug,
httpAddress: httpAddress,
appviewHost: appviewHost,
ogcardHost: ogcardHost,
linkHost: linkHost,
ipccHost: ipccHost,
staticCDNHost: staticCDNHost,
},
ipccClient: http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
debug: debug,
httpAddress: httpAddress,
appviewHost: appviewHost,
ogcardHost: ogcardHost,
linkHost: linkHost,
ipccHost: ipccHost,
},
}
@@ -217,8 +204,14 @@ func serve(cctx *cli.Context) error {
path := c.Request().URL.Path
maxAge := 1 * (60 * 60) // default is 1 hour
// all assets in /static/js, /static/css, /static/media are content-hashed and can be cached for a long time
if strings.HasPrefix(path, "/static/js/") || strings.HasPrefix(path, "/static/css/") || strings.HasPrefix(path, "/static/media/") {
// Cache javascript and images files for 1 week, which works because
// they're always versioned (e.g. /static/js/main.64c14927.js)
if strings.HasPrefix(path, "/static/js/") || strings.HasPrefix(path, "/static/images/") || strings.HasPrefix(path, "/static/media/") {
maxAge = 7 * (60 * 60 * 24) // 1 week
}
// fonts can be cached for a year
if strings.HasSuffix(path, ".otf") {
maxAge = 365 * (60 * 60 * 24) // 1 year
}
@@ -262,7 +255,6 @@ func serve(cctx *cli.Context) error {
e.GET("/support/community-guidelines", server.WebGeneric)
e.GET("/support/copyright", server.WebGeneric)
e.GET("/intent/compose", server.WebGeneric)
e.GET("/intent/verify-email", server.WebGeneric)
e.GET("/messages", server.WebGeneric)
e.GET("/messages/:conversation", server.WebGeneric)
@@ -287,7 +279,6 @@ func serve(cctx *cli.Context) error {
// starter packs
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
e.GET("/starter-pack-short/:code", server.WebGeneric)
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
// ipcc
@@ -346,21 +337,15 @@ func (srv *Server) Shutdown() error {
return srv.httpd.Shutdown(ctx)
}
// NewTemplateContext returns a new pongo2 context with some default values.
func (srv *Server) NewTemplateContext() pongo2.Context {
return pongo2.Context{
"staticCDNHost": srv.cfg.staticCDNHost,
}
}
func (srv *Server) errorHandler(err error, c echo.Context) {
code := http.StatusInternalServerError
if he, ok := err.(*echo.HTTPError); ok {
code = he.Code
}
c.Logger().Error(err)
data := srv.NewTemplateContext()
data["statusCode"] = code
data := pongo2.Context{
"statusCode": code,
}
c.Render(code, "error.html", data)
}
@@ -404,18 +389,18 @@ func (srv *Server) LinkProxyMiddleware(url *url.URL) echo.MiddlewareFunc {
// handler for endpoint that have no specific server-side handling
func (srv *Server) WebGeneric(c echo.Context) error {
data := srv.NewTemplateContext()
data := pongo2.Context{}
return c.Render(http.StatusOK, "base.html", data)
}
func (srv *Server) WebHome(c echo.Context) error {
data := srv.NewTemplateContext()
data := pongo2.Context{}
return c.Render(http.StatusOK, "home.html", data)
}
func (srv *Server) WebPost(c echo.Context) error {
ctx := c.Request().Context()
data := srv.NewTemplateContext()
data := pongo2.Context{}
// sanity check arguments. don't 4xx, just let app handle if not expected format
rkeyParam := c.Param("rkey")
@@ -490,7 +475,7 @@ func (srv *Server) WebPost(c echo.Context) error {
func (srv *Server) WebStarterPack(c echo.Context) error {
req := c.Request()
ctx := req.Context()
data := srv.NewTemplateContext()
data := pongo2.Context{}
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
// sanity check arguments. don't 4xx, just let app handle if not expected format
rkeyParam := c.Param("rkey")
@@ -528,7 +513,7 @@ func (srv *Server) WebStarterPack(c echo.Context) error {
func (srv *Server) WebProfile(c echo.Context) error {
ctx := c.Request().Context()
data := srv.NewTemplateContext()
data := pongo2.Context{}
// sanity check arguments. don't 4xx, just let app handle if not expected format
handleOrDIDParam := c.Param("handleOrDID")
@@ -593,8 +578,15 @@ func (srv *Server) WebIpCC(c echo.Context) error {
}
ipccUrlBuilder.Path = "ipccdata.IpCcService/Lookup"
ipccUrl := ipccUrlBuilder.String()
cl := http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
}
postBodyReader := bytes.NewReader(request)
response, err := srv.ipccClient.Post(ipccUrl, "application/json", postBodyReader)
response, err := cl.Post(ipccUrl, "application/json", postBodyReader)
if err != nil {
log.Warnf("ipcc backend error %s", err)
return c.JSON(500, IPCCResponse{})
+6 -24
View File
@@ -13,8 +13,8 @@
<!-- Hello Humans! API docs at https://atproto.com -->
<link rel="preload" as="font" type="font/ttf" href="{{ staticCDNHost }}/static/media/InterVariable.c9f788f6e7ebaec75d7c.ttf">
<link rel="preload" as="font" type="font/ttf" href="{{ staticCDNHost }}/static/media/InterVariable-Italic.55d6a3f35e9b605ba6f4.ttf">
<link rel="preload" as="font" type="font/ttf" href="/static/media/InterVariable.c9f788f6e7ebaec75d7c.ttf">
<link rel="preload" as="font" type="font/ttf" href="/static/media/InterVariable-Italic.55d6a3f35e9b605ba6f4.ttf">
<style>
/**
@@ -24,20 +24,6 @@
*
* THIS NEEDS TO BE DUPLICATED IN `bskyweb/templates/base.html`
*/
@font-face {
font-family: 'InterVariable';
src: url("{{ staticCDNHost }}/static/media/InterVariable.c9f788f6e7ebaec75d7c.ttf") format('truetype');
font-weight: 300 1000;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'InterVariableItalic';
src: url("{{ staticCDNHost }}/static/media/InterVariable-Italic.55d6a3f35e9b605ba6f4.ttf") format('truetype');
font-weight: 300 1000;
font-style: italic;
font-display: swap;
}
html {
background-color: white;
scrollbar-gutter: stable both-edges;
@@ -75,17 +61,13 @@
top: 50%;
transform: translateX(-50%) translateY(-50%) translateY(-50px);
}
/* We need this style to prevent web dropdowns from shifting the display when opening */
body {
width: 100%;
}
</style>
{% include "scripts.html" %}
<link rel="apple-touch-icon" sizes="180x180" href="{{ staticCDNHost }}/static/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="{{ staticCDNHost }}/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="{{ staticCDNHost }}/static/favicon-16x16.png">
<link rel="mask-icon" href="{{ staticCDNHost }}/static/safari-pinned-tab.svg" color="#1185fe">
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
<link rel="mask-icon" href="/static/safari-pinned-tab.svg" color="#1185fe">
<meta name="theme-color">
<meta name="application-name" content="Bluesky">
<meta name="generator" content="bskyweb">
-5
View File
@@ -96,11 +96,6 @@ jest.mock('expo-modules-core', () => ({
getIsReducedMotionEnabled: () => false,
}
}
if (moduleName === 'BottomSheet') {
return {
dismissAll: () => {},
}
}
}),
requireNativeViewManager: jest.fn().mockImplementation(moduleName => {
return () => null
-1
View File
@@ -18,7 +18,6 @@ module.exports = {
'tr',
'uk',
'zh-CN',
'zh-HK',
'zh-TW',
],
catalogs: [
+14 -41
View File
@@ -34,7 +34,6 @@ class ViewController: UIViewController, WKScriptMessageHandler, WKNavigationDele
webView.navigationDelegate = self
self.view.addSubview(webView)
self.webView = webView
self.webView?.load(URLRequest(url: URL(string: "https://bsky.app/?splash=true&clip=true")!))
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
@@ -43,14 +42,16 @@ class ViewController: UIViewController, WKScriptMessageHandler, WKNavigationDele
let payload = try? JSONDecoder().decode(WebViewActionPayload.self, from: data) else {
return
}
switch payload.action {
case .present:
self.presentAppStoreOverlay()
if let url = self.starterPackUrl {
defaults?.setValue(url.absoluteString, forKey: "starterPackUri")
guard let url = self.starterPackUrl else {
return
}
self.presentAppStoreOverlay()
defaults?.setValue(url.absoluteString, forKey: "starterPackUri")
case .store:
guard let keyToStoreAs = payload.keyToStoreAs, let jsonToStore = payload.jsonToStore else {
return
@@ -65,51 +66,23 @@ class ViewController: UIViewController, WKScriptMessageHandler, WKNavigationDele
guard let url = navigationAction.request.url else {
return .allow
}
// Store the previous one to compare later, but only set starterPackUrl when we find the right one
prevUrl = url
// pathComponents starts with "/" as the first component, then each path name. so...
// ["/", "start", "name", "rkey"]
if isStarterPackUrl(url){
if url.pathComponents.count == 4,
url.pathComponents[1] == "start" {
self.starterPackUrl = url
}
return .allow
}
func isStarterPackUrl(_ url: URL) -> Bool {
var host: String?
if #available(iOS 16.0, *) {
host = url.host()
} else {
host = url.host
}
switch host {
case "bsky.app":
if url.pathComponents.count == 4,
(url.pathComponents[1] == "start" || url.pathComponents[1] == "starter-pack") {
return true
}
return false
case "go.bsky.app":
if url.pathComponents.count == 2 {
return true
}
return false
default:
return false
}
}
func handleURL(url: URL) {
if isStarterPackUrl(url) {
let urlString = "\(url.absoluteString)?clip=true"
if let url = URL(string: urlString) {
self.webView?.load(URLRequest(url: url))
}
} else {
self.webView?.load(URLRequest(url: URL(string: "https://bsky.app/?splash=true&clip=true")!))
let urlString = "\(url.absoluteString)?clip=true"
if let url = URL(string: urlString) {
self.webView?.load(URLRequest(url: url))
}
}
@@ -1,5 +1,4 @@
import UIKit
import AVKit
let IMAGE_EXTENSIONS: [String] = ["png", "jpg", "jpeg", "gif", "heic"]
let MOVIE_EXTENSIONS: [String] = ["mov", "mp4", "m4v"]
@@ -120,11 +119,16 @@ class ShareViewController: UIViewController {
private func handleVideos(items: [NSItemProvider]) async {
let firstItem = items.first
if let dataUrl = try? await firstItem?.loadItem(forTypeIdentifier: "public.movie") as? URL {
let ext = String(dataUrl.lastPathComponent.split(separator: ".").last ?? "mp4")
if let videoUriInfo = saveVideoWithInfo(dataUrl),
let url = URL(string: "\(self.appScheme)://intent/compose?videoUri=\(videoUriInfo)") {
_ = self.openURL(url)
if let dataUri = try? await firstItem?.loadItem(forTypeIdentifier: "public.movie") as? URL {
let ext = String(dataUri.lastPathComponent.split(separator: ".").last ?? "mp4")
if let tempUrl = getTempUrl(ext: ext) {
let data = try? Data(contentsOf: dataUri)
try? data?.write(to: tempUrl)
if let encoded = tempUrl.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
let url = URL(string: "\(self.appScheme)://intent/compose?videoUri=\(encoded)") {
_ = self.openURL(url)
}
}
}
@@ -149,24 +153,6 @@ class ShareViewController: UIViewController {
return nil
}
private func saveVideoWithInfo(_ dataUrl: URL) -> String? {
let ext = String(dataUrl.lastPathComponent.split(separator: ".").last ?? "mp4")
guard let tempUrl = getTempUrl(ext: ext) else {
return nil
}
let data = try? Data(contentsOf: dataUrl)
try? data?.write(to: tempUrl)
guard let track = AVURLAsset(url: dataUrl).tracks(withMediaType: AVMediaType.video).first else {
_ = try? FileManager().removeItem(at: tempUrl)
return nil
}
let size = track.naturalSize.applying(track.preferredTransform)
return "\(tempUrl.absoluteString)|\(size.width)|\(size.height)"
}
private func completeRequest() {
self.extensionContext?.completeRequest(returningItems: nil)
}
-49
View File
@@ -1,49 +0,0 @@
apply plugin: 'com.android.library'
group = 'expo.modules.bottomsheet'
version = '0.1.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useExpoPublishing()
// If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
// Most of the time, you may like to manage the Android SDK versions yourself.
def useManagedAndroidSdkVersions = false
if (useManagedAndroidSdkVersions) {
useDefaultAndroidSdkVersions()
} else {
buildscript {
// Simple helper that allows the root project to override versions declared by this library.
ext.safeExtGet = { prop, fallback ->
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
}
project.android {
compileSdkVersion safeExtGet("compileSdkVersion", 34)
defaultConfig {
minSdkVersion safeExtGet("minSdkVersion", 21)
targetSdkVersion safeExtGet("targetSdkVersion", 34)
}
}
}
android {
namespace "expo.modules.bottomsheet"
defaultConfig {
versionCode 1
versionName "0.1.0"
}
lintOptions {
abortOnError false
}
}
dependencies {
implementation project(':expo-modules-core')
implementation 'com.google.android.material:material:1.12.0'
implementation "com.facebook.react:react-native:+"
}
@@ -1,2 +0,0 @@
<manifest>
</manifest>
@@ -1,53 +0,0 @@
package expo.modules.bottomsheet
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class BottomSheetModule : Module() {
override fun definition() =
ModuleDefinition {
Name("BottomSheet")
AsyncFunction("dismissAll") {
SheetManager.dismissAll()
}
View(BottomSheetView::class) {
Events(
arrayOf(
"onAttemptDismiss",
"onSnapPointChange",
"onStateChange",
),
)
AsyncFunction("dismiss") { view: BottomSheetView ->
view.dismiss()
}
AsyncFunction("updateLayout") { view: BottomSheetView ->
view.updateLayout()
}
Prop("disableDrag") { view: BottomSheetView, prop: Boolean ->
view.disableDrag = prop
}
Prop("minHeight") { view: BottomSheetView, prop: Float ->
view.minHeight = prop
}
Prop("maxHeight") { view: BottomSheetView, prop: Float ->
view.maxHeight = prop
}
Prop("preventDismiss") { view: BottomSheetView, prop: Boolean ->
view.preventDismiss = prop
}
Prop("preventExpansion") { view: BottomSheetView, prop: Boolean ->
view.preventExpansion = prop
}
}
}
}
@@ -1,344 +0,0 @@
package expo.modules.bottomsheet
import android.content.Context
import android.util.DisplayMetrics
import android.view.View
import android.view.ViewGroup
import android.view.ViewStructure
import android.view.accessibility.AccessibilityEvent
import android.widget.FrameLayout
import androidx.core.view.allViews
import com.facebook.react.bridge.LifecycleEventListener
import com.facebook.react.bridge.ReactContext
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.events.EventDispatcher
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
class BottomSheetView(
context: Context,
appContext: AppContext,
) : ExpoView(context, appContext),
LifecycleEventListener {
private var innerView: View? = null
private var dialog: BottomSheetDialog? = null
private lateinit var dialogRootViewGroup: DialogRootViewGroup
private var eventDispatcher: EventDispatcher? = null
private val screenHeight =
context.resources.displayMetrics.heightPixels
.toFloat()
private val onAttemptDismiss by EventDispatcher()
private val onSnapPointChange by EventDispatcher()
private val onStateChange by EventDispatcher()
// Props
var disableDrag = false
set (value) {
field = value
this.setDraggable(!value)
}
var preventDismiss = false
set(value) {
field = value
this.dialog?.setCancelable(!value)
}
var preventExpansion = false
var minHeight = 0f
set(value) {
field =
if (value < 0) {
0f
} else {
dpToPx(value)
}
}
var maxHeight = this.screenHeight
set(value) {
val px = dpToPx(value)
field =
if (px > this.screenHeight) {
this.screenHeight
} else {
px
}
}
private var isOpen: Boolean = false
set(value) {
field = value
onStateChange(
mapOf(
"state" to if (value) "open" else "closed",
),
)
}
private var isOpening: Boolean = false
set(value) {
field = value
if (value) {
onStateChange(
mapOf(
"state" to "opening",
),
)
}
}
private var isClosing: Boolean = false
set(value) {
field = value
if (value) {
onStateChange(
mapOf(
"state" to "closing",
),
)
}
}
private var selectedSnapPoint = 0
set(value) {
if (field == value) return
field = value
onSnapPointChange(
mapOf(
"snapPoint" to value,
),
)
}
// Lifecycle
init {
(appContext.reactContext as? ReactContext)?.let {
it.addLifecycleEventListener(this)
this.eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(it, this.id)
this.dialogRootViewGroup = DialogRootViewGroup(context)
this.dialogRootViewGroup.eventDispatcher = this.eventDispatcher
}
SheetManager.add(this)
}
override fun onLayout(
changed: Boolean,
l: Int,
t: Int,
r: Int,
b: Int,
) {
this.present()
}
private fun destroy() {
this.isClosing = false
this.isOpen = false
this.dialog = null
this.innerView = null
SheetManager.remove(this)
}
// Presentation
private fun present() {
if (this.isOpen || this.isOpening || this.isClosing) return
val contentHeight = this.getContentHeight()
val dialog = BottomSheetDialog(context)
dialog.setContentView(dialogRootViewGroup)
dialog.setCancelable(!preventDismiss)
dialog.setOnDismissListener {
this.isClosing = true
this.destroy()
}
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
bottomSheet?.let {
it.setBackgroundColor(0)
val behavior = BottomSheetBehavior.from(it)
behavior.state = BottomSheetBehavior.STATE_HIDDEN
behavior.isFitToContents = true
behavior.halfExpandedRatio = this.clampRatio(this.getTargetHeight() / this.screenHeight)
behavior.skipCollapsed = true
behavior.isDraggable = true
behavior.isHideable = true
if (contentHeight >= this.screenHeight || this.minHeight >= this.screenHeight) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
this.selectedSnapPoint = 2
} else {
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
this.selectedSnapPoint = 1
}
behavior.addBottomSheetCallback(
object : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(
bottomSheet: View,
newState: Int,
) {
when (newState) {
BottomSheetBehavior.STATE_EXPANDED -> {
selectedSnapPoint = 2
}
BottomSheetBehavior.STATE_COLLAPSED -> {
selectedSnapPoint = 1
}
BottomSheetBehavior.STATE_HALF_EXPANDED -> {
selectedSnapPoint = 1
}
BottomSheetBehavior.STATE_HIDDEN -> {
selectedSnapPoint = 0
}
}
}
override fun onSlide(
bottomSheet: View,
slideOffset: Float,
) { }
},
)
}
this.isOpening = true
dialog.show()
this.dialog = dialog
}
fun updateLayout() {
val dialog = this.dialog ?: return
val contentHeight = this.getContentHeight()
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
bottomSheet?.let {
val behavior = BottomSheetBehavior.from(it)
behavior.halfExpandedRatio = this.clampRatio(this.getTargetHeight() / this.screenHeight)
if (contentHeight > this.screenHeight && behavior.state != BottomSheetBehavior.STATE_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
} else if (contentHeight < this.screenHeight && behavior.state != BottomSheetBehavior.STATE_HALF_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
}
}
}
fun dismiss() {
this.dialog?.dismiss()
}
// Util
private fun getContentHeight(): Float {
val innerView = this.innerView ?: return 0f
var index = 0
innerView.allViews.forEach {
if (index == 1) {
return it.height.toFloat()
}
index++
}
return 0f
}
private fun getTargetHeight(): Float {
val contentHeight = this.getContentHeight()
val height =
if (contentHeight > maxHeight) {
maxHeight
} else if (contentHeight < minHeight) {
minHeight
} else {
contentHeight
}
return height
}
private fun clampRatio(ratio: Float): Float {
if (ratio < 0.01) {
return 0.01f
} else if (ratio > 0.99) {
return 0.99f
}
return ratio
}
private fun setDraggable(draggable: Boolean) {
val dialog = this.dialog ?: return
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
bottomSheet?.let {
val behavior = BottomSheetBehavior.from(it)
behavior.isDraggable = draggable
}
}
override fun onHostResume() { }
override fun onHostPause() { }
override fun onHostDestroy() {
(appContext.reactContext as? ReactContext)?.let {
it.removeLifecycleEventListener(this)
this.destroy()
}
}
// View overrides to pass to DialogRootViewGroup instead
override fun dispatchProvideStructure(structure: ViewStructure?) {
dialogRootViewGroup.dispatchProvideStructure(structure)
}
override fun setId(id: Int) {
super.setId(id)
dialogRootViewGroup.id = id
}
override fun addView(
child: View?,
index: Int,
) {
this.innerView = child
(child as ViewGroup).let {
dialogRootViewGroup.addView(child, index)
}
}
override fun removeView(view: View?) {
UiThreadUtil.assertOnUiThread()
if (view != null) {
dialogRootViewGroup.removeView(view)
}
}
override fun removeViewAt(index: Int) {
UiThreadUtil.assertOnUiThread()
val child = getChildAt(index)
dialogRootViewGroup.removeView(child)
}
override fun addChildrenForAccessibility(outChildren: ArrayList<View>?) { }
override fun dispatchPopulateAccessibilityEvent(event: AccessibilityEvent?): Boolean = false
// https://stackoverflow.com/questions/11862391/getheight-px-or-dpi
fun dpToPx(dp: Float): Float {
val displayMetrics = context.resources.displayMetrics
val px = dp * (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT)
return px
}
}
@@ -1,171 +0,0 @@
package expo.modules.bottomsheet
import android.annotation.SuppressLint
import android.content.Context
import android.view.MotionEvent
import android.view.View
import com.facebook.react.bridge.GuardedRunnable
import com.facebook.react.config.ReactFeatureFlags
import com.facebook.react.uimanager.JSPointerDispatcher
import com.facebook.react.uimanager.JSTouchDispatcher
import com.facebook.react.uimanager.RootView
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.UIManagerModule
import com.facebook.react.uimanager.events.EventDispatcher
import com.facebook.react.views.view.ReactViewGroup
// SEE https://github.com/facebook/react-native/blob/309cdea337101cfe2212cfb6abebf1e783e43282/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt#L378
/**
* DialogRootViewGroup is the ViewGroup which contains all the children of a Modal. It gets all
* child information forwarded from [ReactModalHostView] and uses that to create children. It is
* also responsible for acting as a RootView and handling touch events. It does this the same way
* as ReactRootView.
*
* To get layout to work properly, we need to layout all the elements within the Modal as if they
* can fill the entire window. To do that, we need to explicitly set the styleWidth and
* styleHeight on the LayoutShadowNode to be the window size. This is done through the
* UIManagerModule, and will then cause the children to layout as if they can fill the window.
*/
class DialogRootViewGroup(
private val context: Context?,
) : ReactViewGroup(context),
RootView {
private var hasAdjustedSize = false
private var viewWidth = 0
private var viewHeight = 0
private val jSTouchDispatcher = JSTouchDispatcher(this)
private var jSPointerDispatcher: JSPointerDispatcher? = null
private var sizeChangeListener: OnSizeChangeListener? = null
var eventDispatcher: EventDispatcher? = null
interface OnSizeChangeListener {
fun onSizeChange(
width: Int,
height: Int,
)
}
init {
if (ReactFeatureFlags.dispatchPointerEvents) {
jSPointerDispatcher = JSPointerDispatcher(this)
}
}
override fun onSizeChanged(
w: Int,
h: Int,
oldw: Int,
oldh: Int,
) {
super.onSizeChanged(w, h, oldw, oldh)
viewWidth = w
viewHeight = h
updateFirstChildView()
sizeChangeListener?.onSizeChange(w, h)
}
fun setOnSizeChangeListener(listener: OnSizeChangeListener) {
sizeChangeListener = listener
}
private fun updateFirstChildView() {
if (childCount > 0) {
hasAdjustedSize = false
val viewTag = getChildAt(0).id
reactContext.runOnNativeModulesQueueThread(
object : GuardedRunnable(reactContext) {
override fun runGuarded() {
val uiManager: UIManagerModule =
reactContext
.reactApplicationContext
.getNativeModule(UIManagerModule::class.java) ?: return
uiManager.updateNodeSize(viewTag, viewWidth, viewHeight)
}
},
)
} else {
hasAdjustedSize = true
}
}
override fun addView(
child: View,
index: Int,
params: LayoutParams,
) {
super.addView(child, index, params)
if (hasAdjustedSize) {
updateFirstChildView()
}
}
override fun handleException(t: Throwable) {
reactContext.reactApplicationContext.handleException(RuntimeException(t))
}
private val reactContext: ThemedReactContext
get() = context as ThemedReactContext
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
eventDispatcher?.let { jSTouchDispatcher.handleTouchEvent(event, it) }
jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, true)
return super.onInterceptTouchEvent(event)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
eventDispatcher?.let { jSTouchDispatcher.handleTouchEvent(event, it) }
jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, false)
super.onTouchEvent(event)
// In case when there is no children interested in handling touch event, we return true from
// the root view in order to receive subsequent events related to that gesture
return true
}
override fun onInterceptHoverEvent(event: MotionEvent): Boolean {
jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, true)
return super.onHoverEvent(event)
}
override fun onHoverEvent(event: MotionEvent): Boolean {
jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, false)
return super.onHoverEvent(event)
}
@Deprecated("Deprecated in Java")
override fun onChildStartedNativeGesture(ev: MotionEvent?) {
eventDispatcher?.let {
if (ev != null) {
jSTouchDispatcher.onChildStartedNativeGesture(ev, it)
}
}
}
override fun onChildStartedNativeGesture(
childView: View,
ev: MotionEvent,
) {
eventDispatcher?.let { jSTouchDispatcher.onChildStartedNativeGesture(ev, it) }
jSPointerDispatcher?.onChildStartedNativeGesture(childView, ev, eventDispatcher)
}
override fun onChildEndedNativeGesture(
childView: View,
ev: MotionEvent,
) {
eventDispatcher?.let { jSTouchDispatcher.onChildEndedNativeGesture(ev, it) }
jSPointerDispatcher?.onChildEndedNativeGesture()
}
override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
// No-op - override in order to still receive events to onInterceptTouchEvent
// even when some other view disallow that
}
}
@@ -1,28 +0,0 @@
package expo.modules.bottomsheet
import java.lang.ref.WeakReference
class SheetManager {
companion object {
private val sheets = mutableSetOf<WeakReference<BottomSheetView>>()
fun add(view: BottomSheetView) {
sheets.add(WeakReference(view))
}
fun remove(view: BottomSheetView) {
sheets.forEach {
if (it.get() == view) {
sheets.remove(it)
return
}
}
}
fun dismissAll() {
sheets.forEach {
it.get()?.dismiss()
}
}
}
}
@@ -1,9 +0,0 @@
{
"platforms": ["ios", "android"],
"ios": {
"modules": ["BottomSheetModule"]
},
"android": {
"modules": ["expo.modules.bottomsheet.BottomSheetModule"]
}
}
-23
View File
@@ -1,23 +0,0 @@
import {BottomSheet} from './src/BottomSheet'
import {
BottomSheetSnapPoint,
BottomSheetState,
BottomSheetViewProps,
} from './src/BottomSheet.types'
import {BottomSheetNativeComponent} from './src/BottomSheetNativeComponent'
import {
BottomSheetOutlet,
BottomSheetPortalProvider,
BottomSheetProvider,
} from './src/BottomSheetPortal'
export {
BottomSheet,
BottomSheetNativeComponent,
BottomSheetOutlet,
BottomSheetPortalProvider,
BottomSheetProvider,
BottomSheetSnapPoint,
type BottomSheetState,
type BottomSheetViewProps,
}
@@ -1,21 +0,0 @@
Pod::Spec.new do |s|
s.name = 'BottomSheet'
s.version = '1.0.0'
s.summary = 'A bottom sheet for use in Bluesky'
s.description = 'A bottom sheet for use in Bluesky'
s.author = ''
s.homepage = 'https://github.com/bluesky-social/social-app'
s.platforms = { :ios => '15.0', :tvos => '15.0' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,swift}"
end
@@ -1,47 +0,0 @@
import ExpoModulesCore
public class BottomSheetModule: Module {
public func definition() -> ModuleDefinition {
Name("BottomSheet")
AsyncFunction("dismissAll") {
SheetManager.shared.dismissAll()
}
View(SheetView.self) {
Events([
"onAttemptDismiss",
"onSnapPointChange",
"onStateChange"
])
AsyncFunction("dismiss") { (view: SheetView) in
view.dismiss()
}
AsyncFunction("updateLayout") { (view: SheetView) in
view.updateLayout()
}
Prop("cornerRadius") { (view: SheetView, prop: Float) in
view.cornerRadius = CGFloat(prop)
}
Prop("minHeight") { (view: SheetView, prop: Double) in
view.minHeight = prop
}
Prop("maxHeight") { (view: SheetView, prop: Double) in
view.maxHeight = prop
}
Prop("preventDismiss") { (view: SheetView, prop: Bool) in
view.preventDismiss = prop
}
Prop("preventExpansion") { (view: SheetView, prop: Bool) in
view.preventExpansion = prop
}
}
}
}
@@ -1,28 +0,0 @@
//
// SheetManager.swift
// Pods
//
// Created by Hailey on 10/1/24.
//
import ExpoModulesCore
class SheetManager {
static let shared = SheetManager()
private var sheetViews = NSHashTable<SheetView>(options: .weakMemory)
func add(_ view: SheetView) {
sheetViews.add(view)
}
func remove(_ view: SheetView) {
sheetViews.remove(view)
}
func dismissAll() {
sheetViews.allObjects.forEach { sheetView in
sheetView.dismiss()
}
}
}
-192
View File
@@ -1,192 +0,0 @@
import ExpoModulesCore
import UIKit
class SheetView: ExpoView, UISheetPresentationControllerDelegate {
// Views
private var sheetVc: SheetViewController?
private var innerView: UIView?
private var touchHandler: RCTTouchHandler?
// Events
private let onAttemptDismiss = EventDispatcher()
private let onSnapPointChange = EventDispatcher()
private let onStateChange = EventDispatcher()
// Open event firing
private var isOpen: Bool = false {
didSet {
onStateChange([
"state": isOpen ? "open" : "closed"
])
}
}
// React view props
var preventDismiss = false
var preventExpansion = false
var cornerRadius: CGFloat?
var minHeight = 0.0
var maxHeight: CGFloat! {
didSet {
let screenHeight = Util.getScreenHeight() ?? 0
if maxHeight > screenHeight {
maxHeight = screenHeight
}
}
}
private var isOpening = false {
didSet {
if isOpening {
onStateChange([
"state": "opening"
])
}
}
}
private var isClosing = false {
didSet {
if isClosing {
onStateChange([
"state": "closing"
])
}
}
}
private var selectedDetentIdentifier: UISheetPresentationController.Detent.Identifier? {
didSet {
if selectedDetentIdentifier == .large {
onSnapPointChange([
"snapPoint": 2
])
} else {
onSnapPointChange([
"snapPoint": 1
])
}
}
}
private var prevLayoutDetentIdentifier: UISheetPresentationController.Detent.Identifier?
// MARK: - Lifecycle
required init (appContext: AppContext? = nil) {
super.init(appContext: appContext)
self.maxHeight = Util.getScreenHeight()
self.touchHandler = RCTTouchHandler(bridge: appContext?.reactBridge)
SheetManager.shared.add(self)
}
deinit {
self.destroy()
}
// We don't want this view to actually get added to the tree, so we'll simply store it for adding
// to the SheetViewController
override func insertReactSubview(_ subview: UIView!, at atIndex: Int) {
self.touchHandler?.attach(to: subview)
self.innerView = subview
}
// We'll grab the content height from here so we know the initial detent to set
override func layoutSubviews() {
super.layoutSubviews()
guard let innerView = self.innerView else {
return
}
if innerView.subviews.count != 1 {
return
}
self.present()
}
private func destroy() {
self.isClosing = false
self.isOpen = false
self.sheetVc = nil
self.touchHandler?.detach(from: self.innerView)
self.touchHandler = nil
self.innerView = nil
SheetManager.shared.remove(self)
}
// MARK: - Presentation
func present() {
guard !self.isOpen,
!self.isOpening,
!self.isClosing,
let innerView = self.innerView,
let contentHeight = innerView.subviews.first?.frame.height,
let rvc = self.reactViewController() else {
return
}
let sheetVc = SheetViewController()
sheetVc.setDetents(contentHeight: self.clampHeight(contentHeight), preventExpansion: self.preventExpansion)
if let sheet = sheetVc.sheetPresentationController {
sheet.delegate = self
sheet.preferredCornerRadius = self.cornerRadius
self.selectedDetentIdentifier = sheet.selectedDetentIdentifier
}
sheetVc.view.addSubview(innerView)
self.sheetVc = sheetVc
self.isOpening = true
rvc.present(sheetVc, animated: true) { [weak self] in
self?.isOpening = false
self?.isOpen = true
}
}
func updateLayout() {
if self.prevLayoutDetentIdentifier == self.selectedDetentIdentifier,
let contentHeight = self.innerView?.subviews.first?.frame.size.height {
self.sheetVc?.updateDetents(contentHeight: self.clampHeight(contentHeight),
preventExpansion: self.preventExpansion)
self.selectedDetentIdentifier = self.sheetVc?.getCurrentDetentIdentifier()
}
self.prevLayoutDetentIdentifier = self.selectedDetentIdentifier
}
func dismiss() {
self.isClosing = true
self.sheetVc?.dismiss(animated: true) { [weak self] in
self?.destroy()
}
}
// MARK: - Utils
private func clampHeight(_ height: CGFloat) -> CGFloat {
if height < self.minHeight {
return self.minHeight
} else if height > self.maxHeight {
return self.maxHeight
}
return height
}
// MARK: - UISheetPresentationControllerDelegate
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
self.onAttemptDismiss()
return !self.preventDismiss
}
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
self.isClosing = true
}
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
self.destroy()
}
func sheetPresentationControllerDidChangeSelectedDetentIdentifier(_ sheetPresentationController: UISheetPresentationController) {
self.selectedDetentIdentifier = sheetPresentationController.selectedDetentIdentifier
}
}
@@ -1,86 +0,0 @@
//
// SheetViewController.swift
// Pods
//
// Created by Hailey on 9/30/24.
//
import Foundation
import UIKit
class SheetViewController: UIViewController {
init() {
super.init(nibName: nil, bundle: nil)
self.modalPresentationStyle = .formSheet
self.isModalInPresentation = false
if let sheet = self.sheetPresentationController {
sheet.prefersGrabberVisible = false
}
}
func setDetents(contentHeight: CGFloat, preventExpansion: Bool) {
guard let sheet = self.sheetPresentationController,
let screenHeight = Util.getScreenHeight()
else {
return
}
if #available(iOS 16.0, *) {
if contentHeight > screenHeight - 100 {
sheet.detents = [
.large()
]
sheet.selectedDetentIdentifier = .large
} else {
sheet.detents = [
.custom { _ in
return contentHeight
}
]
if !preventExpansion {
sheet.detents.append(.large())
}
sheet.selectedDetentIdentifier = .medium
}
} else {
if contentHeight > screenHeight / 2 {
sheet.detents = [
.large()
]
sheet.selectedDetentIdentifier = .large
} else {
sheet.detents = [
.medium()
]
if !preventExpansion {
sheet.detents.append(.large())
}
sheet.selectedDetentIdentifier = .medium
}
}
}
func updateDetents(contentHeight: CGFloat, preventExpansion: Bool) {
if let sheet = self.sheetPresentationController {
sheet.animateChanges {
self.setDetents(contentHeight: contentHeight, preventExpansion: preventExpansion)
if #available(iOS 16.0, *) {
sheet.invalidateDetents()
}
}
}
}
func getCurrentDetentIdentifier() -> UISheetPresentationController.Detent.Identifier? {
guard let sheet = self.sheetPresentationController else {
return nil
}
return sheet.selectedDetentIdentifier
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
-18
View File
@@ -1,18 +0,0 @@
//
// Util.swift
// Pods
//
// Created by Hailey on 10/2/24.
//
class Util {
static func getScreenHeight() -> CGFloat? {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first {
let safeAreaInsets = window.safeAreaInsets
let fullScreenHeight = UIScreen.main.bounds.height
return fullScreenHeight - (safeAreaInsets.top + safeAreaInsets.bottom)
}
return nil
}
}
-24
View File
@@ -1,24 +0,0 @@
import React from 'react'
import {BottomSheetViewProps} from './BottomSheet.types'
import {BottomSheetNativeComponent} from './BottomSheetNativeComponent'
import {useBottomSheetPortal_INTERNAL} from './BottomSheetPortal'
export const BottomSheet = React.forwardRef<
BottomSheetNativeComponent,
BottomSheetViewProps
>(function BottomSheet(props, ref) {
const Portal = useBottomSheetPortal_INTERNAL()
if (__DEV__ && !Portal) {
throw new Error(
'BottomSheet: You need to wrap your component tree with a <BottomSheetPortalProvider> to use the bottom sheet.',
)
}
return (
<Portal>
<BottomSheetNativeComponent {...props} ref={ref} />
</Portal>
)
})
@@ -1,35 +0,0 @@
import React from 'react'
import {ColorValue, NativeSyntheticEvent} from 'react-native'
export type BottomSheetState = 'closed' | 'closing' | 'open' | 'opening'
export enum BottomSheetSnapPoint {
Hidden,
Partial,
Full,
}
export type BottomSheetAttemptDismissEvent = NativeSyntheticEvent<object>
export type BottomSheetSnapPointChangeEvent = NativeSyntheticEvent<{
snapPoint: BottomSheetSnapPoint
}>
export type BottomSheetStateChangeEvent = NativeSyntheticEvent<{
state: BottomSheetState
}>
export interface BottomSheetViewProps {
children: React.ReactNode
cornerRadius?: number
preventDismiss?: boolean
preventExpansion?: boolean
backgroundColor?: ColorValue
containerBackgroundColor?: ColorValue
disableDrag?: boolean
minHeight?: number
maxHeight?: number
onAttemptDismiss?: (event: BottomSheetAttemptDismissEvent) => void
onSnapPointChange?: (event: BottomSheetSnapPointChangeEvent) => void
onStateChange?: (event: BottomSheetStateChangeEvent) => void
}
@@ -1,5 +0,0 @@
import {BottomSheetViewProps} from './BottomSheet.types'
export function BottomSheet(_: BottomSheetViewProps) {
throw new Error('BottomSheet is not available on web')
}
@@ -1,125 +0,0 @@
import * as React from 'react'
import {
Dimensions,
NativeSyntheticEvent,
Platform,
StyleProp,
View,
ViewStyle,
} from 'react-native'
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
import {BottomSheetState, BottomSheetViewProps} from './BottomSheet.types'
import {BottomSheetPortalProvider} from './BottomSheetPortal'
const screenHeight = Dimensions.get('screen').height
const NativeView: React.ComponentType<
BottomSheetViewProps & {
ref: React.RefObject<any>
style: StyleProp<ViewStyle>
}
> = requireNativeViewManager('BottomSheet')
const NativeModule = requireNativeModule('BottomSheet')
const isIOS15 = Platform.OS === 'ios' && Number(Platform.Version) < 16
export class BottomSheetNativeComponent extends React.Component<
BottomSheetViewProps,
{
open: boolean
viewHeight?: number
}
> {
ref = React.createRef<any>()
constructor(props: BottomSheetViewProps) {
super(props)
this.state = {
open: false,
}
}
present() {
this.setState({open: true})
}
dismiss() {
this.ref.current?.dismiss()
}
private onStateChange = (
event: NativeSyntheticEvent<{state: BottomSheetState}>,
) => {
const {state} = event.nativeEvent
const isOpen = state !== 'closed'
this.setState({open: isOpen})
this.props.onStateChange?.(event)
}
private updateLayout = () => {
this.ref.current?.updateLayout()
}
static dismissAll = async () => {
await NativeModule.dismissAll()
}
render() {
const {children, backgroundColor, ...rest} = this.props
const cornerRadius = rest.cornerRadius ?? 0
let extraStyles
if (isIOS15 && this.state.viewHeight) {
const {viewHeight} = this.state
if (viewHeight < screenHeight / 2) {
extraStyles = {
height: viewHeight,
marginTop: screenHeight / 2 - viewHeight,
borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius,
}
}
}
if (!this.state.open) {
return null
}
return (
<NativeView
{...rest}
onStateChange={this.onStateChange}
ref={this.ref}
style={{
position: 'absolute',
height: screenHeight,
width: '100%',
}}
containerBackgroundColor={backgroundColor}>
<View
style={[
{
flex: 1,
backgroundColor,
},
Platform.OS === 'android' && {
borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius,
},
extraStyles,
]}>
<View
onLayout={e => {
const {height} = e.nativeEvent.layout
this.setState({viewHeight: height})
this.updateLayout()
}}>
<BottomSheetPortalProvider>{children}</BottomSheetPortalProvider>
</View>
</View>
</NativeView>
)
}
}
@@ -1,5 +0,0 @@
import {BottomSheetViewProps} from './BottomSheet.types'
export function BottomSheetNativeComponent(_: BottomSheetViewProps) {
throw new Error('BottomSheetNativeComponent is not available on web')
}
@@ -1,40 +0,0 @@
import React from 'react'
import {createPortalGroup_INTERNAL} from './lib/Portal'
type PortalContext = React.ElementType<{children: React.ReactNode}>
const Context = React.createContext({} as PortalContext)
export const useBottomSheetPortal_INTERNAL = () => React.useContext(Context)
export function BottomSheetPortalProvider({
children,
}: {
children: React.ReactNode
}) {
const portal = React.useMemo(() => {
return createPortalGroup_INTERNAL()
}, [])
return (
<Context.Provider value={portal.Portal}>
<portal.Provider>
{children}
<portal.Outlet />
</portal.Provider>
</Context.Provider>
)
}
const defaultPortal = createPortalGroup_INTERNAL()
export const BottomSheetOutlet = defaultPortal.Outlet
export function BottomSheetProvider({children}: {children: React.ReactNode}) {
return (
<Context.Provider value={defaultPortal.Portal}>
<defaultPortal.Provider>{children}</defaultPortal.Provider>
</Context.Provider>
)
}
-67
View File
@@ -1,67 +0,0 @@
import React from 'react'
type Component = React.ReactElement
type ContextType = {
outlet: Component | null
append(id: string, component: Component): void
remove(id: string): void
}
type ComponentMap = {
[id: string]: Component
}
export function createPortalGroup_INTERNAL() {
const Context = React.createContext<ContextType>({
outlet: null,
append: () => {},
remove: () => {},
})
function Provider(props: React.PropsWithChildren<{}>) {
const map = React.useRef<ComponentMap>({})
const [outlet, setOutlet] = React.useState<ContextType['outlet']>(null)
const append = React.useCallback<ContextType['append']>((id, component) => {
if (map.current[id]) return
map.current[id] = <React.Fragment key={id}>{component}</React.Fragment>
setOutlet(<>{Object.values(map.current)}</>)
}, [])
const remove = React.useCallback<ContextType['remove']>(id => {
delete map.current[id]
setOutlet(<>{Object.values(map.current)}</>)
}, [])
const contextValue = React.useMemo(
() => ({
outlet,
append,
remove,
}),
[outlet, append, remove],
)
return (
<Context.Provider value={contextValue}>{props.children}</Context.Provider>
)
}
function Outlet() {
const ctx = React.useContext(Context)
return ctx.outlet
}
function Portal({children}: React.PropsWithChildren<{}>) {
const {append, remove} = React.useContext(Context)
const id = React.useId()
React.useEffect(() => {
append(id, children as Component)
return () => remove(id)
}, [id, children, append, remove])
return null
}
return {Provider, Outlet, Portal}
}
@@ -2,7 +2,6 @@ package xyz.blueskyweb.app.exporeceiveandroidintents
import android.content.Intent
import android.graphics.Bitmap
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
@@ -144,10 +143,7 @@ class ExpoReceiveAndroidIntentsModule : Module() {
appContext.currentActivity?.contentResolver?.openInputStream(uri)?.use {
it.copyTo(out)
}
val info = getVideoInfo(uri) ?: return
"bluesky://intent/compose?videoUri=${URLEncoder.encode(file.path, "UTF-8")}|${info["width"]}|${info["height"]}".toUri().let {
"bluesky://intent/compose?videoUri=${URLEncoder.encode(file.path, "UTF-8")}".toUri().let {
val newIntent = Intent(Intent.ACTION_VIEW, it)
appContext.currentActivity?.startActivity(newIntent)
}
@@ -170,24 +166,6 @@ class ExpoReceiveAndroidIntentsModule : Module() {
)
}
private fun getVideoInfo(uri: Uri): Map<String, Any>? {
val retriever = MediaMetadataRetriever()
retriever.setDataSource(appContext.currentActivity, uri)
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull()
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
if (width == null || height == null) {
return null
}
return mapOf(
"width" to width,
"height" to height,
"path" to uri.path.toString(),
)
}
private fun createFile(extension: String): File = File.createTempFile(extension, "temp.$extension", appContext.currentActivity?.cacheDir)
// We will pas the width and height to the app here, since getting measurements
+11 -13
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.93.0",
"version": "1.91.2",
"private": true,
"engines": {
"node": ">=18"
@@ -53,7 +53,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.13.11",
"@atproto/api": "^0.13.8",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
"@emoji-mart/react": "^1.1.1",
@@ -68,14 +68,11 @@
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
"@fortawesome/react-native-fontawesome": "^0.3.2",
"@haileyok/bluesky-video": "0.2.2",
"@haileyok/bluesky-video": "0.1.10",
"@lingui/react": "^4.5.0",
"@mattermost/react-native-paste-input": "^0.7.1",
"@miblanchard/react-native-slider": "^2.3.1",
"@radix-ui/react-dismissable-layer": "^1.1.1",
"@radix-ui/react-dropdown-menu": "2.0.1",
"@radix-ui/react-focus-guards": "^1.1.1",
"@radix-ui/react-focus-scope": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-masked-view/masked-view": "0.3.0",
"@react-native-menu/menu": "^1.1.0",
@@ -84,7 +81,8 @@
"@react-navigation/drawer": "^6.6.15",
"@react-navigation/native": "^6.1.17",
"@react-navigation/native-stack": "^6.9.26",
"@sentry/react-native": "5.24.3",
"@sentry/react-native": "5.32.0",
"@tamagui/focus-scope": "^1.84.1",
"@tanstack/query-async-storage-persister": "^5.25.0",
"@tanstack/react-query": "^5.8.1",
"@tanstack/react-query-persist-client": "^5.25.0",
@@ -139,6 +137,7 @@
"expo-sharing": "^12.0.1",
"expo-splash-screen": "~0.27.4",
"expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.4",
"expo-task-manager": "~11.8.1",
"expo-updates": "~0.25.14",
"expo-web-browser": "~13.0.3",
@@ -172,11 +171,11 @@
"react-native-compressor": "^1.8.24",
"react-native-date-picker": "^4.4.2",
"react-native-drawer-layout": "^4.0.0-alpha.3",
"react-native-gesture-handler": "2.20.0",
"react-native-gesture-handler": "~2.16.2",
"react-native-get-random-values": "~1.11.0",
"react-native-image-crop-picker": "0.41.2",
"react-native-ios-context-menu": "^1.15.3",
"react-native-keyboard-controller": "^1.14.0",
"react-native-keyboard-controller": "^1.12.1",
"react-native-mmkv": "^2.12.2",
"react-native-pager-view": "6.2.3",
"react-native-picker-select": "^9.1.3",
@@ -205,7 +204,7 @@
"zod": "^3.20.2"
},
"devDependencies": {
"@atproto/dev-env": "^0.3.55",
"@atproto/dev-env": "^0.3.39",
"@babel/core": "^7.23.2",
"@babel/preset-env": "^7.20.0",
"@babel/runtime": "^7.20.0",
@@ -280,8 +279,7 @@
"**/zod": "3.23.8",
"**/expo-constants": "16.0.1",
"**/expo-device": "6.0.2",
"@react-native/babel-preset": "0.74.1",
"@radix-ui/react-focus-scope": "1.1.0"
"@react-native/babel-preset": "0.74.1"
},
"jest": {
"preset": "jest-expo/ios",
+17 -63
View File
@@ -38,65 +38,37 @@ index b0d71dc..41b9a0e 100644
- (void)reactBlur
diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
index e9b330f..ec5f58c 100644
index e9b330f..1ecdf0a 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
@@ -15,5 +15,8 @@
@property (nonatomic, copy) NSString *title;
@@ -16,4 +16,6 @@
@property (nonatomic, copy) RCTDirectEventBlock onRefresh;
@property (nonatomic, weak) UIScrollView *scrollView;
+@property (nonatomic, copy) UIColor *customTintColor;
+
+- (void)forwarderBeginRefreshing;
+- (void)forwarderBeginRefreshing;
+
@end
diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
index b09e653..288e60c 100644
index b09e653..f93cb46 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
@@ -22,6 +22,7 @@ @implementation RCTRefreshControl {
NSString *_title;
UIColor *_titleColor;
CGFloat _progressViewOffset;
+ UIColor *_customTintColor;
}
@@ -198,9 +198,53 @@ - (void)refreshControlValueChanged
[self setCurrentRefreshingState:super.refreshing];
_refreshingProgrammatically = NO;
- (instancetype)init
@@ -56,6 +57,12 @@ - (void)layoutSubviews
_isInitialRender = false;
}
+- (void)didMoveToSuperview
+{
+ [super didMoveToSuperview];
+ [self setTintColor:_customTintColor];
+}
+ if (@available(iOS 17.4, *)) {
+ if (_currentRefreshingState) {
+ UIImpactFeedbackGenerator *feedbackGenerator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
+ [feedbackGenerator prepare];
+ [feedbackGenerator impactOccurred];
+ }
+ }
+
- (void)beginRefreshingProgrammatically
{
UInt64 beginRefreshingTimestamp = _currentRefreshingStateTimestamp;
@@ -203,4 +210,58 @@ - (void)refreshControlValueChanged
if (_onRefresh) {
_onRefresh(nil);
}
}
+- (void)setCustomTintColor:(UIColor *)customTintColor
+{
+ _customTintColor = customTintColor;
+ [self setTintColor:customTintColor];
+}
+
+// Fix for https://github.com/facebook/react-native/issues/43388
+// A bug in iOS 17.4 causes the haptic to not play when refreshing if the tintColor
+// is set before the refresh control gets added to the scrollview. We'll call this
+// function whenever the superview changes. We'll also call it if the value of customTintColor
+// changes.
+- (void)setTintColor:(UIColor *)tintColor
+{
+ if ([self.superview isKindOfClass:[UIScrollView class]] && self.tintColor != tintColor) {
+ [super setTintColor:tintColor];
+ }
+}
+
+/*
+ This method is used by Bluesky's ExpoScrollForwarder. This allows other React Native
+ libraries to perform a refresh of a scrollview and access the refresh control's onRefresh
@@ -134,24 +106,6 @@ index b09e653..288e60c 100644
+}
+
@end
diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
index 40aaf9c..1c60164 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControlManager.m
@@ -22,11 +22,12 @@ - (UIView *)view
RCT_EXPORT_VIEW_PROPERTY(onRefresh, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(refreshing, BOOL)
-RCT_EXPORT_VIEW_PROPERTY(tintColor, UIColor)
RCT_EXPORT_VIEW_PROPERTY(title, NSString)
RCT_EXPORT_VIEW_PROPERTY(titleColor, UIColor)
RCT_EXPORT_VIEW_PROPERTY(progressViewOffset, CGFloat)
+RCT_REMAP_VIEW_PROPERTY(tintColor, customTintColor, UIColor)
+
RCT_EXPORT_METHOD(setNativeRefreshing : (nonnull NSNumber *)viewTag toRefreshing : (BOOL)refreshing)
{
[self.bridge.uiManager addUIBlock:^(RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry) {
diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.java
index 5f5e1ab..aac00b6 100644
--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.java
+2 -2
View File
@@ -23,10 +23,10 @@ const outputFile = entrypoints
const ext = path.extname(file)
if (ext === '.js') {
return `<script defer="defer" src="{{ staticCDNHost }}/static/js/${file}"></script>`
return `<script defer="defer" src="/static/js/${file}"></script>`
}
if (ext === '.css') {
return `<link rel="stylesheet" href="{{ staticCDNHost }}/static/css/${file}">`
return `<link rel="stylesheet" href="/static/css/${file}">`
}
return ''
+41 -45
View File
@@ -1,6 +1,6 @@
import 'react-native-url-polyfill/auto'
import '#/lib/sentry' // must be near top
import '#/view/icons'
import 'lib/sentry' // must be near top
import 'view/icons'
import React, {useEffect, useState} from 'react'
import {GestureHandlerRootView} from 'react-native-gesture-handler'
@@ -14,6 +14,7 @@ import * as SplashScreen from 'expo-splash-screen'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
import {QueryProvider} from '#/lib/react-query'
import {
initialize,
@@ -50,7 +51,6 @@ import {
} from '#/state/session'
import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as ComposerProvider} from '#/state/shell/composer'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
@@ -67,7 +67,6 @@ import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PortalProvider} from '#/components/Portal'
import {Splash} from '#/Splash'
import {BottomSheetProvider} from '../modules/bottom-sheet'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
SplashScreen.preventAutoHideAsync()
@@ -84,6 +83,7 @@ function InnerApp() {
const theme = useColorModeTheme()
const {_} = useLingui()
useIntentHandler()
const hasCheckedReferrer = useStarterPackEntry()
// init
@@ -125,37 +125,35 @@ function InnerApp() {
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<ComposerProvider>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<GestureHandlerRootView
style={s.h100pct}>
<TestCtrls />
<Shell />
<NuxDialogs />
</GestureHandlerRootView>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</ComposerProvider>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<GestureHandlerRootView
style={s.h100pct}>
<TestCtrls />
<Shell />
<NuxDialogs />
</GestureHandlerRootView>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
</VideoVolumeProvider>
@@ -196,16 +194,14 @@ function App() {
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<BottomSheetProvider>
<StarterPackProvider>
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<IntentDialogProvider>
<InnerApp />
</IntentDialogProvider>
</SafeAreaProvider>
</StarterPackProvider>
</BottomSheetProvider>
<StarterPackProvider>
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<IntentDialogProvider>
<InnerApp />
</IntentDialogProvider>
</SafeAreaProvider>
</StarterPackProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
+32 -30
View File
@@ -9,6 +9,7 @@ import {SafeAreaProvider} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
import {QueryProvider} from '#/lib/react-query'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {ThemeProvider} from '#/lib/ThemeContext'
@@ -40,7 +41,7 @@ import {
} from '#/state/session'
import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as ComposerProvider} from '#/state/shell/composer'
import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
@@ -70,8 +71,11 @@ function InnerApp() {
const {resumeSession} = useSessionApi()
const theme = useColorModeTheme()
const {_} = useLingui()
useIntentHandler()
const hasCheckedReferrer = useStarterPackEntry()
useComposerKeyboardShortcut()
// init
useEffect(() => {
async function onLaunch(account?: SessionAccount) {
@@ -112,35 +116,33 @@ function InnerApp() {
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<ComposerProvider>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<Shell />
<NuxDialogs />
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</ComposerProvider>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<Shell />
<NuxDialogs />
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</QueryProvider>
<ToastContainer />
</React.Fragment>
+14 -14
View File
@@ -17,6 +17,7 @@ import {
import {timeout} from '#/lib/async/timeout'
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
import {buildStateObject} from '#/lib/routes/helpers'
import {
@@ -77,8 +78,8 @@ import {BottomBar} from '#/view/shell/bottom-bar/BottomBar'
import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth'
import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
import HashtagScreen from '#/screens/Hashtag'
import {MessagesScreen} from '#/screens/Messages/ChatList'
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
import {MessagesScreen} from '#/screens/Messages/List'
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation'
import {PostLikedByScreen} from '#/screens/Post/PostLikedBy'
@@ -92,7 +93,6 @@ import {
StarterPackScreenShort,
} from '#/screens/StarterPack/StarterPackScreen'
import {Wizard} from '#/screens/StarterPack/Wizard'
import {useTheme} from '#/alf'
import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army'
@@ -412,7 +412,7 @@ function TabsNavigator() {
}
function HomeTabNavigator() {
const t = useTheme()
const pal = usePalette('default')
return (
<HomeTab.Navigator
@@ -422,7 +422,7 @@ function HomeTabNavigator() {
gestureEnabled: true,
fullScreenGestureEnabled: true,
headerShown: false,
contentStyle: t.atoms.bg,
contentStyle: pal.view,
}}>
<HomeTab.Screen name="Home" getComponent={() => HomeScreen} />
<HomeTab.Screen name="Start" getComponent={() => HomeScreen} />
@@ -432,7 +432,7 @@ function HomeTabNavigator() {
}
function SearchTabNavigator() {
const t = useTheme()
const pal = usePalette('default')
return (
<SearchTab.Navigator
screenOptions={{
@@ -441,7 +441,7 @@ function SearchTabNavigator() {
gestureEnabled: true,
fullScreenGestureEnabled: true,
headerShown: false,
contentStyle: t.atoms.bg,
contentStyle: pal.view,
}}>
<SearchTab.Screen name="Search" getComponent={() => SearchScreen} />
{commonScreens(SearchTab as typeof HomeTab)}
@@ -450,7 +450,7 @@ function SearchTabNavigator() {
}
function NotificationsTabNavigator() {
const t = useTheme()
const pal = usePalette('default')
return (
<NotificationsTab.Navigator
screenOptions={{
@@ -459,7 +459,7 @@ function NotificationsTabNavigator() {
gestureEnabled: true,
fullScreenGestureEnabled: true,
headerShown: false,
contentStyle: t.atoms.bg,
contentStyle: pal.view,
}}>
<NotificationsTab.Screen
name="Notifications"
@@ -472,7 +472,7 @@ function NotificationsTabNavigator() {
}
function MyProfileTabNavigator() {
const t = useTheme()
const pal = usePalette('default')
return (
<MyProfileTab.Navigator
screenOptions={{
@@ -481,7 +481,7 @@ function MyProfileTabNavigator() {
gestureEnabled: true,
fullScreenGestureEnabled: true,
headerShown: false,
contentStyle: t.atoms.bg,
contentStyle: pal.view,
}}>
<MyProfileTab.Screen
// @ts-ignore // TODO: fix this broken type in ProfileScreen
@@ -498,7 +498,7 @@ function MyProfileTabNavigator() {
}
function MessagesTabNavigator() {
const t = useTheme()
const pal = usePalette('default')
return (
<MessagesTab.Navigator
screenOptions={{
@@ -507,7 +507,7 @@ function MessagesTabNavigator() {
gestureEnabled: true,
fullScreenGestureEnabled: true,
headerShown: false,
contentStyle: t.atoms.bg,
contentStyle: pal.view,
}}>
<MessagesTab.Screen
name="Messages"
@@ -527,7 +527,7 @@ function MessagesTabNavigator() {
* in a single ("flat") stack.
*/
const FlatNavigator = () => {
const t = useTheme()
const pal = usePalette('default')
const numUnread = useUnreadNotifications()
const screenListeners = useWebScrollRestoration()
const title = (page: MessageDescriptor) => bskyTitle(i18n._(page), numUnread)
@@ -541,7 +541,7 @@ const FlatNavigator = () => {
gestureEnabled: true,
fullScreenGestureEnabled: true,
headerShown: false,
contentStyle: t.atoms.bg,
contentStyle: pal.view,
}}>
<Flat.Screen
name="Home"
+9 -8
View File
@@ -1,27 +1,28 @@
import React, {useCallback, useEffect} from 'react'
import {
AccessibilityInfo,
Image as RNImage,
StyleSheet,
useColorScheme,
View,
StyleSheet,
Image as RNImage,
AccessibilityInfo,
useColorScheme,
} from 'react-native'
import * as SplashScreen from 'expo-splash-screen'
import {Image} from 'expo-image'
import Animated, {
Easing,
interpolate,
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
Easing,
} from 'react-native-reanimated'
import MaskedView from '@react-native-masked-view/masked-view'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import Svg, {Path, SvgProps} from 'react-native-svg'
import {Image} from 'expo-image'
import * as SplashScreen from 'expo-splash-screen'
import MaskedView from '@react-native-masked-view/masked-view'
import {isAndroid} from '#/platform/detection'
import {Logotype} from '#/view/icons/Logotype'
// @ts-ignore
import splashImagePointer from '../assets/splash.png'
// @ts-ignore
+3 -31
View File
@@ -276,13 +276,13 @@ export const atoms = {
letterSpacing: tokens.TRACKING,
},
font_normal: {
fontWeight: tokens.fontWeight.normal,
fontWeight: tokens.fontWeight.regular,
},
font_bold: {
fontWeight: tokens.fontWeight.bold,
fontWeight: tokens.fontWeight.semibold,
},
font_heavy: {
fontWeight: tokens.fontWeight.heavy,
fontWeight: tokens.fontWeight.extrabold,
},
italic: {
fontStyle: 'italic',
@@ -901,32 +901,4 @@ export const atoms = {
hidden: {
display: 'none',
},
/*
* Transition
*/
transition_none: web({
transitionProperty: 'none',
}),
transition_all: web({
transitionProperty: 'all',
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
transitionDuration: '100ms',
}),
transition_color: web({
transitionProperty:
'color, background-color, border-color, text-decoration-color, fill, stroke',
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
transitionDuration: '100ms',
}),
transition_opacity: web({
transitionProperty: 'opacity',
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
transitionDuration: '100ms',
}),
transition_transform: web({
transitionProperty: 'transform',
transitionTimingFunction: 'cubic-bezier(0.17, 0.73, 0.14, 1)',
transitionDuration: '100ms',
}),
} as const
+18 -54
View File
@@ -1,9 +1,7 @@
import {TextStyle} from 'react-native'
import {isAndroid, isWeb} from '#/platform/detection'
import {isWeb} from '#/platform/detection'
import {Device, device} from '#/storage'
const WEB_FONT_FAMILIES = `system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"`
const FAMILIES = `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Liberation Sans", Helvetica, Arial, sans-serif`
const factor = 0.0625 // 1 - (15/16)
const fontScaleMultipliers: Record<Device['fontScale'], number> = {
@@ -37,65 +35,31 @@ export function setFontFamily(fontFamily: Device['fontFamily']) {
/*
* Unused fonts are commented out, but the files are there if we need them.
*/
export function applyFonts(style: TextStyle, fontFamily: 'system' | 'theme') {
export function applyFonts(
style: Record<string, any>,
fontFamily: 'system' | 'theme',
) {
if (fontFamily === 'theme') {
if (isAndroid) {
style.fontFamily =
{
400: 'Inter-Regular',
500: 'Inter-Regular',
600: 'Inter-SemiBold',
700: 'Inter-SemiBold',
800: 'Inter-ExtraBold',
900: 'Inter-ExtraBold',
}[String(style.fontWeight || '400')] || 'Inter-Regular'
style.fontFamily = 'InterVariable'
if (style.fontStyle === 'italic') {
if (style.fontFamily === 'Inter-Regular') {
style.fontFamily = 'Inter-Italic'
} else {
style.fontFamily += 'Italic'
}
}
/*
* These are not supported on Android and actually break the styling.
*/
delete style.fontWeight
delete style.fontStyle
} else {
style.fontFamily = 'InterVariable'
if (style.fontStyle === 'italic') {
style.fontFamily += 'Italic'
}
if (style.fontStyle === 'italic') {
style.fontFamily += 'Italic'
}
// fallback families only supported on web
if (isWeb) {
// fallback families only supported on web
style.fontFamily += `, ${WEB_FONT_FAMILIES}`
style.fontFamily += `, ${FAMILIES}`
}
/**
* Disable contextual alternates in Inter
* {@link https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant}
*/
style.fontVariant = (style.fontVariant || []).concat('no-contextual')
} else {
// fallback families only supported on web
if (isWeb) {
style.fontFamily = style.fontFamily || WEB_FONT_FAMILIES
style.fontFamily = style.fontFamily || FAMILIES
}
/**
* Overridden to previous spacing for the `system` font option.
* https://github.com/bluesky-social/social-app/commit/2419096e2409008b7d71fd6b8f8d0dd5b016e267
*/
style.letterSpacing = 0.25
}
}
/**
* Here only for bundling purposes, not actually used.
*/
export {DO_NOT_USE} from '#/alf/util/unusedUseFonts'
/**
* Disable contextual ligatures
* {@link https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant}
*/
style.fontVariant = ['no-contextual']
}
+8 -5
View File
@@ -1,6 +1,6 @@
import {isAndroid} from '#/platform/detection'
import {Platform} from 'react-native'
export const TRACKING = isAndroid ? 0.1 : 0
export const TRACKING = Platform.OS === 'android' ? 0.1 : 0
export const color = {
temp_purple: 'rgb(105 0 255)',
@@ -51,9 +51,12 @@ export const borderRadius = {
* These correspond to Inter font files we actually load.
*/
export const fontWeight = {
normal: '400',
bold: '600',
heavy: '800',
regular: '400',
// medium: '500',
semibold: '600',
// bold: '700',
extrabold: '800',
// black: '900',
} as const
export const gradients = {
+1 -38
View File
@@ -1,62 +1,25 @@
import {Platform} from 'react-native'
import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection'
import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
/**
* Identity function on web. Returns nothing on other platforms.
*
* Note: Platform splitting does not tree-shake away the other platforms,
* so don't do stuff like e.g. rely on platform-specific imports. Use
* platform-split files instead.
*/
export function web(value: any) {
if (isWeb) {
return value
}
}
/**
* Identity function on iOS. Returns nothing on other platforms.
*
* Note: Platform splitting does not tree-shake away the other platforms,
* so don't do stuff like e.g. rely on platform-specific imports. Use
* platform-split files instead.
*/
export function ios(value: any) {
if (isIOS) {
return value
}
}
/**
* Identity function on Android. Returns nothing on other platforms..
*
* Note: Platform splitting does not tree-shake away the other platforms,
* so don't do stuff like e.g. rely on platform-specific imports. Use
* platform-split files instead.
*/
export function android(value: any) {
if (isAndroid) {
return value
}
}
/**
* Identity function on iOS and Android. Returns nothing on web.
*
* Note: Platform splitting does not tree-shake away the other platforms,
* so don't do stuff like e.g. rely on platform-specific imports. Use
* platform-split files instead.
*/
export function native(value: any) {
if (isNative) {
return value
}
}
/**
* Note: Platform splitting does not tree-shake away the other platforms,
* so don't do stuff like e.g. rely on platform-specific imports. Use
* platform-split files instead.
*/
export const platform = Platform.select
-19
View File
@@ -1,19 +0,0 @@
import {useFonts} from 'expo-font'
/*
* IMPORTANT: This is unused. Expo statically extracts these fonts.
*
* All used fonts MUST be configured here. Unused fonts can be commented out.
*
* This is used for both web fonts and native fonts.
*/
export function DO_NOT_USE() {
return useFonts({
'Inter-Regular': require('../../../assets/fonts/inter/Inter-Regular.otf'),
'Inter-Italic': require('../../../assets/fonts/inter/Inter-Italic.otf'),
'Inter-Bold': require('../../../assets/fonts/inter/Inter-SemiBold.otf'),
'Inter-BoldItalic': require('../../../assets/fonts/inter/Inter-SemiBoldItalic.otf'),
'Inter-Black': require('../../../assets/fonts/inter/Inter-ExtraBold.otf'),
'Inter-BlackItalic': require('../../../assets/fonts/inter/Inter-ExtraBoldItalic.otf'),
})
}
-15
View File
@@ -1,15 +0,0 @@
import {useFonts} from 'expo-font'
/*
* IMPORTANT: This is unused. Expo statically extracts these fonts.
*
* All used fonts MUST be configured here. Unused fonts can be commented out.
*
* This is used for both web fonts and native fonts.
*/
export function DO_NOT_USE() {
return useFonts({
InterVariable: require('../../../assets/fonts/inter/InterVariable.ttf'),
'InterVariable-Italic': require('../../../assets/fonts/inter/InterVariable-Italic.ttf'),
})
}
+4 -2
View File
@@ -1,8 +1,9 @@
import React from 'react'
import {ColorSchemeName, useColorScheme} from 'react-native'
import * as SystemUI from 'expo-system-ui'
import {isWeb} from '#/platform/detection'
import {useThemePrefs} from '#/state/shell'
import {isWeb} from 'platform/detection'
import {useThemePrefs} from 'state/shell'
import {dark, dim, light} from '#/alf/themes'
import {ThemeName} from '#/alf/types'
@@ -11,6 +12,7 @@ export function useColorModeTheme(): ThemeName {
React.useLayoutEffect(() => {
updateDocument(theme)
SystemUI.setBackgroundColorAsync(getBackgroundColor(theme))
}, [theme])
return theme
+6 -16
View File
@@ -1,12 +1,9 @@
import React, {useCallback} from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfilesQuery} from '#/state/queries/profile'
import {useProfileQuery} from '#/state/queries/profile'
import {type SessionAccount, useSession} from '#/state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
@@ -29,9 +26,6 @@ export function AccountList({
const {currentAccount, accounts} = useSession()
const t = useTheme()
const {_} = useLingui()
const {data: profiles} = useProfilesQuery({
handles: accounts.map(acc => acc.did),
})
const onPressAddAccount = useCallback(() => {
onSelectOther()
@@ -49,7 +43,6 @@ export function AccountList({
{accounts.map(account => (
<React.Fragment key={account.did}>
<AccountItem
profile={profiles?.profiles.find(p => p.did === account.did)}
account={account}
onSelect={onSelectAccount}
isCurrentAccount={account.did === currentAccount?.did}
@@ -91,13 +84,11 @@ export function AccountList({
}
function AccountItem({
profile,
account,
onSelect,
isCurrentAccount,
isPendingAccount,
}: {
profile?: AppBskyActorDefs.ProfileViewDetailed
account: SessionAccount
onSelect: (account: SessionAccount) => void
isCurrentAccount: boolean
@@ -105,8 +96,9 @@ function AccountItem({
}) {
const t = useTheme()
const {_} = useLingui()
const {data: profile} = useProfileQuery({did: account.did})
const onPress = useCallback(() => {
const onPress = React.useCallback(() => {
onSelect(account)
}, [account, onSelect])
@@ -135,12 +127,10 @@ function AccountItem({
</View>
<Text style={[a.align_baseline, a.flex_1, a.flex_row, a.py_sm]}>
<Text emoji style={[a.font_bold]}>
{sanitizeDisplayName(
profile?.displayName || profile?.handle || account.handle,
)}
</Text>{' '}
{profile?.displayName || account.handle}{' '}
</Text>
<Text emoji style={[t.atoms.text_contrast_medium]}>
{sanitizeHandle(account.handle)}
{account.handle}
</Text>
</Text>
{isCurrentAccount ? (
-121
View File
@@ -1,121 +0,0 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
import {Eye_Stroke2_Corner0_Rounded as InfoIcon} from '#/components/icons/Eye'
import {Leaf_Stroke2_Corner0_Rounded as TipIcon} from '#/components/icons/Leaf'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {Text as BaseText, TextProps} from '#/components/Typography'
const colors = {
warning: {
light: '#DFBC00',
dark: '#BFAF1F',
},
}
type Context = {
type: 'info' | 'tip' | 'warning' | 'error'
}
const Context = React.createContext<Context>({
type: 'info',
})
export function Icon() {
const t = useTheme()
const {type} = React.useContext(Context)
const Icon = {
info: InfoIcon,
tip: TipIcon,
warning: WarningIcon,
error: ErrorIcon,
}[type]
const fill = {
info: t.atoms.text_contrast_medium.color,
tip: t.palette.primary_500,
warning: colors.warning.light,
error: t.palette.negative_500,
}[type]
return <Icon fill={fill} size="md" />
}
export function Text({
children,
style,
...rest
}: Pick<TextProps, 'children' | 'style'>) {
return (
<BaseText
{...rest}
style={[
a.flex_1,
a.text_sm,
a.leading_snug,
{
paddingTop: 1,
},
style,
]}>
{children}
</BaseText>
)
}
export function Row({children}: {children: React.ReactNode}) {
return <View style={[a.flex_row, a.gap_sm]}>{children}</View>
}
export function Outer({
children,
type = 'info',
style,
}: {
children: React.ReactNode
type?: Context['type']
style?: StyleProp<ViewStyle>
}) {
const t = useTheme()
const {gtMobile} = useBreakpoints()
const borderColor = {
info: t.atoms.border_contrast_low.borderColor,
tip: t.atoms.border_contrast_low.borderColor,
warning: t.atoms.border_contrast_low.borderColor,
error: t.atoms.border_contrast_low.borderColor,
}[type]
return (
<Context.Provider value={{type}}>
<View
style={[
gtMobile ? a.p_md : a.p_sm,
a.rounded_sm,
a.border,
t.atoms.bg_contrast_25,
{borderColor},
style,
]}>
{children}
</View>
</Context.Provider>
)
}
export function Admonition({
children,
type,
style,
}: {
children: TextProps['children']
type?: Context['type']
style?: StyleProp<ViewStyle>
}) {
return (
<Outer type={type} style={style}>
<Row>
<Icon />
<Text>{children}</Text>
</Row>
</Outer>
)
}
+8 -11
View File
@@ -14,7 +14,7 @@ import {
} from 'react-native'
import {LinearGradient} from 'expo-linear-gradient'
import {atoms as a, flatten, select, tokens, useTheme} from '#/alf'
import {atoms as a, flatten, select, tokens, useTheme, web} from '#/alf'
import {Props as SVGIconProps} from '#/components/icons/common'
import {Text} from '#/components/Typography'
@@ -87,7 +87,6 @@ export type ButtonProps = Pick<
style?: StyleProp<ViewStyle>
hoverStyle?: StyleProp<ViewStyle>
children: NonTextElements | ((context: ButtonContext) => NonTextElements)
PressableComponent?: React.ComponentType<PressableProps>
}
export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
@@ -115,7 +114,6 @@ export const Button = React.forwardRef<View, ButtonProps>(
disabled = false,
style,
hoverStyle: hoverStyleProp,
PressableComponent = Pressable,
...rest
},
ref,
@@ -354,7 +352,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
})
} else if (size === 'small') {
baseStyles.push({
paddingVertical: 9,
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 6,
gap: 6,
@@ -376,7 +374,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
}
} else if (size === 'small') {
if (shape === 'round') {
baseStyles.push({height: 34, width: 34})
baseStyles.push({height: 36, width: 36})
} else {
baseStyles.push({height: 34, width: 34})
}
@@ -451,11 +449,10 @@ export const Button = React.forwardRef<View, ButtonProps>(
const flattenedBaseStyles = flatten([baseStyles, style])
return (
<PressableComponent
<Pressable
role="button"
accessibilityHint={undefined} // optional
{...rest}
// @ts-ignore - this will always be a pressable
ref={ref}
aria-label={label}
aria-pressed={state.pressed}
@@ -503,7 +500,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
<Context.Provider value={context}>
{typeof children === 'function' ? children(context) : children}
</Context.Provider>
</PressableComponent>
</Pressable>
)
},
)
@@ -630,9 +627,9 @@ export function useSharedButtonTextStyles() {
}
if (size === 'large') {
baseStyles.push(a.text_md, a.leading_tight)
baseStyles.push(a.text_md, a.leading_tight, web({top: -0.4}))
} else if (size === 'small') {
baseStyles.push(a.text_sm, a.leading_tight)
baseStyles.push(a.text_sm, a.leading_tight, web({top: -0.4}))
} else if (size === 'tiny') {
baseStyles.push(a.text_xs, a.leading_tight)
}
@@ -670,7 +667,7 @@ export function ButtonIcon({
size ??
(({
large: 'sm',
small: 'sm',
small: 'xs',
tiny: 'xs',
}[buttonSize || 'small'] || 'sm') as Exclude<
SVGIconProps['size'],
-5
View File
@@ -6,14 +6,9 @@ import {
DialogControlRefProps,
DialogOuterProps,
} from '#/components/Dialog/types'
import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types'
export const Context = React.createContext<DialogContextProps>({
close: () => {},
isNativeDialog: false,
nativeSnapPoint: BottomSheetSnapPoint.Hidden,
disableDrag: false,
setDisableDrag: () => {},
})
export function useDialogContext() {
+220 -234
View File
@@ -1,52 +1,86 @@
import React, {useImperativeHandle} from 'react'
import {
NativeScrollEvent,
NativeSyntheticEvent,
Dimensions,
Keyboard,
Pressable,
ScrollView,
StyleProp,
TextInput,
View,
ViewStyle,
} from 'react-native'
import {
KeyboardAwareScrollView,
useKeyboardController,
useKeyboardHandler,
} from 'react-native-keyboard-controller'
import {runOnJS} from 'react-native-reanimated'
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes'
import Animated, {useAnimatedStyle} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import BottomSheet, {
BottomSheetBackdropProps,
BottomSheetFlatList,
BottomSheetFlatListMethods,
BottomSheetScrollView,
BottomSheetScrollViewMethods,
BottomSheetTextInput,
BottomSheetView,
useBottomSheet,
WINDOW_HEIGHT,
} from '@discord/bottom-sheet/src'
import {BottomSheetFlatListProps} from '@discord/bottom-sheet/src/components/bottomSheetScrollable/types'
import {ScrollProvider} from '#/lib/ScrollContext'
import {logger} from '#/logger'
import {isAndroid, isIOS} from '#/platform/detection'
import {useA11y} from '#/state/a11y'
import {useDialogStateControlContext} from '#/state/dialogs'
import {List, ListMethods, ListProps} from '#/view/com/util/List'
import {atoms as a, useTheme} from '#/alf'
import {Context, useDialogContext} from '#/components/Dialog/context'
import {atoms as a, flatten, useTheme} from '#/alf'
import {Context} from '#/components/Dialog/context'
import {
DialogControlProps,
DialogInnerProps,
DialogOuterProps,
} from '#/components/Dialog/types'
import {createInput} from '#/components/forms/TextField'
import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet'
import {
BottomSheetSnapPointChangeEvent,
BottomSheetStateChangeEvent,
} from '../../../modules/bottom-sheet/src/BottomSheet.types'
import {BottomSheetNativeComponent} from '../../../modules/bottom-sheet/src/BottomSheetNativeComponent'
import {FullWindowOverlay} from '#/components/FullWindowOverlay'
import {Portal} from '#/components/Portal'
export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
export * from '#/components/Dialog/shared'
export * from '#/components/Dialog/types'
export * from '#/components/Dialog/utils'
// @ts-ignore
export const Input = createInput(TextInput)
export const Input = createInput(BottomSheetTextInput)
function Backdrop(props: BottomSheetBackdropProps) {
const t = useTheme()
const bottomSheet = useBottomSheet()
const animatedStyle = useAnimatedStyle(() => {
const opacity =
(Math.abs(WINDOW_HEIGHT - props.animatedPosition.value) - 50) / 1000
return {
opacity: Math.min(Math.max(opacity, 0), 0.55),
}
})
const onPress = React.useCallback(() => {
bottomSheet.close()
}, [bottomSheet])
return (
<Animated.View
style={[
t.atoms.bg_contrast_300,
{
top: 0,
left: 0,
right: 0,
bottom: 0,
position: 'absolute',
},
animatedStyle,
]}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Dialog backdrop"
accessibilityHint="Press the backdrop to close the dialog"
style={{flex: 1}}
onPress={onPress}
/>
</Animated.View>
)
}
export function Outer({
children,
@@ -56,19 +90,22 @@ export function Outer({
testID,
}: React.PropsWithChildren<DialogOuterProps>) {
const t = useTheme()
const ref = React.useRef<BottomSheetNativeComponent>(null)
const sheet = React.useRef<BottomSheet>(null)
const sheetOptions = nativeOptions?.sheet || {}
const hasSnapPoints = !!sheetOptions.snapPoints
const insets = useSafeAreaInsets()
const closeCallbacks = React.useRef<(() => void)[]>([])
const {setDialogIsOpen, setFullyExpandedCount} =
useDialogStateControlContext()
const {setDialogIsOpen} = useDialogStateControlContext()
const prevSnapPoint = React.useRef<BottomSheetSnapPoint>(
BottomSheetSnapPoint.Hidden,
)
/*
* Used to manage open/closed, but index is otherwise handled internally by `BottomSheet`
*/
const [openIndex, setOpenIndex] = React.useState(-1)
const [disableDrag, setDisableDrag] = React.useState(false)
const [snapPoint, setSnapPoint] = React.useState<BottomSheetSnapPoint>(
BottomSheetSnapPoint.Partial,
)
/*
* `openIndex` is the index of the snap point to open the bottom sheet to. If >0, the bottom sheet is open.
*/
const isOpen = openIndex > -1
const callQueuedCallbacks = React.useCallback(() => {
for (const cb of closeCallbacks.current) {
@@ -82,19 +119,25 @@ export function Outer({
closeCallbacks.current = []
}, [])
const open = React.useCallback<DialogControlProps['open']>(() => {
// Run any leftover callbacks that might have been queued up before calling `.open()`
callQueuedCallbacks()
setDialogIsOpen(control.id, true)
ref.current?.present()
}, [setDialogIsOpen, control.id, callQueuedCallbacks])
const open = React.useCallback<DialogControlProps['open']>(
({index} = {}) => {
// Run any leftover callbacks that might have been queued up before calling `.open()`
callQueuedCallbacks()
setDialogIsOpen(control.id, true)
// can be set to any index of `snapPoints`, but `0` is the first i.e. "open"
setOpenIndex(index || 0)
sheet.current?.snapToIndex(index || 0)
},
[setDialogIsOpen, control.id, callQueuedCallbacks],
)
// This is the function that we call when we want to dismiss the dialog.
const close = React.useCallback<DialogControlProps['close']>(cb => {
if (typeof cb === 'function') {
closeCallbacks.current.push(cb)
}
ref.current?.dismiss()
sheet.current?.close()
}, [])
// This is the actual thing we are doing once we "confirm" the dialog. We want the dialog's close animation to
@@ -103,39 +146,12 @@ export function Outer({
// This removes the dialog from our list of stored dialogs. Not super necessary on iOS, but on Android this
// tells us that we need to toggle the accessibility overlay setting
setDialogIsOpen(control.id, false)
setOpenIndex(-1)
callQueuedCallbacks()
onClose?.()
}, [callQueuedCallbacks, control.id, onClose, setDialogIsOpen])
const onSnapPointChange = (e: BottomSheetSnapPointChangeEvent) => {
const {snapPoint} = e.nativeEvent
setSnapPoint(snapPoint)
if (
snapPoint === BottomSheetSnapPoint.Full &&
prevSnapPoint.current !== BottomSheetSnapPoint.Full
) {
setFullyExpandedCount(c => c + 1)
} else if (
snapPoint !== BottomSheetSnapPoint.Full &&
prevSnapPoint.current === BottomSheetSnapPoint.Full
) {
setFullyExpandedCount(c => c - 1)
}
prevSnapPoint.current = snapPoint
}
const onStateChange = (e: BottomSheetStateChangeEvent) => {
if (e.nativeEvent.state === 'closed') {
onCloseAnimationComplete()
if (prevSnapPoint.current === BottomSheetSnapPoint.Full) {
setFullyExpandedCount(c => c - 1)
}
prevSnapPoint.current = BottomSheetSnapPoint.Hidden
}
}
useImperativeHandle(
control.ref,
() => ({
@@ -145,189 +161,159 @@ export function Outer({
[open, close],
)
const context = React.useMemo(
() => ({
close,
isNativeDialog: true,
nativeSnapPoint: snapPoint,
disableDrag,
setDisableDrag,
}),
[close, snapPoint, disableDrag, setDisableDrag],
)
React.useEffect(() => {
return () => {
setDialogIsOpen(control.id, false)
}
}, [control.id, setDialogIsOpen])
const context = React.useMemo(() => ({close}), [close])
return (
<BottomSheet
ref={ref}
cornerRadius={20}
backgroundColor={t.atoms.bg.backgroundColor}
{...nativeOptions}
onSnapPointChange={onSnapPointChange}
onStateChange={onStateChange}
disableDrag={disableDrag}>
<Context.Provider value={context}>
<View testID={testID}>{children}</View>
</Context.Provider>
</BottomSheet>
isOpen && (
<Portal>
<FullWindowOverlay>
<View
// iOS
accessibilityViewIsModal
// Android
importantForAccessibility="yes"
style={[a.absolute, a.inset_0]}
testID={testID}
onTouchMove={() => Keyboard.dismiss()}>
<BottomSheet
enableDynamicSizing={!hasSnapPoints}
enablePanDownToClose
keyboardBehavior="interactive"
android_keyboardInputMode="adjustResize"
keyboardBlurBehavior="restore"
topInset={insets.top}
{...sheetOptions}
snapPoints={sheetOptions.snapPoints || ['100%']}
ref={sheet}
index={openIndex}
backgroundStyle={{backgroundColor: 'transparent'}}
backdropComponent={Backdrop}
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
handleStyle={{display: 'none'}}
onClose={onCloseAnimationComplete}>
<Context.Provider value={context}>
<View
style={[
a.absolute,
a.inset_0,
t.atoms.bg,
{
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
height: Dimensions.get('window').height * 2,
},
]}
/>
{children}
</Context.Provider>
</BottomSheet>
</View>
</FullWindowOverlay>
</Portal>
)
)
}
export function Inner({children, style, header}: DialogInnerProps) {
export function Inner({children, style}: DialogInnerProps) {
const insets = useSafeAreaInsets()
return (
<>
{header}
<View
style={[
a.pt_2xl,
a.px_xl,
{
paddingBottom: insets.bottom + insets.top,
},
style,
]}>
{children}
</View>
</>
<BottomSheetView
style={[
a.py_xl,
a.px_xl,
{
paddingTop: 40,
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
paddingBottom: insets.bottom + a.pb_5xl.paddingBottom,
},
flatten(style),
]}>
{children}
</BottomSheetView>
)
}
export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
function ScrollableInner(
{children, style, contentContainerStyle, header, ...props},
ref,
) {
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
const insets = useSafeAreaInsets()
const {setEnabled} = useKeyboardController()
const [keyboardHeight, setKeyboardHeight] = React.useState(0)
React.useEffect(() => {
if (!isIOS) {
return
}
setEnabled(true)
return () => {
setEnabled(false)
}
})
useKeyboardHandler({
onEnd: e => {
'worklet'
runOnJS(setKeyboardHeight)(e.height)
},
})
const basePading =
(isIOS ? 30 : 50) + (isIOS ? keyboardHeight / 4 : keyboardHeight)
const fullPaddingBase = insets.bottom + insets.top + basePading
const fullPadding = isIOS ? fullPaddingBase : fullPaddingBase + 50
const paddingBottom =
nativeSnapPoint === BottomSheetSnapPoint.Full ? fullPadding : basePading
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
if (!isAndroid) {
return
}
const {contentOffset} = e.nativeEvent
if (contentOffset.y > 0 && !disableDrag) {
setDisableDrag(true)
} else if (contentOffset.y <= 1 && disableDrag) {
setDisableDrag(false)
}
}
return (
<KeyboardAwareScrollView
style={[style]}
contentContainerStyle={[
a.pt_2xl,
a.px_xl,
{paddingBottom},
contentContainerStyle,
]}
ref={ref}
{...props}
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
bottomOffset={30}
scrollEventThrottle={50}
onScroll={isAndroid ? onScroll : undefined}
keyboardShouldPersistTaps="handled"
stickyHeaderIndices={header ? [0] : undefined}>
{header}
{children}
</KeyboardAwareScrollView>
)
},
)
export const ScrollableInner = React.forwardRef<
BottomSheetScrollViewMethods,
DialogInnerProps
>(function ScrollableInner({children, style}, ref) {
const insets = useSafeAreaInsets()
return (
<BottomSheetScrollView
keyboardShouldPersistTaps="handled"
style={[
a.flex_1, // main diff is this
a.p_xl,
a.h_full,
{
paddingTop: 40,
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
},
style,
]}
contentContainerStyle={a.pb_4xl}
ref={ref}>
{children}
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
</BottomSheetScrollView>
)
})
export const InnerFlatList = React.forwardRef<
ListMethods,
ListProps<any> & {webInnerStyle?: StyleProp<ViewStyle>}
>(function InnerFlatList({style, ...props}, ref) {
BottomSheetFlatListMethods,
BottomSheetFlatListProps<any> & {webInnerStyle?: StyleProp<ViewStyle>}
>(function InnerFlatList({style, contentContainerStyle, ...props}, ref) {
const insets = useSafeAreaInsets()
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
const onScroll = (e: ReanimatedScrollEvent) => {
'worklet'
if (!isAndroid) {
return
}
const {contentOffset} = e
if (contentOffset.y > 0 && !disableDrag) {
runOnJS(setDisableDrag)(true)
} else if (contentOffset.y <= 1 && disableDrag) {
runOnJS(setDisableDrag)(false)
}
}
return (
<ScrollProvider onScroll={onScroll}>
<List
keyboardShouldPersistTaps="handled"
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
ListFooterComponent={
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
}
ref={ref}
{...props}
style={[style]}
/>
</ScrollProvider>
<BottomSheetFlatList
keyboardShouldPersistTaps="handled"
contentContainerStyle={[a.pb_4xl, flatten(contentContainerStyle)]}
ListFooterComponent={
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
}
ref={ref}
{...props}
style={[
a.flex_1,
a.p_xl,
a.pt_0,
a.h_full,
{
marginTop: 40,
},
flatten(style),
]}
/>
)
})
export function Handle() {
const t = useTheme()
const {_} = useLingui()
const {screenReaderEnabled} = useA11y()
const {close} = useDialogContext()
return (
<View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 20}]}>
<Pressable
accessible={screenReaderEnabled}
onPress={() => close()}
accessibilityLabel={_(msg`Dismiss`)}
accessibilityHint={_(msg`Double tap to close the dialog`)}>
<View
style={[
a.rounded_sm,
{
top: 10,
width: 35,
height: 5,
alignSelf: 'center',
backgroundColor: t.palette.contrast_975,
opacity: 0.5,
},
]}
/>
</Pressable>
<View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}>
<View
style={[
a.rounded_sm,
{
top: a.pt_lg.paddingTop,
width: 35,
height: 4,
alignSelf: 'center',
backgroundColor: t.palette.contrast_900,
opacity: 0.5,
},
]}
/>
</View>
)
}
+25 -30
View File
@@ -10,9 +10,7 @@ import {
import Animated, {FadeIn, FadeInDown} from 'react-native-reanimated'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {DismissableLayer} from '@radix-ui/react-dismissable-layer'
import {useFocusGuards} from '@radix-ui/react-focus-guards'
import {FocusScope} from '@radix-ui/react-focus-scope'
import {FocusScope} from '@tamagui/focus-scope'
import {logger} from '#/logger'
import {useDialogStateControlContext} from '#/state/dialogs'
@@ -28,13 +26,11 @@ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Portal} from '#/components/Portal'
export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
export * from '#/components/Dialog/shared'
export * from '#/components/Dialog/types'
export * from '#/components/Dialog/utils'
export {Input} from '#/components/forms/TextField'
const stopPropagation = (e: any) => e.stopPropagation()
const preventDefault = (e: any) => e.preventDefault()
export function Outer({
children,
@@ -89,13 +85,24 @@ export function Outer({
[close, open],
)
React.useEffect(() => {
if (!isOpen) return
function handler(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.stopPropagation()
close()
}
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [close, isOpen])
const context = React.useMemo(
() => ({
close,
isNativeDialog: false,
nativeSnapPoint: 0,
disableDrag: false,
setDisableDrag: () => {},
}),
[close],
)
@@ -155,15 +162,11 @@ export function Inner({
label,
accessibilityLabelledBy,
accessibilityDescribedBy,
header,
contentContainerStyle,
}: DialogInnerProps) {
const t = useTheme()
const {close} = React.useContext(Context)
const {gtMobile} = useBreakpoints()
useFocusGuards()
return (
<FocusScope loop asChild trapped>
<FocusScope loop enabled trapped>
<Animated.View
role="dialog"
aria-role="dialog"
@@ -176,11 +179,12 @@ export function Inner({
onTouchEnd={stopPropagation}
entering={FadeInDown.duration(100)}
// exiting={FadeOut.duration(100)}
style={flatten([
style={[
a.relative,
a.rounded_md,
a.w_full,
a.border,
gtMobile ? a.p_2xl : a.p_xl,
t.atoms.bg,
{
maxWidth: 600,
@@ -190,17 +194,8 @@ export function Inner({
shadowRadius: 30,
},
flatten(style),
])}>
<DismissableLayer
onInteractOutside={preventDefault}
onFocusOutside={preventDefault}
onDismiss={close}
style={{display: 'flex', flexDirection: 'column'}}>
{header}
<View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}>
{children}
</View>
</DismissableLayer>
]}>
{children}
</Animated.View>
</FocusScope>
)
@@ -234,6 +229,10 @@ export const InnerFlatList = React.forwardRef<
)
})
export function Handle() {
return null
}
export function Close() {
const {_} = useLingui()
const {close} = React.useContext(Context)
@@ -259,7 +258,3 @@ export function Close() {
</View>
)
}
export function Handle() {
return null
}
-61
View File
@@ -1,61 +0,0 @@
import React from 'react'
import {StyleProp, TextStyle, View, ViewStyle} from 'react-native'
import {atoms as a, useTheme, web} from '#/alf'
import {Text} from '#/components/Typography'
export function Header({
renderLeft,
renderRight,
children,
style,
}: {
renderLeft?: () => React.ReactNode
renderRight?: () => React.ReactNode
children?: React.ReactNode
style?: StyleProp<ViewStyle>
}) {
const t = useTheme()
return (
<View
style={[
a.relative,
a.w_full,
a.py_sm,
a.flex_row,
a.justify_center,
a.align_center,
{minHeight: 50},
a.border_b,
t.atoms.border_contrast_medium,
t.atoms.bg,
web([
{borderRadiusTopLeft: a.rounded_md.borderRadius},
{borderRadiusTopRight: a.rounded_md.borderRadius},
]),
style,
]}>
{renderLeft && (
<View style={[a.absolute, {left: 6}]}>{renderLeft()}</View>
)}
{children}
{renderRight && (
<View style={[a.absolute, {right: 6}]}>{renderRight()}</View>
)}
</View>
)
}
export function HeaderText({
children,
style,
}: {
children?: React.ReactNode
style?: StyleProp<TextStyle>
}) {
return (
<Text style={[a.text_lg, a.text_center, a.font_bold, style]}>
{children}
</Text>
)
}
-20
View File
@@ -1,20 +0,0 @@
import {useCallback} from 'react'
import {useDialogStateControlContext} from '#/state/dialogs'
/**
* If we're calling a system API like the image picker that opens a sheet
* wrap it in this function to make sure the status bar is the correct color.
*/
export function useSheetWrapper() {
const {setFullyExpandedCount} = useDialogStateControlContext()
return useCallback(
async <T>(promise: Promise<T>): Promise<T> => {
setFullyExpandedCount(c => c + 1)
const res = await promise
setFullyExpandedCount(c => c - 1)
return res
},
[setFullyExpandedCount],
)
}
+4 -13
View File
@@ -4,12 +4,9 @@ import type {
GestureResponderEvent,
ScrollViewProps,
} from 'react-native'
import {ViewStyle} from 'react-native'
import {StyleProp} from 'react-native'
import {BottomSheetProps} from '@discord/bottom-sheet/src'
import {ViewStyleProp} from '#/alf'
import {BottomSheetViewProps} from '../../../modules/bottom-sheet'
import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types'
type A11yProps = Required<AccessibilityProps>
@@ -40,10 +37,6 @@ export type DialogControlProps = DialogControlRefProps & {
export type DialogContextProps = {
close: DialogControlProps['close']
isNativeDialog: boolean
nativeSnapPoint: BottomSheetSnapPoint
disableDrag: boolean
setDisableDrag: React.Dispatch<React.SetStateAction<boolean>>
}
export type DialogControlOpenOptions = {
@@ -59,7 +52,9 @@ export type DialogControlOpenOptions = {
export type DialogOuterProps = {
control: DialogControlProps
onClose?: () => void
nativeOptions?: Omit<BottomSheetViewProps, 'children'>
nativeOptions?: {
sheet?: Omit<BottomSheetProps, 'children'>
}
webOptions?: {}
testID?: string
}
@@ -71,14 +66,10 @@ export type DialogInnerProps =
accessibilityLabelledBy: A11yProps['aria-labelledby']
accessibilityDescribedBy: string
keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
contentContainerStyle?: StyleProp<ViewStyle>
header?: React.ReactNode
}>
| DialogInnerPropsBase<{
label: string
accessibilityLabelledBy?: undefined
accessibilityDescribedBy?: undefined
keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
contentContainerStyle?: StyleProp<ViewStyle>
header?: React.ReactNode
}>
@@ -0,0 +1,31 @@
import React from 'react'
import {useKeyboardHandler} from 'react-native-keyboard-controller'
import Animated, {
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated'
export function KeyboardControllerPadding({maxHeight}: {maxHeight?: number}) {
const keyboardHeight = useSharedValue(0)
useKeyboardHandler(
{
onMove: e => {
'worklet'
if (maxHeight && e.height > maxHeight) {
keyboardHeight.value = maxHeight
} else {
keyboardHeight.value = e.height
}
},
},
[maxHeight],
)
const animatedStyle = useAnimatedStyle(() => ({
height: keyboardHeight.value,
}))
return <Animated.View style={animatedStyle} />
}
@@ -0,0 +1,7 @@
export function KeyboardControllerPadding({
maxHeight: _,
}: {
maxHeight?: number
}) {
return null
}
-41
View File
@@ -1,41 +0,0 @@
import React from 'react'
import {View, ViewStyle} from 'react-native'
import {StyleProp} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {atoms as a} from '#/alf'
// Every screen should have a Layout component wrapping it.
// This component provides a default padding for the top of the screen.
// This allows certain screens to avoid the top padding if they want to.
//
// In a future PR I will add a unified header component to this file and
// things like a preconfigured scrollview.
/**
* Every screen should have a Layout.Screen component wrapping it.
* This component provides a default padding for the top of the screen
* and height/minHeight
*/
let Screen = ({
disableTopPadding,
style,
...props
}: React.ComponentProps<typeof View> & {
disableTopPadding?: boolean
style?: StyleProp<ViewStyle>
}): React.ReactNode => {
const {top} = useSafeAreaInsets()
return (
<View
style={[
{paddingTop: disableTopPadding ? 0 : top},
a.util_screen_outer,
style,
]}
{...props}
/>
)
}
Screen = React.memo(Screen)
export {Screen}
+10 -8
View File
@@ -1,19 +1,20 @@
import React, {useCallback, useMemo} from 'react'
import React, {useMemo, useCallback} from 'react'
import {ActivityIndicator, FlatList, View} from 'react-native'
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
import {useLikedByQuery} from '#/state/queries/post-liked-by'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {useLikedByQuery} from '#/state/queries/post-liked-by'
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {atoms as a, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import * as Dialog from '#/components/Dialog'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
import {Loader} from '#/components/Loader'
interface LikesDialogProps {
control: Dialog.DialogOuterProps['control']
@@ -24,6 +25,7 @@ export function LikesDialog(props: LikesDialogProps) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<LikesDialogInner {...props} />
</Dialog.Outer>
)
+49 -21
View File
@@ -1,11 +1,15 @@
import React from 'react'
import {GestureResponderEvent} from 'react-native'
import {
GestureResponderEvent,
Pressable,
StyleProp,
ViewStyle,
} from 'react-native'
import {sanitizeUrl} from '@braintree/sanitize-url'
import {StackActions, useLinkProps} from '@react-navigation/native'
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {AllNavigatorParams} from '#/lib/routes/types'
import {shareUrl} from '#/lib/sharing'
import {
@@ -17,6 +21,7 @@ import {
import {isNative, isWeb} from '#/platform/detection'
import {shouldClickOpenNewTab} from '#/platform/urls'
import {useModalControls} from '#/state/modals'
import {useOpenLink} from '#/state/preferences/in-app-browser'
import {atoms as a, flatten, TextStyleProp, useTheme, web} from '#/alf'
import {Button, ButtonProps} from '#/components/Button'
import {useInteractionState} from '#/components/hooks/useInteractionState'
@@ -98,17 +103,17 @@ export function useLink({
linkRequiresWarning(href, displayText),
)
if (isWeb) {
e.preventDefault()
}
if (requiresWarning) {
e.preventDefault()
openModal({
name: 'link-warning',
text: displayText,
href: href,
})
} else {
e.preventDefault()
if (isExternal) {
openLink(href)
} else {
@@ -325,22 +330,45 @@ export function InlineLinkText({
}
/**
* Utility to create a static `onPress` handler for a `Link` that would otherwise link to a URI
*
* Example:
* `<Link {...createStaticClick(e => {...})} />`
* A Pressable that uses useLink to handle navigation. It is unstyled, so can be used in cases where the Button styles
* in Link are not desired.
* @param displayText
* @param style
* @param children
* @param rest
* @constructor
*/
export function createStaticClick(
onPressHandler: Exclude<BaseLinkProps['onPress'], undefined>,
): Pick<BaseLinkProps, 'to' | 'onPress'> {
return {
to: '#',
onPress(e: GestureResponderEvent) {
e.preventDefault()
onPressHandler(e)
return false
},
}
export function BaseLink({
displayText,
onPress: onPressOuter,
style,
children,
...rest
}: {
style?: StyleProp<ViewStyle>
children: React.ReactNode
to: string
action: 'push' | 'replace' | 'navigate'
onPress?: () => false | void
shareOnLongPress?: boolean
label: string
displayText?: string
}) {
const {onPress, ...btnProps} = useLink({
displayText: displayText ?? rest.to,
...rest,
})
return (
<Pressable
style={style}
onPress={e => {
onPressOuter?.()
onPress(e)
}}
{...btnProps}>
{children}
</Pressable>
)
}
export function WebOnlyInlineLinkText({
+10 -10
View File
@@ -4,7 +4,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import flattenReactChildren from 'react-keyed-flatten-children'
import {isNative} from '#/platform/detection'
import {isNative} from 'platform/detection'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
@@ -82,20 +82,19 @@ export function Outer({
style?: StyleProp<ViewStyle>
}>) {
const context = React.useContext(Context)
const {_} = useLingui()
return (
<Dialog.Outer
control={context.control}
nativeOptions={{preventExpansion: true}}>
<Dialog.Outer control={context.control}>
<Dialog.Handle />
{/* Re-wrap with context since Dialogs are portal-ed to root */}
<Context.Provider value={context}>
<Dialog.ScrollableInner label={_(msg`Menu`)} style={[a.py_sm]}>
<Dialog.ScrollableInner label="Menu TODO">
<View style={[a.gap_lg]}>
{children}
{isNative && showCancel && <Cancel />}
</View>
<View style={{height: a.gap_lg.gap}} />
</Dialog.ScrollableInner>
</Context.Provider>
</Dialog.Outer>
@@ -117,14 +116,15 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
{...rest}
accessibilityHint=""
accessibilityLabel={label}
onFocus={onFocus}
onBlur={onBlur}
onPress={async e => {
await onPress(e)
onPress={e => {
onPress(e)
if (!e.defaultPrevented) {
control?.close()
}
}}
onFocus={onFocus}
onBlur={onBlur}
onPressIn={e => {
onPressIn()
rest.onPressIn?.(e)
+4 -4
View File
@@ -5,12 +5,12 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {differenceInSeconds} from 'date-fns'
import {HITSLOP_10} from '#/lib/constants'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {isNative} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSession} from '#/state/session'
import {HITSLOP_10} from 'lib/constants'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {useSession} from 'state/session'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
@@ -127,7 +127,7 @@ export function NewskieDialog({
}}>
<View
style={[
a.w_full,
a.flex_1,
a.mt_sm,
a.p_lg,
a.border,
+6 -11
View File
@@ -4,10 +4,9 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonColor, ButtonText} from '#/components/Button'
import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Text} from '#/components/Typography'
import {BottomSheetViewProps} from '../../modules/bottom-sheet'
export {
type DialogControlProps as PromptControlProps,
@@ -26,11 +25,9 @@ export function Outer({
children,
control,
testID,
nativeOptions,
}: React.PropsWithChildren<{
control: Dialog.DialogControlProps
testID?: string
nativeOptions?: Omit<BottomSheetViewProps, 'children'>
}>) {
const {gtMobile} = useBreakpoints()
const titleId = React.useId()
@@ -42,12 +39,10 @@ export function Outer({
)
return (
<Dialog.Outer
control={control}
testID={testID}
nativeOptions={{preventExpansion: true, ...nativeOptions}}>
<Dialog.Handle />
<Dialog.Outer control={control} testID={testID}>
<Context.Provider value={context}>
<Dialog.Handle />
<Dialog.ScrollableInner
accessibilityLabelledBy={titleId}
accessibilityDescribedBy={descriptionId}
@@ -146,7 +141,7 @@ export function Action({
* Note: The dialog will close automatically when the action is pressed, you
* should NOT close the dialog as a side effect of this method.
*/
onPress: (e: GestureResponderEvent) => void
onPress: ButtonProps['onPress']
color?: ButtonColor
/**
* Optional i18n string. If undefined, it will default to "Confirm".
@@ -199,7 +194,7 @@ export function Basic({
* Note: The dialog will close automatically when the action is pressed, you
* should NOT close the dialog as a side effect of this method.
*/
onConfirm: (e: GestureResponderEvent) => void
onConfirm: ButtonProps['onPress']
confirmButtonColor?: ButtonColor
showCancel?: boolean
}>) {
@@ -4,6 +4,7 @@ import {AppBskyLabelerDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
import {getLabelingServiceTitle} from '#/lib/moderation'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, useButtonContext} from '#/components/Button'
@@ -6,7 +6,6 @@ import {useLingui} from '@lingui/react'
import {getLabelingServiceTitle} from '#/lib/moderation'
import {ReportOption} from '#/lib/moderation/useReportOptions'
import {isAndroid} from '#/platform/detection'
import {useAgent} from '#/state/session'
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
import * as Toast from '#/view/com/util/Toast'
@@ -226,8 +225,6 @@ export function SubmitView({
{submitting && <ButtonIcon icon={Loader} />}
</Button>
</View>
{/* Maybe fix this later -h */}
{isAndroid ? <View style={{height: 300}} /> : null}
</View>
)
}
+8 -3
View File
@@ -1,6 +1,5 @@
import React from 'react'
import {Pressable, View} from 'react-native'
import {ScrollView} from 'react-native-gesture-handler'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -9,10 +8,12 @@ import {useMyLabelersQuery} from '#/state/queries/preferences'
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
import {AppBskyLabelerDefs} from '@atproto/api'
import {BottomSheetScrollViewMethods} from '@discord/bottom-sheet/src'
import {atoms as a} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
import {useOnKeyboardDidShow} from '#/components/hooks/useOnKeyboard'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {SelectLabelerView} from './SelectLabelerView'
@@ -24,6 +25,7 @@ export function ReportDialog(props: ReportDialogProps) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<ReportDialogInner {...props} />
</Dialog.Outer>
)
@@ -35,10 +37,13 @@ function ReportDialogInner(props: ReportDialogProps) {
isLoading: isLabelerLoading,
data: labelers,
error,
} = useMyLabelersQuery({excludeNonConfigurableLabelers: true})
} = useMyLabelersQuery()
const isLoading = useDelayedLoading(500, isLabelerLoading)
const ref = React.useRef<ScrollView>(null)
const ref = React.useRef<BottomSheetScrollViewMethods>(null)
useOnKeyboardDidShow(() => {
ref.current?.scrollToEnd({animated: true})
})
return (
<Dialog.ScrollableInner label={_(msg`Report dialog`)} ref={ref}>
@@ -149,6 +149,7 @@ export function QrCodeDialog({
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Create a QR code for a starter pack`)}>
<View style={[a.flex_1, a.align_center, a.gap_5xl]}>
+8 -8
View File
@@ -6,14 +6,14 @@ import {AppBskyGraphDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {saveImageToMediaLibrary} from '#/lib/media/manip'
import {shareUrl} from '#/lib/sharing'
import {logEvent} from '#/lib/statsig/statsig'
import {getStarterPackOgCard} from '#/lib/strings/starter-pack'
import {logger} from '#/logger'
import {isNative, isWeb} from '#/platform/detection'
import * as Toast from '#/view/com/util/Toast'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {saveImageToMediaLibrary} from 'lib/media/manip'
import {shareUrl} from 'lib/sharing'
import {logEvent} from 'lib/statsig/statsig'
import {getStarterPackOgCard} from 'lib/strings/starter-pack'
import {isNative, isWeb} from 'platform/detection'
import * as Toast from 'view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {DialogControlProps} from '#/components/Dialog'
@@ -32,7 +32,6 @@ interface Props {
export function ShareDialog(props: Props) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<ShareDialogInner {...props} />
</Dialog.Outer>
)
@@ -85,6 +84,7 @@ function ShareDialogInner({
return (
<>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Share link dialog`)}>
{!imageLoaded || !link ? (
<View style={[a.p_xl, a.align_center]}>
@@ -13,7 +13,7 @@ import {precacheStarterPack} from '#/state/queries/starter-packs'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {StarterPack} from '#/components/icons/StarterPack'
import {Link as BaseLink, LinkProps as BaseLinkProps} from '#/components/Link'
import {BaseLink} from '#/components/Link'
import {Text} from '#/components/Typography'
export function Default({
@@ -62,7 +62,7 @@ export function Card({
}
return (
<View style={[a.w_full, a.gap_md]}>
<View style={[a.flex_1, a.gap_md]}>
<View style={[a.flex_row, a.gap_sm]}>
{!noIcon ? <StarterPack width={40} gradient="sky" /> : null}
<View>
@@ -96,7 +96,7 @@ export function Link({
}: {
starterPack: AppBskyGraphDefs.StarterPackViewBasic
onPress?: () => void
children: BaseLinkProps['children']
children: React.ReactNode
}) {
const {_} = useLingui()
const queryClient = useQueryClient()
@@ -123,8 +123,7 @@ export function Link({
starterPack.creator.did,
)
precacheStarterPack(queryClient, starterPack)
}}
style={[a.flex_col, a.align_start]}>
}}>
{children}
</BaseLink>
)
@@ -3,13 +3,13 @@ import type {ListRenderItemInfo} from 'react-native'
import {View} from 'react-native'
import {AppBskyActorDefs, ModerationOpts} from '@atproto/api'
import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {isWeb} from '#/platform/detection'
import {useSession} from '#/state/session'
import {ListMethods} from '#/view/com/util/List'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {isWeb} from 'platform/detection'
import {useSession} from 'state/session'
import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
import {atoms as a, native, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
@@ -45,7 +45,7 @@ export function WizardEditListDialog({
const {currentAccount} = useSession()
const initialNumToRender = useInitialNumToRender()
const listRef = useRef<ListMethods>(null)
const listRef = useRef<BottomSheetFlatListMethods>(null)
const getData = () => {
if (state.currentStep === 'Feeds') return state.feeds
@@ -76,7 +76,10 @@ export function WizardEditListDialog({
)
return (
<Dialog.Outer control={control} testID="newChatDialog">
<Dialog.Outer
control={control}
testID="newChatDialog"
nativeOptions={{sheet: {snapPoints: ['95%']}}}>
<Dialog.Handle />
<Dialog.InnerFlatList
ref={listRef}
@@ -86,7 +89,6 @@ export function WizardEditListDialog({
ListHeaderComponent={
<View
style={[
native(a.pt_4xl),
a.flex_row,
a.justify_between,
a.border_b,
@@ -101,7 +103,13 @@ export function WizardEditListDialog({
height: 48,
},
]
: [a.pb_sm, a.align_end],
: [
a.pb_sm,
a.align_end,
{
height: 68,
},
],
]}>
<View style={{width: 60}} />
<Text style={[a.font_bold, a.text_xl]}>
@@ -135,6 +143,8 @@ export function WizardEditListDialog({
paddingHorizontal: 0,
marginTop: 0,
paddingTop: 0,
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
}),
]}
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
+22 -7
View File
@@ -4,6 +4,7 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {makeSearchLink} from '#/lib/routes/links'
import {NavigationProp} from '#/lib/routes/types'
import {isInvalidHandle} from '#/lib/strings/handles'
import {
@@ -18,7 +19,7 @@ import {Divider} from '#/components/Divider'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
import {createStaticClick, Link} from '#/components/Link'
import {Link} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
@@ -84,6 +85,7 @@ export function TagMenu({
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.Inner label={_(msg`Tag menu: ${displayTag}`)}>
{isPreferencesLoading ? (
<View style={[a.w_full, a.align_center]}>
@@ -100,14 +102,19 @@ export function TagMenu({
t.atoms.bg_contrast_25,
]}>
<Link
label={_(msg`View all posts with tag ${displayTag}`)}
{...createStaticClick(() => {
label={_(msg`Search for all posts with tag ${displayTag}`)}
to={makeSearchLink({query: displayTag})}
onPress={e => {
e.preventDefault()
control.close(() => {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
})
})
})}>
return false
}}>
<View
style={[
a.w_full,
@@ -146,16 +153,24 @@ export function TagMenu({
<Link
label={_(
msg`View all posts by @${authorHandle} with tag ${displayTag}`,
msg`Search for all posts by @${authorHandle} with tag ${displayTag}`,
)}
{...createStaticClick(() => {
to={makeSearchLink({
query: displayTag,
from: authorHandle,
})}
onPress={e => {
e.preventDefault()
control.close(() => {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
author: authorHandle,
})
})
})}>
return false
}}>
<View
style={[
a.w_full,
+13 -20
View File
@@ -26,7 +26,7 @@ export type TextProps = Omit<RNTextProps, 'children'> & {
title?: string
} & (
| {
emoji?: true
emoji: true
children: StringChild
}
| {
@@ -53,14 +53,11 @@ export function childIsString(
)
}
export function renderChildrenWithEmoji(
children: StringChild,
props: Omit<TextProps, 'children'> = {},
) {
export function renderChildrenWithEmoji(children: StringChild) {
const normalized = Array.isArray(children) ? children : [children]
return (
<UITextView {...props}>
<UITextView>
{normalized.map(child => {
if (typeof child !== 'string') return child
@@ -71,12 +68,10 @@ export function renderChildrenWithEmoji(
}
return child.split(EMOJI).map((stringPart, index) => (
<UITextView key={index} {...props}>
<UITextView key={index}>
{stringPart}
{emojis[index] ? (
<UITextView
{...props}
style={[props?.style, {color: 'black', fontFamily: 'System'}]}>
<UITextView style={{color: 'black', fontFamily: 'System'}}>
{emojis[index]}
</UITextView>
) : null}
@@ -168,17 +163,15 @@ export function Text({
}
}
const shared = {
uiTextView: true,
selectable,
style: s,
dataSet: Object.assign({tooltip: title}, dataSet || {}),
...rest,
}
return (
<UITextView {...shared}>
{isIOS && emoji ? renderChildrenWithEmoji(children, shared) : children}
<UITextView
selectable={selectable}
uiTextView
style={s}
{...rest}
// @ts-ignore
dataSet={Object.assign({tooltip: title}, dataSet || {})}>
{isIOS && emoji ? renderChildrenWithEmoji(children) : children}
</UITextView>
)
}
@@ -31,6 +31,7 @@ export function BirthDateSettingsDialog({
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`My Birthday`)}>
<View style={[a.gap_sm, a.pb_lg]}>
<Text style={[a.text_2xl, a.font_bold]}>
+1
View File
@@ -50,6 +50,7 @@ export function EmbedConsentDialog({
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`External Media`)}
style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}>
+255
View File
@@ -0,0 +1,255 @@
import React, {
useCallback,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import {Modal, ScrollView, TextInput, View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {cleanError} from '#/lib/strings/errors'
import {
Gif,
useFeaturedGifsQuery,
useGifSearchQuery,
} from '#/state/queries/tenor'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {FlatList_INTERNAL} from '#/view/com/util/Views'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import * as TextField from '#/components/forms/TextField'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {Button, ButtonText} from '../Button'
import {Handle} from '../Dialog'
import {useThrottledValue} from '../hooks/useThrottledValue'
import {ListFooter, ListMaybePlaceholder} from '../Lists'
import {GifPreview} from './GifSelect.shared'
export function GifSelectDialog({
controlRef,
onClose,
onSelectGif: onSelectGifProp,
}: {
controlRef: React.RefObject<{open: () => void}>
onClose: () => void
onSelectGif: (gif: Gif) => void
}) {
const t = useTheme()
const [open, setOpen] = useState(false)
useImperativeHandle(controlRef, () => ({
open: () => setOpen(true),
}))
const close = useCallback(() => {
setOpen(false)
onClose()
}, [onClose])
const onSelectGif = useCallback(
(gif: Gif) => {
onSelectGifProp(gif)
close()
},
[onSelectGifProp, close],
)
const renderErrorBoundary = useCallback(
(error: any) => <ModalError details={String(error)} close={close} />,
[close],
)
return (
<Modal
visible={open}
animationType="slide"
presentationStyle="formSheet"
onRequestClose={close}
aria-modal
accessibilityViewIsModal>
<View style={[a.flex_1, t.atoms.bg]}>
<Handle />
<ErrorBoundary renderError={renderErrorBoundary}>
<GifList onSelectGif={onSelectGif} close={close} />
</ErrorBoundary>
</View>
</Modal>
)
}
function GifList({
onSelectGif,
}: {
close: () => void
onSelectGif: (gif: Gif) => void
}) {
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const textInputRef = useRef<TextInput>(null)
const listRef = useRef<FlatList_INTERNAL>(null)
const [undeferredSearch, setSearch] = useState('')
const search = useThrottledValue(undeferredSearch, 500)
const isSearching = search.length > 0
const trendingQuery = useFeaturedGifsQuery()
const searchQuery = useGifSearchQuery(search)
const {
data,
fetchNextPage,
isFetchingNextPage,
hasNextPage,
error,
isLoading,
isError,
refetch,
} = isSearching ? searchQuery : trendingQuery
const flattenedData = useMemo(() => {
return data?.pages.flatMap(page => page.results) || []
}, [data])
const renderItem = useCallback(
({item}: {item: Gif}) => {
return <GifPreview gif={item} onSelectGif={onSelectGif} />
},
[onSelectGif],
)
const onEndReached = React.useCallback(() => {
if (isFetchingNextPage || !hasNextPage || error) return
fetchNextPage()
}, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
const hasData = flattenedData.length > 0
const onGoBack = useCallback(() => {
if (isSearching) {
// clear the input and reset the state
textInputRef.current?.clear()
setSearch('')
} else {
close()
}
}, [isSearching])
const listHeader = useMemo(() => {
return (
<View style={[a.relative, a.mb_lg, a.pt_4xl, a.flex_row, a.align_center]}>
{/* cover top corners */}
<View
style={[
a.absolute,
a.inset_0,
{
borderBottomLeftRadius: 8,
borderBottomRightRadius: 8,
},
t.atoms.bg,
]}
/>
<TextField.Root>
<TextField.Icon icon={Search} />
<TextField.Input
label={_(msg`Search GIFs`)}
placeholder={_(msg`Search Tenor`)}
onChangeText={text => {
setSearch(text)
listRef.current?.scrollToOffset({offset: 0, animated: false})
}}
returnKeyType="search"
clearButtonMode="while-editing"
inputRef={textInputRef}
maxLength={50}
/>
</TextField.Root>
</View>
)
}, [t.atoms.bg, _])
return (
<FlatList_INTERNAL
ref={listRef}
key={gtMobile ? '3 cols' : '2 cols'}
data={flattenedData}
renderItem={renderItem}
numColumns={gtMobile ? 3 : 2}
columnWrapperStyle={a.gap_sm}
contentContainerStyle={a.px_lg}
ListHeaderComponent={
<>
{listHeader}
{!hasData && (
<ListMaybePlaceholder
isLoading={isLoading}
isError={isError}
onRetry={refetch}
onGoBack={onGoBack}
emptyType="results"
sideBorders={false}
topBorder={false}
errorTitle={_(msg`Failed to load GIFs`)}
errorMessage={_(msg`There was an issue connecting to Tenor.`)}
emptyMessage={
isSearching
? _(msg`No search results found for "${search}".`)
: _(
msg`No featured GIFs found. There may be an issue with Tenor.`,
)
}
/>
)}
</>
}
stickyHeaderIndices={[0]}
onEndReached={onEndReached}
onEndReachedThreshold={4}
keyExtractor={(item: Gif) => item.id}
keyboardDismissMode="on-drag"
ListFooterComponent={
hasData ? (
<ListFooter
isFetchingNextPage={isFetchingNextPage}
error={cleanError(error)}
onRetry={fetchNextPage}
style={{borderTopWidth: 0}}
/>
) : null
}
/>
)
}
function ModalError({details, close}: {details?: string; close: () => void}) {
const {_} = useLingui()
return (
<ScrollView
style={[a.flex_1, a.gap_md]}
centerContent
contentContainerStyle={a.px_lg}>
<ErrorScreen
title={_(msg`Oh no!`)}
message={_(
msg`There was an unexpected issue in the application. Please let us know if this happened to you!`,
)}
details={details}
/>
<Button
label={_(msg`Close dialog`)}
onPress={close}
color="primary"
size="large"
variant="solid">
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
</ScrollView>
)
}
@@ -0,0 +1,53 @@
import React, {useCallback} from 'react'
import {Image} from 'expo-image'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {Gif} from '#/state/queries/tenor'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button} from '../Button'
export function GifPreview({
gif,
onSelectGif,
}: {
gif: Gif
onSelectGif: (gif: Gif) => void
}) {
const {gtTablet} = useBreakpoints()
const {_} = useLingui()
const t = useTheme()
const onPress = useCallback(() => {
logEvent('composer:gif:select', {})
onSelectGif(gif)
}, [onSelectGif, gif])
return (
<Button
label={_(msg`Select GIF "${gif.title}"`)}
style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
onPress={onPress}>
{({pressed}) => (
<Image
style={[
a.flex_1,
a.mb_sm,
a.rounded_sm,
{aspectRatio: 1, opacity: pressed ? 0.8 : 1},
t.atoms.bg_contrast_25,
]}
source={{
uri: gif.media_formats.tinygif.url,
}}
contentFit="cover"
accessibilityLabel={gif.title}
accessibilityHint=""
cachePolicy="none"
accessibilityIgnoresInvertColors
/>
)}
</Button>
)
}
+12 -68
View File
@@ -6,12 +6,10 @@ import React, {
useState,
} from 'react'
import {TextInput, View} from 'react-native'
import {useWindowDimensions} from 'react-native'
import {Image} from 'expo-image'
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors'
import {isWeb} from '#/platform/detection'
import {
@@ -21,15 +19,15 @@ import {
} from '#/state/queries/tenor'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {ListMethods} from '#/view/com/util/List'
import {atoms as a, ios, native, useBreakpoints, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
import {Button, ButtonIcon, ButtonText} from '../Button'
import {ListFooter, ListMaybePlaceholder} from '../Lists'
import {GifPreview} from './GifSelect.shared'
export function GifSelectDialog({
controlRef,
@@ -37,7 +35,7 @@ export function GifSelectDialog({
onSelectGif: onSelectGifProp,
}: {
controlRef: React.RefObject<{open: () => void}>
onClose?: () => void
onClose: () => void
onSelectGif: (gif: Gif) => void
}) {
const control = Dialog.useDialogControl()
@@ -61,12 +59,8 @@ export function GifSelectDialog({
return (
<Dialog.Outer
control={control}
onClose={onClose}
nativeOptions={{
bottomInset: 0,
// use system corner radius on iOS
...ios({cornerRadius: undefined}),
}}>
nativeOptions={{sheet: {snapPoints: ['100%']}}}
onClose={onClose}>
<Dialog.Handle />
<ErrorBoundary renderError={renderErrorBoundary}>
<GifList control={control} onSelectGif={onSelectGif} />
@@ -86,10 +80,9 @@ function GifList({
const t = useTheme()
const {gtMobile} = useBreakpoints()
const textInputRef = useRef<TextInput>(null)
const listRef = useRef<ListMethods>(null)
const listRef = useRef<BottomSheetFlatListMethods>(null)
const [undeferredSearch, setSearch] = useState('')
const search = useThrottledValue(undeferredSearch, 500)
const {height} = useWindowDimensions()
const isSearching = search.length > 0
@@ -102,7 +95,7 @@ function GifList({
isFetchingNextPage,
hasNextPage,
error,
isPending,
isLoading,
isError,
refetch,
} = isSearching ? searchQuery : trendingQuery
@@ -139,7 +132,6 @@ function GifList({
return (
<View
style={[
native(a.pt_4xl),
a.relative,
a.mb_lg,
a.flex_row,
@@ -204,17 +196,13 @@ function GifList({
data={flattenedData}
renderItem={renderItem}
numColumns={gtMobile ? 3 : 2}
columnWrapperStyle={[a.gap_sm]}
contentContainerStyle={[
native([a.px_xl, {minHeight: height}]),
web(a.h_full_vh),
]}
columnWrapperStyle={a.gap_sm}
ListHeaderComponent={
<>
{listHeader}
{!hasData && (
<ListMaybePlaceholder
isLoading={isPending}
isLoading={isLoading}
isError={isError}
onRetry={refetch}
onGoBack={onGoBack}
@@ -285,47 +273,3 @@ function DialogError({details}: {details?: string}) {
</Dialog.ScrollableInner>
)
}
export function GifPreview({
gif,
onSelectGif,
}: {
gif: Gif
onSelectGif: (gif: Gif) => void
}) {
const {gtTablet} = useBreakpoints()
const {_} = useLingui()
const t = useTheme()
const onPress = useCallback(() => {
logEvent('composer:gif:select', {})
onSelectGif(gif)
}, [onSelectGif, gif])
return (
<Button
label={_(msg`Select GIF "${gif.title}"`)}
style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
onPress={onPress}>
{({pressed}) => (
<Image
style={[
a.flex_1,
a.mb_sm,
a.rounded_sm,
{aspectRatio: 1, opacity: pressed ? 0.8 : 1},
t.atoms.bg_contrast_25,
]}
source={{
uri: gif.media_formats.tinygif.url,
}}
contentFit="cover"
accessibilityLabel={gif.title}
accessibilityHint=""
cachePolicy="none"
accessibilityIgnoresInvertColors
/>
)}
</Button>
)
}
@@ -204,9 +204,7 @@ export function PostInteractionSettingsDialogControlledInner(
label={_(msg`Edit post interaction settings`)}
style={[{maxWidth: 500}, a.w_full]}>
{isLoading ? (
<View style={[a.flex_1, a.py_4xl, a.align_center, a.justify_center]}>
<Loader size="xl" />
</View>
<Loader size="xl" />
) : (
<PostInteractionSettingsForm
replySettingsDisabled={!isThreadgateOwnedByViewer}
@@ -233,6 +231,7 @@ export function PostInteractionSettingsForm({
}: PostInteractionSettingsFormProps) {
const t = useTheme()
const {_} = useLingui()
const control = Dialog.useDialogContext()
const {data: lists} = useMyListsQuery('curate')
const [quotesEnabled, setQuotesEnabled] = React.useState(
!(
@@ -438,6 +437,7 @@ export function PostInteractionSettingsForm({
<Button
label={_(msg`Save`)}
onPress={onSave}
onAccessibilityEscape={control.close}
color="primary"
size="large"
variant="solid"
+1
View File
@@ -43,6 +43,7 @@ export function SwitchAccountDialog({
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Switch Account`)}>
<View style={[a.gap_lg]}>
<Text style={[a.text_2xl, a.font_bold]}>
@@ -1,254 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
import {useAgent, useSession} from '#/state/session'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {atoms as a, useBreakpoints} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export function VerifyEmailDialog({
control,
}: {
control: Dialog.DialogControlProps
}) {
const agent = useAgent()
const [didVerify, setDidVerify] = React.useState(false)
return (
<Dialog.Outer
control={control}
onClose={async () => {
if (!didVerify) {
return
}
try {
await agent.resumeSession(agent.session!)
} catch (e: unknown) {
logger.error(String(e))
return
}
}}>
<Dialog.Handle />
<Inner control={control} setDidVerify={setDidVerify} />
</Dialog.Outer>
)
}
export function Inner({
control,
setDidVerify,
}: {
control: Dialog.DialogControlProps
setDidVerify: (value: boolean) => void
}) {
const {_} = useLingui()
const {currentAccount} = useSession()
const agent = useAgent()
const {openModal} = useModalControls()
const {gtMobile} = useBreakpoints()
const [currentStep, setCurrentStep] = React.useState<
'StepOne' | 'StepTwo' | 'StepThree'
>('StepOne')
const [confirmationCode, setConfirmationCode] = React.useState('')
const [isProcessing, setIsProcessing] = React.useState(false)
const [error, setError] = React.useState('')
const uiStrings = {
StepOne: {
title: _(msg`Verify Your Email`),
message: '',
},
StepTwo: {
title: _(msg`Enter Code`),
message: _(
msg`An email has been sent! Please enter the confirmation code included in the email below.`,
),
},
StepThree: {
title: _(msg`Success!`),
message: _(msg`Thank you! Your email has been successfully verified.`),
},
}
const onSendEmail = async () => {
setError('')
setIsProcessing(true)
try {
await agent.com.atproto.server.requestEmailConfirmation()
setCurrentStep('StepTwo')
} catch (e: unknown) {
setError(cleanError(e))
} finally {
setIsProcessing(false)
}
}
const onVerifyEmail = async () => {
setError('')
setIsProcessing(true)
try {
await agent.com.atproto.server.confirmEmail({
email: (currentAccount?.email || '').trim(),
token: confirmationCode.trim(),
})
} catch (e: unknown) {
setError(cleanError(String(e)))
setIsProcessing(false)
return
}
setIsProcessing(false)
setDidVerify(true)
setCurrentStep('StepThree')
}
return (
<Dialog.ScrollableInner
label={_(msg`Verify email dialog`)}
style={[
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
]}>
<Dialog.Close />
<View style={[a.gap_xl]}>
<View style={[a.gap_sm]}>
<Text style={[a.font_heavy, a.text_2xl]}>
{uiStrings[currentStep].title}
</Text>
{error ? (
<View style={[a.rounded_sm, a.overflow_hidden]}>
<ErrorMessage message={error} />
</View>
) : null}
<Text style={[a.text_md, a.leading_snug]}>
{currentStep === 'StepOne' ? (
<>
<Trans>
You'll receive an email at{' '}
<Text style={[a.text_md, a.leading_snug, a.font_bold]}>
{currentAccount?.email}
</Text>{' '}
to verify it's you.
</Trans>{' '}
<InlineLinkText
to="#"
label={_(msg`Change email address`)}
style={[a.text_md, a.leading_snug]}
onPress={e => {
e.preventDefault()
control.close(() => {
openModal({name: 'change-email'})
})
return false
}}>
<Trans>Need to change it?</Trans>
</InlineLinkText>
</>
) : (
uiStrings[currentStep].message
)}
</Text>
</View>
{currentStep === 'StepTwo' ? (
<View>
<TextField.LabelText>
<Trans>Confirmation Code</Trans>
</TextField.LabelText>
<TextField.Root>
<TextField.Input
label={_(msg`Confirmation code`)}
placeholder="XXXXX-XXXXX"
onChangeText={setConfirmationCode}
/>
</TextField.Root>
</View>
) : null}
<View style={[a.gap_sm, gtMobile && [a.flex_row_reverse, a.ml_auto]]}>
{currentStep === 'StepOne' ? (
<>
<Button
label={_(msg`Send confirmation email`)}
variant="solid"
color="primary"
size="large"
disabled={isProcessing}
onPress={onSendEmail}>
<ButtonText>
<Trans>Send Confirmation</Trans>
</ButtonText>
{isProcessing ? (
<Loader size="sm" style={[{color: 'white'}]} />
) : null}
</Button>
<Button
label={_(msg`I Have a Code`)}
variant="solid"
color="secondary"
size="large"
disabled={isProcessing}
onPress={() => setCurrentStep('StepTwo')}>
<ButtonText>
<Trans>I Have a Code</Trans>
</ButtonText>
</Button>
</>
) : currentStep === 'StepTwo' ? (
<>
<Button
label={_(msg`Confirm`)}
variant="solid"
color="primary"
size="large"
disabled={isProcessing}
onPress={onVerifyEmail}>
<ButtonText>
<Trans>Confirm</Trans>
</ButtonText>
{isProcessing ? (
<Loader size="sm" style={[{color: 'white'}]} />
) : null}
</Button>
<Button
label={_(msg`Resend Email`)}
variant="solid"
color="secondary"
size="large"
disabled={isProcessing}
onPress={() => {
setConfirmationCode('')
setCurrentStep('StepOne')
}}>
<ButtonText>
<Trans>Resend Email</Trans>
</ButtonText>
</Button>
</>
) : currentStep === 'StepThree' ? (
<Button
label={_(msg`Confirm`)}
variant="solid"
color="primary"
size="large"
onPress={() => control.close()}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
) : null}
</View>
</View>
</Dialog.ScrollableInner>
)
}
@@ -44,6 +44,7 @@ export function NeueTypography() {
return (
<Dialog.Outer control={control} onClose={onClose}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`Introducing new font settings`)}>
<View style={[a.gap_xl]}>
<View style={[a.gap_md]}>
+1 -1
View File
@@ -41,7 +41,7 @@ const queuedNuxs: {
id: Nux.NeueTypography,
enabled(props) {
if (props.currentProfile.createdAt) {
if (new Date(props.currentProfile.createdAt) < new Date('2024-10-09')) {
if (new Date(props.currentProfile.createdAt) < new Date('2024-10-01')) {
return true
}
}
+3 -3
View File
@@ -136,7 +136,7 @@ let ConvoMenu = ({
<Menu.Outer>
<Menu.Item
label={_(msg`Leave conversation`)}
onPress={() => leaveConvoControl.open()}>
onPress={leaveConvoControl.open}>
<Menu.ItemText>
<Trans>Leave conversation</Trans>
</Menu.ItemText>
@@ -195,7 +195,7 @@ let ConvoMenu = ({
</Menu.Item>
<Menu.Item
label={_(msg`Report conversation`)}
onPress={() => reportControl.open()}>
onPress={reportControl.open}>
<Menu.ItemText>
<Trans>Report conversation</Trans>
</Menu.ItemText>
@@ -206,7 +206,7 @@ let ConvoMenu = ({
<Menu.Group>
<Menu.Item
label={_(msg`Leave conversation`)}
onPress={() => leaveConvoControl.open()}>
onPress={leaveConvoControl.open}>
<Menu.ItemText>
<Trans>Leave conversation</Trans>
</Menu.ItemText>
-80
View File
@@ -1,80 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {subDays} from 'date-fns'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '../Typography'
import {localDateString} from './util'
const timeFormatter = new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: 'numeric',
})
const weekdayFormatter = new Intl.DateTimeFormat(undefined, {
weekday: 'long',
})
const longDateFormatter = new Intl.DateTimeFormat(undefined, {
weekday: 'short',
month: 'long',
day: 'numeric',
})
const longDateFormatterWithYear = new Intl.DateTimeFormat(undefined, {
weekday: 'short',
month: 'long',
day: 'numeric',
year: 'numeric',
})
let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => {
const {_} = useLingui()
const t = useTheme()
let date: string
const time = timeFormatter.format(new Date(dateStr))
const timestamp = new Date(dateStr)
const today = new Date()
const yesterday = subDays(today, 1)
const oneWeekAgo = subDays(today, 7)
if (localDateString(today) === localDateString(timestamp)) {
date = _(msg`Today`)
} else if (localDateString(yesterday) === localDateString(timestamp)) {
date = _(msg`Yesterday`)
} else {
if (timestamp < oneWeekAgo) {
if (timestamp.getFullYear() === today.getFullYear()) {
date = longDateFormatter.format(timestamp)
} else {
date = longDateFormatterWithYear.format(timestamp)
}
} else {
date = weekdayFormatter.format(timestamp)
}
}
return (
<View style={[a.w_full, a.my_lg]}>
<Text
style={[
a.text_xs,
a.text_center,
t.atoms.bg,
t.atoms.text_contrast_medium,
a.px_md,
]}>
<Trans>
<Text style={[a.text_xs, t.atoms.text_contrast_medium, a.font_bold]}>
{date}
</Text>{' '}
at {time}
</Trans>
</Text>
</View>
)
}
DateDivider = React.memo(DateDivider)
export {DateDivider}
+90 -88
View File
@@ -17,15 +17,13 @@ import {useLingui} from '@lingui/react'
import {ConvoItem} from '#/state/messages/convo/types'
import {useSession} from '#/state/session'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {TimeElapsed} from 'view/com/util/TimeElapsed'
import {atoms as a, useTheme} from '#/alf'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
import {isOnlyEmoji, RichText} from '../RichText'
import {DateDivider} from './DateDivider'
import {MessageItemEmbed} from './MessageItemEmbed'
import {localDateString} from './util'
let MessageItem = ({
item,
@@ -35,37 +33,14 @@ let MessageItem = ({
const t = useTheme()
const {currentAccount} = useSession()
const {message, nextMessage, prevMessage} = item
const {message, nextMessage} = item
const isPending = item.type === 'pending-message'
const isFromSelf = message.sender?.did === currentAccount?.did
const nextIsMessage = ChatBskyConvoDefs.isMessageView(nextMessage)
const isNextFromSelf =
nextIsMessage && nextMessage.sender?.did === currentAccount?.did
const isNextFromSameSender = isNextFromSelf === isFromSelf
const isNewDay = useMemo(() => {
if (!prevMessage) return true
const thisDate = new Date(message.sentAt)
const prevDate = new Date(prevMessage.sentAt)
return localDateString(thisDate) !== localDateString(prevDate)
}, [message, prevMessage])
const isLastMessageOfDay = useMemo(() => {
if (!nextMessage || !nextIsMessage) return true
const thisDate = new Date(message.sentAt)
const prevDate = new Date(nextMessage.sentAt)
return localDateString(thisDate) !== localDateString(prevDate)
}, [message.sentAt, nextIsMessage, nextMessage])
const needsTail = isLastMessageOfDay || !isNextFromSameSender
ChatBskyConvoDefs.isMessageView(nextMessage) &&
nextMessage.sender?.did === currentAccount?.did
const isLastInGroup = useMemo(() => {
// if this message is pending, it means the next message is pending too
@@ -73,19 +48,24 @@ let MessageItem = ({
return false
}
// or, if there's a 5 minute gap between this message and the next
// if the next message is from a different sender, then it's the last in the group
if (isFromSelf ? !isNextFromSelf : isNextFromSelf) {
return true
}
// or, if there's a 3 minute gap between this message and the next
if (ChatBskyConvoDefs.isMessageView(nextMessage)) {
const thisDate = new Date(message.sentAt)
const nextDate = new Date(nextMessage.sentAt)
const diff = nextDate.getTime() - thisDate.getTime()
// 5 minutes
return diff > 5 * 60 * 1000
// 3 minutes
return diff > 3 * 60 * 1000
}
return true
}, [message, nextMessage, isPending])
}, [message, nextMessage, isFromSelf, isNextFromSelf, isPending])
const lastInGroupRef = useRef(isLastInGroup)
if (lastInGroupRef.current !== isLastInGroup) {
@@ -100,59 +80,52 @@ let MessageItem = ({
}, [message.text, message.facets])
return (
<>
{isNewDay && <DateDivider date={message.sentAt} />}
<View
style={[
isFromSelf ? a.mr_md : a.ml_md,
nextIsMessage && !isNextFromSameSender && a.mb_md,
]}>
<ActionsWrapper isFromSelf={isFromSelf} message={message}>
{AppBskyEmbedRecord.isView(message.embed) && (
<MessageItemEmbed embed={message.embed} />
)}
{rt.text.length > 0 && (
<View
style={
!isOnlyEmoji(message.text) && [
a.py_sm,
a.my_2xs,
a.rounded_md,
{
paddingLeft: 14,
paddingRight: 14,
backgroundColor: isFromSelf
? isPending
? pendingColor
: t.palette.primary_500
: t.palette.contrast_50,
borderRadius: 17,
},
isFromSelf ? a.self_end : a.self_start,
isFromSelf
? {borderBottomRightRadius: needsTail ? 2 : 17}
: {borderBottomLeftRadius: needsTail ? 2 : 17},
]
}>
<RichText
value={rt}
style={[a.text_md, isFromSelf && {color: t.palette.white}]}
interactiveStyle={a.underline}
enableTags
emojiMultiplier={3}
/>
</View>
)}
</ActionsWrapper>
{isLastInGroup && (
<MessageItemMetadata
item={item}
style={isFromSelf ? a.text_right : a.text_left}
/>
<View style={[isFromSelf ? a.mr_md : a.ml_md]}>
<ActionsWrapper isFromSelf={isFromSelf} message={message}>
{AppBskyEmbedRecord.isView(message.embed) && (
<MessageItemEmbed embed={message.embed} />
)}
</View>
</>
{rt.text.length > 0 && (
<View
style={
!isOnlyEmoji(message.text) && [
a.py_sm,
a.my_2xs,
a.rounded_md,
{
paddingLeft: 14,
paddingRight: 14,
backgroundColor: isFromSelf
? isPending
? pendingColor
: t.palette.primary_500
: t.palette.contrast_50,
borderRadius: 17,
},
isFromSelf ? a.self_end : a.self_start,
isFromSelf
? {borderBottomRightRadius: isLastInGroup ? 2 : 17}
: {borderBottomLeftRadius: isLastInGroup ? 2 : 17},
]
}>
<RichText
value={rt}
style={[a.text_md, isFromSelf && {color: t.palette.white}]}
interactiveStyle={a.underline}
enableTags
emojiMultiplier={3}
/>
</View>
)}
</ActionsWrapper>
{isLastInGroup && (
<MessageItemMetadata
item={item}
style={isFromSelf ? a.text_right : a.text_left}
/>
)}
</View>
)
}
MessageItem = React.memo(MessageItem)
@@ -192,12 +165,31 @@ let MessageItemMetadata = ({
const diff = now.getTime() - date.getTime()
// if under 30 seconds
if (diff < 1000 * 30) {
// if under 1 minute
if (diff < 1000 * 60) {
return _(msg`Now`)
}
return time
// if in the last day
if (localDateString(now) === localDateString(date)) {
return time
}
// if yesterday
const yesterday = new Date(now)
yesterday.setDate(yesterday.getDate() - 1)
if (localDateString(yesterday) === localDateString(date)) {
return _(msg`Yesterday, ${time}`)
}
return i18n.date(date, {
hour: 'numeric',
minute: 'numeric',
day: 'numeric',
month: 'numeric',
year: 'numeric',
})
},
[_],
)
@@ -250,5 +242,15 @@ let MessageItemMetadata = ({
</Text>
)
}
MessageItemMetadata = React.memo(MessageItemMetadata)
export {MessageItemMetadata}
function localDateString(date: Date) {
// can't use toISOString because it should be in local time
const mm = date.getMonth()
const dd = date.getDate()
const yyyy = date.getFullYear()
// not padding with 0s because it's not necessary, it's just used for comparison
return `${yyyy}-${mm}-${dd}`
}
+6 -6
View File
@@ -5,13 +5,13 @@ import {ChatBskyConvoDefs, RichText} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {getTranslatorLink} from '#/locale/helpers'
import {isWeb} from '#/platform/detection'
import {useConvoActive} from '#/state/messages/convo'
import {useLanguagePrefs} from '#/state/preferences'
import {useSession} from '#/state/session'
import {useOpenLink} from '#/state/preferences/in-app-browser'
import {isWeb} from 'platform/detection'
import {useConvoActive} from 'state/messages/convo'
import {useSession} from 'state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {ReportDialog} from '#/components/dms/ReportDialog'
@@ -120,7 +120,7 @@ export let MessageMenu = ({
<Menu.Item
testID="messageDropdownDeleteBtn"
label={_(msg`Delete message for me`)}
onPress={() => deleteControl.open()}>
onPress={deleteControl.open}>
<Menu.ItemText>{_(msg`Delete for me`)}</Menu.ItemText>
<Menu.ItemIcon icon={Trash} position="right" />
</Menu.Item>
@@ -128,7 +128,7 @@ export let MessageMenu = ({
<Menu.Item
testID="messageDropdownReportBtn"
label={_(msg`Report message`)}
onPress={() => reportControl.open()}>
onPress={reportControl.open}>
<Menu.ItemText>{_(msg`Report`)}</Menu.ItemText>
<Menu.ItemIcon icon={Warning} position="right" />
</Menu.Item>
+17 -9
View File
@@ -4,13 +4,12 @@ import {AppBskyActorDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {useMaybeConvoForUser} from '#/state/queries/messages/get-convo-for-members'
import {logEvent} from 'lib/statsig/statsig'
import {atoms as a, useTheme} from '#/alf'
import {ButtonIcon} from '#/components/Button'
import {canBeMessaged} from '#/components/dms/util'
import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message'
import {Link} from '#/components/Link'
import {Message_Stroke2_Corner0_Rounded as Message} from '../icons/Message'
import {Link} from '../Link'
import {canBeMessaged} from './util'
export function MessageProfileButton({
profile,
@@ -41,9 +40,15 @@ export function MessageProfileButton({
a.align_center,
t.atoms.bg_contrast_25,
a.rounded_full,
{width: 34, height: 34},
{width: 36, height: 36},
]}>
<Message style={[t.atoms.text, {opacity: 0.3}]} size="md" />
<Message
style={[
t.atoms.text,
{marginLeft: 1, marginBottom: 1, opacity: 0.3},
]}
size="md"
/>
</View>
)
} else {
@@ -61,9 +66,12 @@ export function MessageProfileButton({
shape="round"
label={_(msg`Message ${profile.handle}`)}
to={`/messages/${convo.id}`}
style={[a.justify_center]}
style={[a.justify_center, {width: 36, height: 36}]}
onPress={onPress}>
<ButtonIcon icon={Message} size="md" />
<Message
style={[t.atoms.text, {marginLeft: 1, marginBottom: 1}]}
size="md"
/>
</Link>
)
} else {
+175
View File
@@ -0,0 +1,175 @@
import React, {useCallback, useEffect} from 'react'
import {View} from 'react-native'
import {ChatBskyActorDeclaration} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useUpdateActorDeclaration} from '#/state/queries/messages/actor-declaration'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as Toggle from '#/components/forms/Toggle'
import {Message_Stroke2_Corner0_Rounded} from '#/components/icons/Message'
import {Text} from '#/components/Typography'
export function MessagesNUX() {
const control = Dialog.useDialogControl()
const {currentAccount} = useSession()
const {data: profile} = useProfileQuery({
did: currentAccount!.did,
})
useEffect(() => {
if (profile && typeof profile.associated?.chat === 'undefined') {
const timeout = setTimeout(() => {
control.open()
}, 1000)
return () => {
clearTimeout(timeout)
}
}
}, [profile, control])
if (!profile) return null
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<DialogInner chatDeclation={profile.associated?.chat} />
</Dialog.Outer>
)
}
function DialogInner({
chatDeclation,
}: {
chatDeclation?: ChatBskyActorDeclaration.Record
}) {
const control = Dialog.useDialogContext()
const {_} = useLingui()
const t = useTheme()
const [initialized, setInitialzed] = React.useState(false)
const {mutate: updateDeclaration} = useUpdateActorDeclaration({
onError: () => {
Toast.show(_(msg`Failed to update settings`), 'xmark')
},
})
const onSelectItem = useCallback(
(keys: string[]) => {
const key = keys[0]
if (!key) return
updateDeclaration(key as 'all' | 'none' | 'following')
},
[updateDeclaration],
)
useEffect(() => {
if (!chatDeclation && !initialized) {
updateDeclaration('following')
setInitialzed(true)
}
}, [chatDeclation, updateDeclaration, initialized])
return (
<Dialog.ScrollableInner
label={_(msg`Introducing Direct Messages`)}
style={web({maxWidth: 440})}>
<View style={a.gap_xl}>
<View style={[a.align_center, a.pt_sm, a.pb_xs]}>
<Message_Stroke2_Corner0_Rounded width={64} />
<Text style={[a.text_2xl, a.font_bold, a.text_center, a.mt_md]}>
<Trans>Direct messages are here!</Trans>
</Text>
<Text style={[a.text_md, a.text_center, a.mt_sm]}>
<Trans>Privately chat with other users.</Trans>
</Text>
</View>
<View
style={[
a.gap_xs,
a.border,
a.overflow_hidden,
a.rounded_sm,
t.atoms.border_contrast_low,
]}>
<View
style={[
a.p_md,
a.border_b,
t.atoms.bg_contrast_25,
t.atoms.border_contrast_low,
]}>
<Text style={[a.text_sm, a.font_bold]}>
<Trans>Who can message you?</Trans>
</Text>
<Text
style={[
a.mt_xs,
a.text_sm,
a.italic,
t.atoms.text_contrast_medium,
]}>
<Trans>You can change this at any time.</Trans>
</Text>
</View>
<View style={[a.px_md, a.py_xs]}>
<Toggle.Group
label={_(msg`Who can message you?`)}
type="radio"
values={[chatDeclation?.allowIncoming ?? 'following']}
onChange={onSelectItem}>
<View>
<Toggle.Item
name="all"
label={_(msg`Everyone`)}
style={[a.justify_between, a.py_sm, a.rounded_2xs]}>
<Toggle.LabelText>
<Trans>Everyone</Trans>
</Toggle.LabelText>
<Toggle.Radio />
</Toggle.Item>
<Toggle.Item
name="following"
label={_(msg`Users I follow`)}
style={[a.justify_between, a.py_sm, a.rounded_2xs]}>
<Toggle.LabelText>
<Trans>Users I follow</Trans>
</Toggle.LabelText>
<Toggle.Radio />
</Toggle.Item>
<Toggle.Item
name="none"
label={_(msg`No one`)}
style={[a.justify_between, a.py_sm, a.rounded_2xs]}>
<Toggle.LabelText>
<Trans>No one</Trans>
</Toggle.LabelText>
<Toggle.Radio />
</Toggle.Item>
</View>
</Toggle.Group>
</View>
</View>
<Button
label={_(msg`Start chatting`)}
accessibilityHint={_(msg`Close modal`)}
size="large"
color="primary"
variant="solid"
onPress={() => control.close()}>
<ButtonText>
<Trans>Get started</Trans>
</ButtonText>
</Button>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}

Some files were not shown because too many files have changed in this diff Show More