Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f727791c5a | |||
| 6e72fbbefc | |||
| ff70b8ff61 | |||
| f050c51f11 | |||
| 140f720fac | |||
| 686dfa5f78 | |||
| 0ebf636652 | |||
| b52f8ced56 | |||
| 25e3c93e1e | |||
| 1118fd6883 | |||
| 76d63f9967 | |||
| 6dfd57e621 | |||
| 795e2c0629 | |||
| 5f3c4c60db | |||
| 3fb14d184a | |||
| 0d99b8b055 | |||
| eb5a6c8911 | |||
| b0fe2c6c9a | |||
| 1cedd4a3c4 | |||
| d17da8474b | |||
| 00486e9499 | |||
| 9802ebe20d | |||
| 09caf327d9 | |||
| 282db85c06 | |||
| 6382fe4535 | |||
| 54ca816536 | |||
| 48241cb2b6 | |||
| 3f66531e83 | |||
| ce53b3a264 | |||
| eb3b01d0ad | |||
| 7e79c7f768 | |||
| 523a9a48c2 | |||
| 2aa365b5b6 | |||
| 03704e2b48 | |||
| d2392d2d64 | |||
| c2dac855cc | |||
| 13c9c79aee | |||
| 405966830c | |||
| 3972d3d4c3 | |||
| a444fc53a0 | |||
| 1a7885b921 | |||
| 3569873123 | |||
| f251a43dd6 | |||
| 5a98ea684d | |||
| e33b2b1bee | |||
| d2fd5589dc | |||
| a7ee561e40 | |||
| b59d6dfbb5 | |||
| 2129f69fa0 | |||
| b4941d8556 | |||
| adaa79a461 | |||
| 26c09ff107 | |||
| 669cdf222c | |||
| 66f3068c58 | |||
| 9ca2928aac |
@@ -1,35 +0,0 @@
|
||||
---
|
||||
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. -->
|
||||
@@ -0,0 +1,64 @@
|
||||
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
|
||||
@@ -0,0 +1,69 @@
|
||||
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
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
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. -->
|
||||
@@ -0,0 +1,42 @@
|
||||
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
|
||||
@@ -15,3 +15,6 @@ android
|
||||
ios
|
||||
src/locale/locales
|
||||
lib/react-compiler-runtime
|
||||
bskyweb/static
|
||||
coverage
|
||||
web-build
|
||||
|
||||
+2
-18
@@ -234,24 +234,8 @@ module.exports = function (config) {
|
||||
'expo-font',
|
||||
{
|
||||
fonts: [
|
||||
// './assets/fonts/inter/Inter-Thin.otf',
|
||||
// './assets/fonts/inter/Inter-ThinItalic.otf',
|
||||
// './assets/fonts/inter/Inter-ExtraLight.otf',
|
||||
// './assets/fonts/inter/Inter-ExtraLightItalic.otf',
|
||||
// './assets/fonts/inter/Inter-Light.otf',
|
||||
// './assets/fonts/inter/Inter-LightItalic.otf',
|
||||
'./assets/fonts/inter/Inter-Regular.otf',
|
||||
'./assets/fonts/inter/Inter-Italic.otf',
|
||||
'./assets/fonts/inter/Inter-Medium.otf',
|
||||
'./assets/fonts/inter/Inter-MediumItalic.otf',
|
||||
'./assets/fonts/inter/Inter-SemiBold.otf',
|
||||
'./assets/fonts/inter/Inter-SemiBoldItalic.otf',
|
||||
'./assets/fonts/inter/Inter-Bold.otf',
|
||||
'./assets/fonts/inter/Inter-BoldItalic.otf',
|
||||
'./assets/fonts/inter/Inter-ExtraBold.otf',
|
||||
'./assets/fonts/inter/Inter-ExtraBoldItalic.otf',
|
||||
'./assets/fonts/inter/Inter-Black.otf',
|
||||
'./assets/fonts/inter/Inter-BlackItalic.otf',
|
||||
'./assets/fonts/inter/InterVariable.ttf',
|
||||
'./assets/fonts/inter/InterVariable-Italic.ttf',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -87,6 +87,13 @@ 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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -41,12 +41,13 @@ type Server struct {
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
debug bool
|
||||
httpAddress string
|
||||
appviewHost string
|
||||
ogcardHost string
|
||||
linkHost string
|
||||
ipccHost string
|
||||
debug bool
|
||||
httpAddress string
|
||||
appviewHost string
|
||||
ogcardHost string
|
||||
linkHost string
|
||||
ipccHost string
|
||||
staticCDNHost string
|
||||
}
|
||||
|
||||
func serve(cctx *cli.Context) error {
|
||||
@@ -58,6 +59,8 @@ 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()
|
||||
@@ -94,12 +97,13 @@ func serve(cctx *cli.Context) error {
|
||||
echo: e,
|
||||
xrpcc: xrpcc,
|
||||
cfg: &Config{
|
||||
debug: debug,
|
||||
httpAddress: httpAddress,
|
||||
appviewHost: appviewHost,
|
||||
ogcardHost: ogcardHost,
|
||||
linkHost: linkHost,
|
||||
ipccHost: ipccHost,
|
||||
debug: debug,
|
||||
httpAddress: httpAddress,
|
||||
appviewHost: appviewHost,
|
||||
ogcardHost: ogcardHost,
|
||||
linkHost: linkHost,
|
||||
ipccHost: ipccHost,
|
||||
staticCDNHost: staticCDNHost,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -204,14 +208,8 @@ func serve(cctx *cli.Context) error {
|
||||
path := c.Request().URL.Path
|
||||
maxAge := 1 * (60 * 60) // default is 1 hour
|
||||
|
||||
// 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") {
|
||||
// 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/") {
|
||||
maxAge = 365 * (60 * 60 * 24) // 1 year
|
||||
}
|
||||
|
||||
@@ -255,6 +253,7 @@ 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)
|
||||
|
||||
@@ -279,6 +278,7 @@ 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
|
||||
@@ -337,15 +337,21 @@ 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 := pongo2.Context{
|
||||
"statusCode": code,
|
||||
}
|
||||
data := srv.NewTemplateContext()
|
||||
data["statusCode"] = code
|
||||
c.Render(code, "error.html", data)
|
||||
}
|
||||
|
||||
@@ -389,18 +395,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 := pongo2.Context{}
|
||||
data := srv.NewTemplateContext()
|
||||
return c.Render(http.StatusOK, "base.html", data)
|
||||
}
|
||||
|
||||
func (srv *Server) WebHome(c echo.Context) error {
|
||||
data := pongo2.Context{}
|
||||
data := srv.NewTemplateContext()
|
||||
return c.Render(http.StatusOK, "home.html", data)
|
||||
}
|
||||
|
||||
func (srv *Server) WebPost(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
data := pongo2.Context{}
|
||||
data := srv.NewTemplateContext()
|
||||
|
||||
// sanity check arguments. don't 4xx, just let app handle if not expected format
|
||||
rkeyParam := c.Param("rkey")
|
||||
@@ -475,7 +481,7 @@ func (srv *Server) WebPost(c echo.Context) error {
|
||||
func (srv *Server) WebStarterPack(c echo.Context) error {
|
||||
req := c.Request()
|
||||
ctx := req.Context()
|
||||
data := pongo2.Context{}
|
||||
data := srv.NewTemplateContext()
|
||||
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")
|
||||
@@ -513,7 +519,7 @@ func (srv *Server) WebStarterPack(c echo.Context) error {
|
||||
|
||||
func (srv *Server) WebProfile(c echo.Context) error {
|
||||
ctx := c.Request().Context()
|
||||
data := pongo2.Context{}
|
||||
data := srv.NewTemplateContext()
|
||||
|
||||
// sanity check arguments. don't 4xx, just let app handle if not expected format
|
||||
handleOrDIDParam := c.Param("handleOrDID")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+21
-23
@@ -13,24 +13,8 @@
|
||||
|
||||
<!-- Hello Humans! API docs at https://atproto.com -->
|
||||
|
||||
<link rel="preload" as="font" type="font/otf" href="/static/media/Inter-Regular.1f5ed03b6dd9fd1f9982.otf">
|
||||
<link rel="preload" as="font" type="font/otf" href="/static/media/Inter-Italic.95778eb0c75dc956257e.otf">
|
||||
<!--
|
||||
<link rel="preload" as="font" type="font/otf" href="/static/media/Inter-Medium.296aa2d65964269836b3.otf">
|
||||
<link rel="preload" as="font" type="font/otf" href="/static/media/Inter-MediumItalic.0e57e17a6311368e2114.otf">
|
||||
-->
|
||||
<link rel="preload" as="font" type="font/otf" href="/static/media/Inter-SemiBold.2277990330981b8409bb.otf">
|
||||
<link rel="preload" as="font" type="font/otf" href="/static/media/Inter-SemiBoldItalic.f62fea3df3a521d6c8a7.otf">
|
||||
<!--
|
||||
<link rel="preload" as="font" type="font/otf" href="/static/media/Inter-Bold.8d330503e1d034ad68de.otf">
|
||||
<link rel="preload" as="font" type="font/otf" href="/static/media/Inter-BoldItalic.bb17e63f9baa0d861a20.otf">
|
||||
-->
|
||||
<link rel="preload" as="font" type="font/otf" href="/static/media/Inter-ExtraBold.ff2581a193bf6b7e0b06.otf">
|
||||
<link rel="preload" as="font" type="font/otf" href="/static/media/Inter-ExtraBoldItalic.0e50b40728d24d40fdf4.otf">
|
||||
<!--
|
||||
<link rel="preload" as="font" type="font/otf" href="/static/media/Inter-Black.66e9a87f1c921e844ed4.otf">
|
||||
<link rel="preload" as="font" type="font/otf" href="/static/media/Inter-BlackItalic.27b9f0ad06fd13a7b9da.otf">
|
||||
-->
|
||||
<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">
|
||||
|
||||
<style>
|
||||
/**
|
||||
@@ -40,6 +24,20 @@
|
||||
*
|
||||
* 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;
|
||||
@@ -53,7 +51,7 @@
|
||||
body {
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
font-family: 'Inter-Regular', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Liberation Sans', Helvetica, Arial, sans-serif;
|
||||
font-family: InterVariable, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Liberation Sans', Helvetica, Arial, sans-serif;
|
||||
text-rendering: optimizeLegibility;
|
||||
/* Platform-specific reset */
|
||||
-webkit-overflow-scrolling: touch;
|
||||
@@ -80,10 +78,10 @@
|
||||
</style>
|
||||
|
||||
{% include "scripts.html" %}
|
||||
<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">
|
||||
<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">
|
||||
<meta name="theme-color">
|
||||
<meta name="application-name" content="Bluesky">
|
||||
<meta name="generator" content="bskyweb">
|
||||
|
||||
@@ -96,6 +96,11 @@ jest.mock('expo-modules-core', () => ({
|
||||
getIsReducedMotionEnabled: () => false,
|
||||
}
|
||||
}
|
||||
if (moduleName === 'BottomSheet') {
|
||||
return {
|
||||
dismissAll: () => {},
|
||||
}
|
||||
}
|
||||
}),
|
||||
requireNativeViewManager: jest.fn().mockImplementation(moduleName => {
|
||||
return () => null
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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:+"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<manifest>
|
||||
</manifest>
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+339
@@ -0,0 +1,339 @@
|
||||
package expo.modules.bottomsheet
|
||||
|
||||
import android.content.Context
|
||||
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
|
||||
import java.util.ArrayList
|
||||
|
||||
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 {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
var maxHeight = this.screenHeight
|
||||
set(value) {
|
||||
field =
|
||||
if (value > this.screenHeight) {
|
||||
this.screenHeight.toFloat()
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
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.setOnShowListener {
|
||||
val bottomSheet = dialog.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
|
||||
bottomSheet?.let {
|
||||
// Let the outside view handle the background color on its own, the default for this is
|
||||
// white and we don't want that.
|
||||
it.setBackgroundColor(0)
|
||||
|
||||
val behavior = BottomSheetBehavior.from(it)
|
||||
|
||||
behavior.isFitToContents = true
|
||||
behavior.halfExpandedRatio = this.clampRatio(this.getTargetHeight() / this.screenHeight)
|
||||
if (contentHeight > this.screenHeight) {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
this.selectedSnapPoint = 2
|
||||
} else {
|
||||
behavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
|
||||
this.selectedSnapPoint = 1
|
||||
}
|
||||
behavior.skipCollapsed = true
|
||||
behavior.isDraggable = true
|
||||
behavior.isHideable = true
|
||||
|
||||
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,
|
||||
) { }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
dialog.setOnDismissListener {
|
||||
this.isClosing = true
|
||||
this.destroy()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"platforms": ["ios", "android"],
|
||||
"ios": {
|
||||
"modules": ["BottomSheetModule"]
|
||||
},
|
||||
"android": {
|
||||
"modules": ["expo.modules.bottomsheet.BottomSheetModule"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import {BottomSheet} from './src/BottomSheet'
|
||||
import {
|
||||
BottomSheetSnapPoint,
|
||||
BottomSheetState,
|
||||
BottomSheetViewProps,
|
||||
} from './src/BottomSheet.types'
|
||||
|
||||
export {
|
||||
BottomSheet,
|
||||
BottomSheetSnapPoint,
|
||||
type BottomSheetState,
|
||||
type BottomSheetViewProps,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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
|
||||
@@ -0,0 +1,47 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//
|
||||
// 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 contentHeight > screenHeight - 100 {
|
||||
sheet.detents = [
|
||||
.large()
|
||||
]
|
||||
sheet.selectedDetentIdentifier = .large
|
||||
} else {
|
||||
if #available(iOS 16.0, *) {
|
||||
sheet.detents = [
|
||||
.custom { _ in
|
||||
return contentHeight
|
||||
}
|
||||
]
|
||||
} 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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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'
|
||||
|
||||
const screenHeight = Dimensions.get('screen').height
|
||||
|
||||
const NativeView: React.ComponentType<
|
||||
BottomSheetViewProps & {
|
||||
ref: React.RefObject<any>
|
||||
style: StyleProp<ViewStyle>
|
||||
}
|
||||
> = requireNativeViewManager('BottomSheet')
|
||||
|
||||
const NativeModule = requireNativeModule('BottomSheet')
|
||||
|
||||
export class BottomSheet extends React.Component<
|
||||
BottomSheetViewProps,
|
||||
{
|
||||
open: boolean
|
||||
}
|
||||
> {
|
||||
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
|
||||
|
||||
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,
|
||||
},
|
||||
]}>
|
||||
<View onLayout={this.updateLayout}>{children}</View>
|
||||
</View>
|
||||
</NativeView>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {BottomSheetViewProps} from './BottomSheet.types'
|
||||
|
||||
export function BottomSheet(_: BottomSheetViewProps) {
|
||||
throw new Error('BottomSheetView is not available on web')
|
||||
}
|
||||
+4
-5
@@ -53,7 +53,7 @@
|
||||
"icons:optimize": "svgo -f ./assets/icons"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.13.8",
|
||||
"@atproto/api": "^0.13.11",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
@@ -81,7 +81,7 @@
|
||||
"@react-navigation/drawer": "^6.6.15",
|
||||
"@react-navigation/native": "^6.1.17",
|
||||
"@react-navigation/native-stack": "^6.9.26",
|
||||
"@sentry/react-native": "5.32.0",
|
||||
"@sentry/react-native": "5.24.3",
|
||||
"@tamagui/focus-scope": "^1.84.1",
|
||||
"@tanstack/query-async-storage-persister": "^5.25.0",
|
||||
"@tanstack/react-query": "^5.8.1",
|
||||
@@ -137,7 +137,6 @@
|
||||
"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",
|
||||
@@ -171,11 +170,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.16.2",
|
||||
"react-native-gesture-handler": "2.20.0",
|
||||
"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.12.1",
|
||||
"react-native-keyboard-controller": "^1.14.0",
|
||||
"react-native-mmkv": "^2.12.2",
|
||||
"react-native-pager-view": "6.2.3",
|
||||
"react-native-picker-select": "^9.1.3",
|
||||
|
||||
@@ -38,37 +38,65 @@ 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..1ecdf0a 100644
|
||||
index e9b330f..ec5f58c 100644
|
||||
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
|
||||
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
|
||||
@@ -16,4 +16,6 @@
|
||||
@@ -15,5 +15,8 @@
|
||||
@property (nonatomic, copy) NSString *title;
|
||||
@property (nonatomic, copy) RCTDirectEventBlock onRefresh;
|
||||
@property (nonatomic, weak) UIScrollView *scrollView;
|
||||
|
||||
+- (void)forwarderBeginRefreshing;
|
||||
+@property (nonatomic, copy) UIColor *customTintColor;
|
||||
+
|
||||
+- (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..f93cb46 100644
|
||||
index b09e653..288e60c 100644
|
||||
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
|
||||
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
|
||||
@@ -198,9 +198,53 @@ - (void)refreshControlValueChanged
|
||||
[self setCurrentRefreshingState:super.refreshing];
|
||||
_refreshingProgrammatically = NO;
|
||||
@@ -22,6 +22,7 @@ @implementation RCTRefreshControl {
|
||||
NSString *_title;
|
||||
UIColor *_titleColor;
|
||||
CGFloat _progressViewOffset;
|
||||
+ UIColor *_customTintColor;
|
||||
}
|
||||
|
||||
+ if (@available(iOS 17.4, *)) {
|
||||
+ if (_currentRefreshingState) {
|
||||
+ UIImpactFeedbackGenerator *feedbackGenerator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
|
||||
+ [feedbackGenerator prepare];
|
||||
+ [feedbackGenerator impactOccurred];
|
||||
+ }
|
||||
+ }
|
||||
- (instancetype)init
|
||||
@@ -56,6 +57,12 @@ - (void)layoutSubviews
|
||||
_isInitialRender = false;
|
||||
}
|
||||
|
||||
+- (void)didMoveToSuperview
|
||||
+{
|
||||
+ [super didMoveToSuperview];
|
||||
+ [self setTintColor:_customTintColor];
|
||||
+}
|
||||
+
|
||||
if (_onRefresh) {
|
||||
_onRefresh(nil);
|
||||
- (void)beginRefreshingProgrammatically
|
||||
{
|
||||
UInt64 beginRefreshingTimestamp = _currentRefreshingStateTimestamp;
|
||||
@@ -203,4 +210,58 @@ - (void)refreshControlValueChanged
|
||||
}
|
||||
}
|
||||
|
||||
+- (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
|
||||
@@ -106,6 +134,24 @@ index b09e653..f93cb46 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
|
||||
|
||||
@@ -23,10 +23,10 @@ const outputFile = entrypoints
|
||||
const ext = path.extname(file)
|
||||
|
||||
if (ext === '.js') {
|
||||
return `<script defer="defer" src="/static/js/${file}"></script>`
|
||||
return `<script defer="defer" src="{{ staticCDNHost }}/static/js/${file}"></script>`
|
||||
}
|
||||
if (ext === '.css') {
|
||||
return `<link rel="stylesheet" href="/static/css/${file}">`
|
||||
return `<link rel="stylesheet" href="{{ staticCDNHost }}/static/css/${file}">`
|
||||
}
|
||||
|
||||
return ''
|
||||
|
||||
+2
-2
@@ -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'
|
||||
|
||||
+1
-1
@@ -78,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'
|
||||
|
||||
+31
-3
@@ -276,13 +276,13 @@ export const atoms = {
|
||||
letterSpacing: tokens.TRACKING,
|
||||
},
|
||||
font_normal: {
|
||||
fontWeight: tokens.fontWeight.regular,
|
||||
fontWeight: tokens.fontWeight.normal,
|
||||
},
|
||||
font_bold: {
|
||||
fontWeight: tokens.fontWeight.semibold,
|
||||
fontWeight: tokens.fontWeight.bold,
|
||||
},
|
||||
font_heavy: {
|
||||
fontWeight: tokens.fontWeight.extrabold,
|
||||
fontWeight: tokens.fontWeight.heavy,
|
||||
},
|
||||
italic: {
|
||||
fontStyle: 'italic',
|
||||
@@ -901,4 +901,32 @@ 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
-23
@@ -1,3 +1,5 @@
|
||||
import {useFonts} from 'expo-font'
|
||||
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {Device, device} from '#/storage'
|
||||
|
||||
@@ -40,31 +42,10 @@ export function applyFonts(
|
||||
fontFamily: 'system' | 'theme',
|
||||
) {
|
||||
if (fontFamily === 'theme') {
|
||||
style.fontFamily =
|
||||
{
|
||||
// '100': 'Inter-Thin',
|
||||
// '200': 'Inter-ExtraLight',
|
||||
// '300': 'Inter-Light',
|
||||
// '500': 'Inter-Medium',
|
||||
// '700': 'Inter-Bold',
|
||||
// '900': 'Inter-Black',
|
||||
'100': 'Inter-Regular',
|
||||
'200': 'Inter-Regular',
|
||||
'300': 'Inter-Regular',
|
||||
'400': 'Inter-Regular',
|
||||
'500': 'Inter-SemiBold',
|
||||
'600': 'Inter-SemiBold',
|
||||
'700': 'Inter-SemiBold',
|
||||
'800': 'Inter-ExtraBold',
|
||||
'900': 'Inter-ExtraBold',
|
||||
}[style.fontWeight as string] || 'Inter-Regular'
|
||||
style.fontFamily = 'InterVariable'
|
||||
|
||||
if (style.fontStyle === 'italic') {
|
||||
if (style.fontFamily === 'Inter-Regular') {
|
||||
style.fontFamily = 'Inter-Italic'
|
||||
} else {
|
||||
style.fontFamily += 'Italic'
|
||||
}
|
||||
style.fontFamily += 'Italic'
|
||||
}
|
||||
|
||||
// fallback families only supported on web
|
||||
@@ -84,3 +65,17 @@ export function applyFonts(
|
||||
*/
|
||||
style.fontVariant = ['no-contextual']
|
||||
}
|
||||
|
||||
/*
|
||||
* 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'),
|
||||
})
|
||||
}
|
||||
|
||||
+5
-8
@@ -1,6 +1,6 @@
|
||||
import {Platform} from 'react-native'
|
||||
import {isAndroid} from '#/platform/detection'
|
||||
|
||||
export const TRACKING = Platform.OS === 'android' ? 0.1 : 0
|
||||
export const TRACKING = isAndroid ? 0.1 : 0
|
||||
|
||||
export const color = {
|
||||
temp_purple: 'rgb(105 0 255)',
|
||||
@@ -51,12 +51,9 @@ export const borderRadius = {
|
||||
* These correspond to Inter font files we actually load.
|
||||
*/
|
||||
export const fontWeight = {
|
||||
regular: '400',
|
||||
// medium: '500',
|
||||
semibold: '600',
|
||||
// bold: '700',
|
||||
extrabold: '800',
|
||||
// black: '900',
|
||||
normal: '400',
|
||||
bold: isAndroid ? '700' : '600',
|
||||
heavy: isAndroid ? '900' : '800',
|
||||
} as const
|
||||
|
||||
export const gradients = {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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'
|
||||
|
||||
@@ -12,7 +11,6 @@ export function useColorModeTheme(): ThemeName {
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
updateDocument(theme)
|
||||
SystemUI.setBackgroundColorAsync(getBackgroundColor(theme))
|
||||
}, [theme])
|
||||
|
||||
return theme
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from 'react-native'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
|
||||
import {atoms as a, flatten, select, tokens, useTheme, web} from '#/alf'
|
||||
import {atoms as a, flatten, select, tokens, useTheme} from '#/alf'
|
||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
@@ -87,6 +87,7 @@ 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}
|
||||
@@ -114,6 +115,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
||||
disabled = false,
|
||||
style,
|
||||
hoverStyle: hoverStyleProp,
|
||||
PressableComponent = Pressable,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
@@ -352,7 +354,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
||||
})
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push({
|
||||
paddingVertical: 8,
|
||||
paddingVertical: 9,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 6,
|
||||
gap: 6,
|
||||
@@ -374,7 +376,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
||||
}
|
||||
} else if (size === 'small') {
|
||||
if (shape === 'round') {
|
||||
baseStyles.push({height: 36, width: 36})
|
||||
baseStyles.push({height: 34, width: 34})
|
||||
} else {
|
||||
baseStyles.push({height: 34, width: 34})
|
||||
}
|
||||
@@ -449,10 +451,11 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
||||
const flattenedBaseStyles = flatten([baseStyles, style])
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
<PressableComponent
|
||||
role="button"
|
||||
accessibilityHint={undefined} // optional
|
||||
{...rest}
|
||||
// @ts-ignore - this will always be a pressable
|
||||
ref={ref}
|
||||
aria-label={label}
|
||||
aria-pressed={state.pressed}
|
||||
@@ -500,7 +503,7 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
||||
<Context.Provider value={context}>
|
||||
{typeof children === 'function' ? children(context) : children}
|
||||
</Context.Provider>
|
||||
</Pressable>
|
||||
</PressableComponent>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -627,9 +630,9 @@ export function useSharedButtonTextStyles() {
|
||||
}
|
||||
|
||||
if (size === 'large') {
|
||||
baseStyles.push(a.text_md, a.leading_tight, web({top: -0.4}))
|
||||
baseStyles.push(a.text_md, a.leading_tight)
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push(a.text_sm, a.leading_tight, web({top: -0.4}))
|
||||
baseStyles.push(a.text_sm, a.leading_tight)
|
||||
} else if (size === 'tiny') {
|
||||
baseStyles.push(a.text_xs, a.leading_tight)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,14 @@ 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() {
|
||||
|
||||
+173
-207
@@ -1,86 +1,48 @@
|
||||
import React, {useImperativeHandle} from 'react'
|
||||
import {
|
||||
Dimensions,
|
||||
Keyboard,
|
||||
NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleProp,
|
||||
TextInput,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import Animated, {useAnimatedStyle} from 'react-native-reanimated'
|
||||
import {
|
||||
KeyboardAwareScrollView,
|
||||
useKeyboardHandler,
|
||||
} from 'react-native-keyboard-controller'
|
||||
import {runOnJS} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
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 {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {isAndroid, isIOS} from '#/platform/detection'
|
||||
import {useA11y} from '#/state/a11y'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
import {atoms as a, flatten, useTheme} from '#/alf'
|
||||
import {Context} from '#/components/Dialog/context'
|
||||
import {List, ListMethods, ListProps} from '#/view/com/util/List'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Context, useDialogContext} from '#/components/Dialog/context'
|
||||
import {
|
||||
DialogControlProps,
|
||||
DialogInnerProps,
|
||||
DialogOuterProps,
|
||||
} from '#/components/Dialog/types'
|
||||
import {createInput} from '#/components/forms/TextField'
|
||||
import {FullWindowOverlay} from '#/components/FullWindowOverlay'
|
||||
import {Portal} from '#/components/Portal'
|
||||
import {Portal as DefaultPortal} from '#/components/Portal'
|
||||
import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet'
|
||||
import {
|
||||
BottomSheetSnapPointChangeEvent,
|
||||
BottomSheetStateChangeEvent,
|
||||
} from '../../../modules/bottom-sheet/src/BottomSheet.types'
|
||||
|
||||
export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
|
||||
export * from '#/components/Dialog/types'
|
||||
export * from '#/components/Dialog/utils'
|
||||
// @ts-ignore
|
||||
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 const Input = createInput(TextInput)
|
||||
|
||||
export function Outer({
|
||||
children,
|
||||
@@ -88,24 +50,22 @@ export function Outer({
|
||||
onClose,
|
||||
nativeOptions,
|
||||
testID,
|
||||
Portal = DefaultPortal,
|
||||
}: React.PropsWithChildren<DialogOuterProps>) {
|
||||
const t = useTheme()
|
||||
const sheet = React.useRef<BottomSheet>(null)
|
||||
const sheetOptions = nativeOptions?.sheet || {}
|
||||
const hasSnapPoints = !!sheetOptions.snapPoints
|
||||
const insets = useSafeAreaInsets()
|
||||
const ref = React.useRef<BottomSheet>(null)
|
||||
const closeCallbacks = React.useRef<(() => void)[]>([])
|
||||
const {setDialogIsOpen} = useDialogStateControlContext()
|
||||
const {setDialogIsOpen, setFullyExpandedCount} =
|
||||
useDialogStateControlContext()
|
||||
|
||||
/*
|
||||
* Used to manage open/closed, but index is otherwise handled internally by `BottomSheet`
|
||||
*/
|
||||
const [openIndex, setOpenIndex] = React.useState(-1)
|
||||
const prevSnapPoint = React.useRef<BottomSheetSnapPoint>(
|
||||
BottomSheetSnapPoint.Hidden,
|
||||
)
|
||||
|
||||
/*
|
||||
* `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 [disableDrag, setDisableDrag] = React.useState(false)
|
||||
const [snapPoint, setSnapPoint] = React.useState<BottomSheetSnapPoint>(
|
||||
BottomSheetSnapPoint.Partial,
|
||||
)
|
||||
|
||||
const callQueuedCallbacks = React.useCallback(() => {
|
||||
for (const cb of closeCallbacks.current) {
|
||||
@@ -119,25 +79,19 @@ export function Outer({
|
||||
closeCallbacks.current = []
|
||||
}, [])
|
||||
|
||||
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],
|
||||
)
|
||||
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])
|
||||
|
||||
// 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)
|
||||
}
|
||||
sheet.current?.close()
|
||||
ref.current?.dismiss()
|
||||
}, [])
|
||||
|
||||
// This is the actual thing we are doing once we "confirm" the dialog. We want the dialog's close animation to
|
||||
@@ -146,12 +100,39 @@ 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,
|
||||
() => ({
|
||||
@@ -161,159 +142,144 @@ export function Outer({
|
||||
[open, close],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
setDialogIsOpen(control.id, false)
|
||||
}
|
||||
}, [control.id, setDialogIsOpen])
|
||||
|
||||
const context = React.useMemo(() => ({close}), [close])
|
||||
const context = React.useMemo(
|
||||
() => ({
|
||||
close,
|
||||
isNativeDialog: true,
|
||||
nativeSnapPoint: snapPoint,
|
||||
disableDrag,
|
||||
setDisableDrag,
|
||||
}),
|
||||
[close, snapPoint, disableDrag, setDisableDrag],
|
||||
)
|
||||
|
||||
return (
|
||||
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>
|
||||
)
|
||||
<Portal>
|
||||
<Context.Provider value={context}>
|
||||
<BottomSheet
|
||||
ref={ref}
|
||||
cornerRadius={20}
|
||||
backgroundColor={t.atoms.bg.backgroundColor}
|
||||
{...nativeOptions}
|
||||
onSnapPointChange={onSnapPointChange}
|
||||
onStateChange={onStateChange}
|
||||
disableDrag={disableDrag}>
|
||||
<View testID={testID}>{children}</View>
|
||||
</BottomSheet>
|
||||
</Context.Provider>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export function Inner({children, style}: DialogInnerProps) {
|
||||
const insets = useSafeAreaInsets()
|
||||
return (
|
||||
<BottomSheetView
|
||||
<View
|
||||
style={[
|
||||
a.py_xl,
|
||||
a.pt_2xl,
|
||||
a.px_xl,
|
||||
{
|
||||
paddingTop: 40,
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
paddingBottom: insets.bottom + a.pb_5xl.paddingBottom,
|
||||
paddingBottom: insets.bottom + insets.top,
|
||||
},
|
||||
flatten(style),
|
||||
style,
|
||||
]}>
|
||||
{children}
|
||||
</BottomSheetView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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 ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||
function ScrollableInner({children, style, ...props}, ref) {
|
||||
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
|
||||
const insets = useSafeAreaInsets()
|
||||
const [keyboardHeight, setKeyboardHeight] = React.useState(0)
|
||||
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>) => {
|
||||
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}]}
|
||||
ref={ref}
|
||||
{...props}
|
||||
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
|
||||
bottomOffset={30}
|
||||
scrollEventThrottle={50}
|
||||
onScroll={isAndroid ? onScroll : undefined}>
|
||||
{children}
|
||||
</KeyboardAwareScrollView>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export const InnerFlatList = React.forwardRef<
|
||||
BottomSheetFlatListMethods,
|
||||
BottomSheetFlatListProps<any> & {webInnerStyle?: StyleProp<ViewStyle>}
|
||||
>(function InnerFlatList({style, contentContainerStyle, ...props}, ref) {
|
||||
ListMethods,
|
||||
ListProps<any> & {webInnerStyle?: StyleProp<ViewStyle>}
|
||||
>(function InnerFlatList({style, ...props}, ref) {
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
const {nativeSnapPoint} = useDialogContext()
|
||||
return (
|
||||
<BottomSheetFlatList
|
||||
<List
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerStyle={[a.pb_4xl, flatten(contentContainerStyle)]}
|
||||
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
|
||||
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),
|
||||
]}
|
||||
style={[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: 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 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -103,6 +103,10 @@ export function Outer({
|
||||
const context = React.useMemo(
|
||||
() => ({
|
||||
close,
|
||||
isNativeDialog: false,
|
||||
nativeSnapPoint: 0,
|
||||
disableDrag: false,
|
||||
setDisableDrag: () => {},
|
||||
}),
|
||||
[close],
|
||||
)
|
||||
@@ -229,10 +233,6 @@ export const InnerFlatList = React.forwardRef<
|
||||
)
|
||||
})
|
||||
|
||||
export function Handle() {
|
||||
return null
|
||||
}
|
||||
|
||||
export function Close() {
|
||||
const {_} = useLingui()
|
||||
const {close} = React.useContext(Context)
|
||||
@@ -258,3 +258,7 @@ export function Close() {
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Handle() {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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,9 +4,11 @@ import type {
|
||||
GestureResponderEvent,
|
||||
ScrollViewProps,
|
||||
} from 'react-native'
|
||||
import {BottomSheetProps} from '@discord/bottom-sheet/src'
|
||||
|
||||
import {ViewStyleProp} from '#/alf'
|
||||
import {PortalComponent} from '#/components/Portal'
|
||||
import {BottomSheetViewProps} from '../../../modules/bottom-sheet'
|
||||
import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types'
|
||||
|
||||
type A11yProps = Required<AccessibilityProps>
|
||||
|
||||
@@ -37,6 +39,10 @@ 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 = {
|
||||
@@ -52,11 +58,10 @@ export type DialogControlOpenOptions = {
|
||||
export type DialogOuterProps = {
|
||||
control: DialogControlProps
|
||||
onClose?: () => void
|
||||
nativeOptions?: {
|
||||
sheet?: Omit<BottomSheetProps, 'children'>
|
||||
}
|
||||
nativeOptions?: Omit<BottomSheetViewProps, 'children'>
|
||||
webOptions?: {}
|
||||
testID?: string
|
||||
Portal?: PortalComponent
|
||||
}
|
||||
|
||||
type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> & T
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
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} />
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export function KeyboardControllerPadding({
|
||||
maxHeight: _,
|
||||
}: {
|
||||
maxHeight?: number
|
||||
}) {
|
||||
return null
|
||||
}
|
||||
@@ -1,20 +1,19 @@
|
||||
import React, {useMemo, useCallback} from 'react'
|
||||
import React, {useCallback, useMemo} 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 {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
||||
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'
|
||||
|
||||
interface LikesDialogProps {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
@@ -25,7 +24,6 @@ export function LikesDialog(props: LikesDialogProps) {
|
||||
return (
|
||||
<Dialog.Outer control={props.control}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<LikesDialogInner {...props} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
|
||||
@@ -103,17 +103,17 @@ export function useLink({
|
||||
linkRequiresWarning(href, displayText),
|
||||
)
|
||||
|
||||
if (requiresWarning) {
|
||||
if (isWeb) {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
if (requiresWarning) {
|
||||
openModal({
|
||||
name: 'link-warning',
|
||||
text: displayText,
|
||||
href: href,
|
||||
})
|
||||
} else {
|
||||
e.preventDefault()
|
||||
|
||||
if (isExternal) {
|
||||
openLink(href)
|
||||
} else {
|
||||
|
||||
@@ -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,19 +82,21 @@ export function Outer({
|
||||
style?: StyleProp<ViewStyle>
|
||||
}>) {
|
||||
const context = React.useContext(Context)
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={context.control}>
|
||||
<Dialog.Outer
|
||||
control={context.control}
|
||||
nativeOptions={{preventExpansion: true}}>
|
||||
<Dialog.Handle />
|
||||
|
||||
{/* Re-wrap with context since Dialogs are portal-ed to root */}
|
||||
<Context.Provider value={context}>
|
||||
<Dialog.ScrollableInner label="Menu TODO">
|
||||
<Dialog.ScrollableInner label={_(msg`Menu`)} style={[a.pt_sm]}>
|
||||
<View style={[a.gap_lg]}>
|
||||
{children}
|
||||
{isNative && showCancel && <Cancel />}
|
||||
<View style={[{height: a.pb_lg.paddingBottom}]} />
|
||||
</View>
|
||||
<View style={{height: a.gap_lg.gap}} />
|
||||
</Dialog.ScrollableInner>
|
||||
</Context.Provider>
|
||||
</Dialog.Outer>
|
||||
@@ -116,15 +118,14 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
|
||||
{...rest}
|
||||
accessibilityHint=""
|
||||
accessibilityLabel={label}
|
||||
onPress={e => {
|
||||
onPress(e)
|
||||
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onPress={async e => {
|
||||
await onPress(e)
|
||||
if (!e.defaultPrevented) {
|
||||
control?.close()
|
||||
}
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onPressIn={e => {
|
||||
onPressIn()
|
||||
rest.onPressIn?.(e)
|
||||
|
||||
@@ -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 {HITSLOP_10} from 'lib/constants'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {useSession} from 'state/session'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
|
||||
@@ -12,6 +12,8 @@ type ComponentMap = {
|
||||
[id: string]: Component
|
||||
}
|
||||
|
||||
export type PortalComponent = ({children}: {children?: React.ReactNode}) => null
|
||||
|
||||
export function createPortalGroup() {
|
||||
const Context = React.createContext<ContextType>({
|
||||
outlet: null,
|
||||
|
||||
@@ -4,8 +4,9 @@ import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button'
|
||||
import {Button, ButtonColor, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {PortalComponent} from '#/components/Portal'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export {
|
||||
@@ -25,9 +26,11 @@ export function Outer({
|
||||
children,
|
||||
control,
|
||||
testID,
|
||||
Portal,
|
||||
}: React.PropsWithChildren<{
|
||||
control: Dialog.DialogControlProps
|
||||
testID?: string
|
||||
Portal?: PortalComponent
|
||||
}>) {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const titleId = React.useId()
|
||||
@@ -39,10 +42,9 @@ export function Outer({
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control} testID={testID}>
|
||||
<Dialog.Outer control={control} testID={testID} Portal={Portal}>
|
||||
<Dialog.Handle />
|
||||
<Context.Provider value={context}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<Dialog.ScrollableInner
|
||||
accessibilityLabelledBy={titleId}
|
||||
accessibilityDescribedBy={descriptionId}
|
||||
@@ -141,7 +143,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: ButtonProps['onPress']
|
||||
onPress: (e: GestureResponderEvent) => void
|
||||
color?: ButtonColor
|
||||
/**
|
||||
* Optional i18n string. If undefined, it will default to "Confirm".
|
||||
@@ -181,6 +183,7 @@ export function Basic({
|
||||
onConfirm,
|
||||
confirmButtonColor,
|
||||
showCancel = true,
|
||||
Portal,
|
||||
}: React.PropsWithChildren<{
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
title: string
|
||||
@@ -194,12 +197,13 @@ 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: ButtonProps['onPress']
|
||||
onConfirm: (e: GestureResponderEvent) => void
|
||||
confirmButtonColor?: ButtonColor
|
||||
showCancel?: boolean
|
||||
Portal?: PortalComponent
|
||||
}>) {
|
||||
return (
|
||||
<Outer control={control} testID="confirmModal">
|
||||
<Outer control={control} testID="confirmModal" Portal={Portal}>
|
||||
<TitleText>{title}</TitleText>
|
||||
<DescriptionText>{description}</DescriptionText>
|
||||
<Actions>
|
||||
|
||||
@@ -4,7 +4,6 @@ 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,6 +6,7 @@ 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'
|
||||
@@ -225,6 +226,8 @@ export function SubmitView({
|
||||
{submitting && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
</View>
|
||||
{/* Maybe fix this later -h */}
|
||||
{isAndroid ? <View style={{height: 300}} /> : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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'
|
||||
|
||||
@@ -8,12 +9,10 @@ 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'
|
||||
@@ -25,7 +24,6 @@ export function ReportDialog(props: ReportDialogProps) {
|
||||
return (
|
||||
<Dialog.Outer control={props.control}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<ReportDialogInner {...props} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
@@ -40,10 +38,7 @@ function ReportDialogInner(props: ReportDialogProps) {
|
||||
} = useMyLabelersQuery()
|
||||
const isLoading = useDelayedLoading(500, isLabelerLoading)
|
||||
|
||||
const ref = React.useRef<BottomSheetScrollViewMethods>(null)
|
||||
useOnKeyboardDidShow(() => {
|
||||
ref.current?.scrollToEnd({animated: true})
|
||||
})
|
||||
const ref = React.useRef<ScrollView>(null)
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner label={_(msg`Report dialog`)} ref={ref}>
|
||||
|
||||
@@ -149,7 +149,6 @@ 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]}>
|
||||
|
||||
@@ -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 {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 {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,6 +32,7 @@ interface Props {
|
||||
export function ShareDialog(props: Props) {
|
||||
return (
|
||||
<Dialog.Outer control={props.control}>
|
||||
<Dialog.Handle />
|
||||
<ShareDialogInner {...props} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
@@ -84,7 +85,6 @@ function ShareDialogInner({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog.Handle />
|
||||
<Dialog.ScrollableInner label={_(msg`Share link dialog`)}>
|
||||
{!imageLoaded || !link ? (
|
||||
<View style={[a.p_xl, a.align_center]}>
|
||||
|
||||
@@ -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 {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useSession} from '#/state/session'
|
||||
import {ListMethods} from '#/view/com/util/List'
|
||||
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<BottomSheetFlatListMethods>(null)
|
||||
const listRef = useRef<ListMethods>(null)
|
||||
|
||||
const getData = () => {
|
||||
if (state.currentStep === 'Feeds') return state.feeds
|
||||
@@ -76,10 +76,7 @@ export function WizardEditListDialog({
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
testID="newChatDialog"
|
||||
nativeOptions={{sheet: {snapPoints: ['95%']}}}>
|
||||
<Dialog.Outer control={control} testID="newChatDialog">
|
||||
<Dialog.Handle />
|
||||
<Dialog.InnerFlatList
|
||||
ref={listRef}
|
||||
@@ -89,6 +86,7 @@ export function WizardEditListDialog({
|
||||
ListHeaderComponent={
|
||||
<View
|
||||
style={[
|
||||
native(a.pt_4xl),
|
||||
a.flex_row,
|
||||
a.justify_between,
|
||||
a.border_b,
|
||||
@@ -103,13 +101,7 @@ export function WizardEditListDialog({
|
||||
height: 48,
|
||||
},
|
||||
]
|
||||
: [
|
||||
a.pb_sm,
|
||||
a.align_end,
|
||||
{
|
||||
height: 68,
|
||||
},
|
||||
],
|
||||
: [a.pb_sm, a.align_end],
|
||||
]}>
|
||||
<View style={{width: 60}} />
|
||||
<Text style={[a.font_bold, a.text_xl]}>
|
||||
@@ -143,8 +135,6 @@ export function WizardEditListDialog({
|
||||
paddingHorizontal: 0,
|
||||
marginTop: 0,
|
||||
paddingTop: 0,
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
}),
|
||||
]}
|
||||
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
|
||||
|
||||
@@ -85,7 +85,6 @@ 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]}>
|
||||
|
||||
@@ -53,11 +53,14 @@ export function childIsString(
|
||||
)
|
||||
}
|
||||
|
||||
export function renderChildrenWithEmoji(children: StringChild) {
|
||||
export function renderChildrenWithEmoji(
|
||||
children: StringChild,
|
||||
props: Omit<TextProps, 'children'> = {},
|
||||
) {
|
||||
const normalized = Array.isArray(children) ? children : [children]
|
||||
|
||||
return (
|
||||
<UITextView>
|
||||
<UITextView {...props}>
|
||||
{normalized.map(child => {
|
||||
if (typeof child !== 'string') return child
|
||||
|
||||
@@ -68,10 +71,12 @@ export function renderChildrenWithEmoji(children: StringChild) {
|
||||
}
|
||||
|
||||
return child.split(EMOJI).map((stringPart, index) => (
|
||||
<UITextView key={index}>
|
||||
<UITextView key={index} {...props}>
|
||||
{stringPart}
|
||||
{emojis[index] ? (
|
||||
<UITextView style={{color: 'black', fontFamily: 'System'}}>
|
||||
<UITextView
|
||||
{...props}
|
||||
style={[props?.style, {color: 'black', fontFamily: 'System'}]}>
|
||||
{emojis[index]}
|
||||
</UITextView>
|
||||
) : null}
|
||||
@@ -163,15 +168,17 @@ export function Text({
|
||||
}
|
||||
}
|
||||
|
||||
const shared = {
|
||||
uiTextView: true,
|
||||
selectable,
|
||||
style: s,
|
||||
dataSet: Object.assign({tooltip: title}, dataSet || {}),
|
||||
...rest,
|
||||
}
|
||||
|
||||
return (
|
||||
<UITextView
|
||||
selectable={selectable}
|
||||
uiTextView
|
||||
style={s}
|
||||
{...rest}
|
||||
// @ts-ignore
|
||||
dataSet={Object.assign({tooltip: title}, dataSet || {})}>
|
||||
{isIOS && emoji ? renderChildrenWithEmoji(children) : children}
|
||||
<UITextView {...shared}>
|
||||
{isIOS && emoji ? renderChildrenWithEmoji(children, shared) : children}
|
||||
</UITextView>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ 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]}>
|
||||
|
||||
@@ -50,7 +50,6 @@ 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]}>
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -6,10 +6,12 @@ import React, {
|
||||
useState,
|
||||
} from 'react'
|
||||
import {TextInput, View} from 'react-native'
|
||||
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
|
||||
import {useWindowDimensions} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
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 {
|
||||
@@ -19,7 +21,8 @@ import {
|
||||
} from '#/state/queries/tenor'
|
||||
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
|
||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {ListMethods} from '#/view/com/util/List'
|
||||
import {atoms as a, ios, native, useBreakpoints, useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
|
||||
@@ -27,16 +30,18 @@ import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arr
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {Button, ButtonIcon, ButtonText} from '../Button'
|
||||
import {ListFooter, ListMaybePlaceholder} from '../Lists'
|
||||
import {GifPreview} from './GifSelect.shared'
|
||||
import {PortalComponent} from '../Portal'
|
||||
|
||||
export function GifSelectDialog({
|
||||
controlRef,
|
||||
onClose,
|
||||
onSelectGif: onSelectGifProp,
|
||||
Portal,
|
||||
}: {
|
||||
controlRef: React.RefObject<{open: () => void}>
|
||||
onClose: () => void
|
||||
onSelectGif: (gif: Gif) => void
|
||||
Portal?: PortalComponent
|
||||
}) {
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
@@ -59,8 +64,13 @@ export function GifSelectDialog({
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
nativeOptions={{sheet: {snapPoints: ['100%']}}}
|
||||
onClose={onClose}>
|
||||
onClose={onClose}
|
||||
Portal={Portal}
|
||||
nativeOptions={{
|
||||
bottomInset: 0,
|
||||
// use system corner radius on iOS
|
||||
...ios({cornerRadius: undefined}),
|
||||
}}>
|
||||
<Dialog.Handle />
|
||||
<ErrorBoundary renderError={renderErrorBoundary}>
|
||||
<GifList control={control} onSelectGif={onSelectGif} />
|
||||
@@ -80,9 +90,10 @@ function GifList({
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const textInputRef = useRef<TextInput>(null)
|
||||
const listRef = useRef<BottomSheetFlatListMethods>(null)
|
||||
const listRef = useRef<ListMethods>(null)
|
||||
const [undeferredSearch, setSearch] = useState('')
|
||||
const search = useThrottledValue(undeferredSearch, 500)
|
||||
const {height} = useWindowDimensions()
|
||||
|
||||
const isSearching = search.length > 0
|
||||
|
||||
@@ -95,7 +106,7 @@ function GifList({
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
error,
|
||||
isLoading,
|
||||
isPending,
|
||||
isError,
|
||||
refetch,
|
||||
} = isSearching ? searchQuery : trendingQuery
|
||||
@@ -132,6 +143,7 @@ function GifList({
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
native(a.pt_4xl),
|
||||
a.relative,
|
||||
a.mb_lg,
|
||||
a.flex_row,
|
||||
@@ -196,13 +208,14 @@ function GifList({
|
||||
data={flattenedData}
|
||||
renderItem={renderItem}
|
||||
numColumns={gtMobile ? 3 : 2}
|
||||
columnWrapperStyle={a.gap_sm}
|
||||
columnWrapperStyle={[a.gap_sm]}
|
||||
contentContainerStyle={[native([a.px_xl, {minHeight: height}])]}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
{listHeader}
|
||||
{!hasData && (
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isLoading}
|
||||
isLoading={isPending}
|
||||
isError={isError}
|
||||
onRetry={refetch}
|
||||
onGoBack={onGoBack}
|
||||
@@ -273,3 +286,47 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,11 +30,14 @@ import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/P
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {createPortalGroup} from '#/components/Portal'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const ONE_DAY = 24 * 60 * 60 * 1000
|
||||
|
||||
const Portal = createPortalGroup()
|
||||
|
||||
export function MutedWordsDialog() {
|
||||
const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
|
||||
return (
|
||||
@@ -105,307 +108,349 @@ function MutedWordsInner() {
|
||||
}, [_, field, targets, addMutedWord, setField, durations, excludeFollowing])
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
|
||||
<View>
|
||||
<Text
|
||||
style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
|
||||
<Trans>Add muted words and tags</Trans>
|
||||
</Text>
|
||||
<Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
Posts can be muted based on their text, their tags, or both. We
|
||||
recommend avoiding common words that appear in many posts, since it
|
||||
can result in no posts being shown.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.pb_sm]}>
|
||||
<Dialog.Input
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
label={_(msg`Enter a word or tag`)}
|
||||
placeholder={_(msg`Enter a word or tag`)}
|
||||
value={field}
|
||||
onChangeText={value => {
|
||||
if (error) {
|
||||
setError('')
|
||||
}
|
||||
setField(value)
|
||||
}}
|
||||
onSubmitEditing={submit}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[a.pb_xl, a.gap_sm]}>
|
||||
<Toggle.Group
|
||||
label={_(msg`Select how long to mute this word for.`)}
|
||||
type="radio"
|
||||
values={durations}
|
||||
onChange={setDurations}>
|
||||
<Text
|
||||
style={[
|
||||
a.pb_xs,
|
||||
a.text_sm,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>Duration:</Trans>
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={[
|
||||
gtMobile && [a.flex_row, a.align_center, a.justify_start],
|
||||
a.gap_sm,
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.justify_start,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
]}>
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word until you unmute it`)}
|
||||
name="forever"
|
||||
style={[a.flex_1]}>
|
||||
<TargetToggle>
|
||||
<View
|
||||
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>Forever</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word for 24 hours`)}
|
||||
name="24_hours"
|
||||
style={[a.flex_1]}>
|
||||
<TargetToggle>
|
||||
<View
|
||||
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>24 hours</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.justify_start,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
]}>
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word for 7 days`)}
|
||||
name="7_days"
|
||||
style={[a.flex_1]}>
|
||||
<TargetToggle>
|
||||
<View
|
||||
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>7 days</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word for 30 days`)}
|
||||
name="30_days"
|
||||
style={[a.flex_1]}>
|
||||
<TargetToggle>
|
||||
<View
|
||||
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>30 days</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
</View>
|
||||
</Toggle.Group>
|
||||
|
||||
<Toggle.Group
|
||||
label={_(msg`Select what content this mute word should apply to.`)}
|
||||
type="radio"
|
||||
values={targets}
|
||||
onChange={setTargets}>
|
||||
<Text
|
||||
style={[
|
||||
a.pb_xs,
|
||||
a.text_sm,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>Mute in:</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm, a.flex_wrap]}>
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word in post text and tags`)}
|
||||
name="content"
|
||||
style={[a.flex_1]}>
|
||||
<TargetToggle>
|
||||
<View
|
||||
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>Text & tags</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
<PageText size="sm" />
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word in tags only`)}
|
||||
name="tag"
|
||||
style={[a.flex_1]}>
|
||||
<TargetToggle>
|
||||
<View
|
||||
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>Tags only</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
<Hashtag size="sm" />
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
</Toggle.Group>
|
||||
|
||||
<View>
|
||||
<Text
|
||||
style={[
|
||||
a.pb_xs,
|
||||
a.text_sm,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>Options:</Trans>
|
||||
</Text>
|
||||
<Toggle.Item
|
||||
label={_(msg`Do not apply this mute word to users you follow`)}
|
||||
name="exclude_following"
|
||||
style={[a.flex_row, a.justify_between]}
|
||||
value={excludeFollowing}
|
||||
onChange={setExcludeFollowing}>
|
||||
<TargetToggle>
|
||||
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Checkbox />
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>Exclude users you follow</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
|
||||
<View style={[a.pt_xs]}>
|
||||
<Button
|
||||
disabled={isPending || !field}
|
||||
label={_(msg`Add mute word for configured settings`)}
|
||||
size="large"
|
||||
color="primary"
|
||||
variant="solid"
|
||||
style={[]}
|
||||
onPress={submit}>
|
||||
<ButtonText>
|
||||
<Trans>Add</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={isPending ? Loader : Plus} position="right" />
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View
|
||||
style={[
|
||||
a.mb_lg,
|
||||
a.flex_row,
|
||||
a.rounded_sm,
|
||||
a.p_md,
|
||||
a.mb_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
backgroundColor: t.palette.negative_400,
|
||||
},
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.italic,
|
||||
{color: t.palette.white},
|
||||
native({marginTop: 2}),
|
||||
]}>
|
||||
{error}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Divider />
|
||||
|
||||
<View style={[a.pt_2xl]}>
|
||||
<Portal.Provider>
|
||||
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
|
||||
<View>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
a.pb_md,
|
||||
a.pb_sm,
|
||||
t.atoms.text_contrast_high,
|
||||
]}>
|
||||
<Trans>Your muted words</Trans>
|
||||
<Trans>Add muted words and tags</Trans>
|
||||
</Text>
|
||||
<Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
Posts can be muted based on their text, their tags, or both. We
|
||||
recommend avoiding common words that appear in many posts, since
|
||||
it can result in no posts being shown.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{isPreferencesLoading ? (
|
||||
<Loader />
|
||||
) : preferencesError || !preferences ? (
|
||||
<View
|
||||
style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
|
||||
<Text style={[a.italic, t.atoms.text_contrast_high]}>
|
||||
<Trans>
|
||||
We're sorry, but we weren't able to load your muted words at
|
||||
this time. Please try again.
|
||||
</Trans>
|
||||
<View style={[a.pb_sm]}>
|
||||
<Dialog.Input
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
label={_(msg`Enter a word or tag`)}
|
||||
placeholder={_(msg`Enter a word or tag`)}
|
||||
value={field}
|
||||
onChangeText={value => {
|
||||
if (error) {
|
||||
setError('')
|
||||
}
|
||||
setField(value)
|
||||
}}
|
||||
onSubmitEditing={submit}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[a.pb_xl, a.gap_sm]}>
|
||||
<Toggle.Group
|
||||
label={_(msg`Select how long to mute this word for.`)}
|
||||
type="radio"
|
||||
values={durations}
|
||||
onChange={setDurations}>
|
||||
<Text
|
||||
style={[
|
||||
a.pb_xs,
|
||||
a.text_sm,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>Duration:</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : preferences.moderationPrefs.mutedWords.length ? (
|
||||
[...preferences.moderationPrefs.mutedWords]
|
||||
.reverse()
|
||||
.map((word, i) => (
|
||||
<MutedWordRow
|
||||
key={word.value + i}
|
||||
word={word}
|
||||
style={[i % 2 === 0 && t.atoms.bg_contrast_25]}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<View
|
||||
style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
|
||||
<Text style={[a.italic, t.atoms.text_contrast_high]}>
|
||||
<Trans>You haven't muted any words or tags yet</Trans>
|
||||
|
||||
<View
|
||||
style={[
|
||||
gtMobile && [a.flex_row, a.align_center, a.justify_start],
|
||||
a.gap_sm,
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.justify_start,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
]}>
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word until you unmute it`)}
|
||||
name="forever"
|
||||
style={[a.flex_1]}>
|
||||
<TargetToggle>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>Forever</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word for 24 hours`)}
|
||||
name="24_hours"
|
||||
style={[a.flex_1]}>
|
||||
<TargetToggle>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>24 hours</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.justify_start,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
]}>
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word for 7 days`)}
|
||||
name="7_days"
|
||||
style={[a.flex_1]}>
|
||||
<TargetToggle>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>7 days</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word for 30 days`)}
|
||||
name="30_days"
|
||||
style={[a.flex_1]}>
|
||||
<TargetToggle>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>30 days</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
</View>
|
||||
</Toggle.Group>
|
||||
|
||||
<Toggle.Group
|
||||
label={_(
|
||||
msg`Select what content this mute word should apply to.`,
|
||||
)}
|
||||
type="radio"
|
||||
values={targets}
|
||||
onChange={setTargets}>
|
||||
<Text
|
||||
style={[
|
||||
a.pb_xs,
|
||||
a.text_sm,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>Mute in:</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm, a.flex_wrap]}>
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word in post text and tags`)}
|
||||
name="content"
|
||||
style={[a.flex_1]}>
|
||||
<TargetToggle>
|
||||
<View
|
||||
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>Text & tags</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
<PageText size="sm" />
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word in tags only`)}
|
||||
name="tag"
|
||||
style={[a.flex_1]}>
|
||||
<TargetToggle>
|
||||
<View
|
||||
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>Tags only</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
<Hashtag size="sm" />
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
</Toggle.Group>
|
||||
|
||||
<View>
|
||||
<Text
|
||||
style={[
|
||||
a.pb_xs,
|
||||
a.text_sm,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>Options:</Trans>
|
||||
</Text>
|
||||
<Toggle.Item
|
||||
label={_(msg`Do not apply this mute word to users you follow`)}
|
||||
name="exclude_following"
|
||||
style={[a.flex_row, a.justify_between]}
|
||||
value={excludeFollowing}
|
||||
onChange={setExcludeFollowing}>
|
||||
<TargetToggle>
|
||||
<View
|
||||
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Checkbox />
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>Exclude users you follow</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[a.pt_xs]}>
|
||||
<Button
|
||||
disabled={isPending || !field}
|
||||
label={_(msg`Add mute word for configured settings`)}
|
||||
size="large"
|
||||
color="primary"
|
||||
variant="solid"
|
||||
style={[]}
|
||||
onPress={submit}>
|
||||
<ButtonText>
|
||||
<Trans>Add</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={isPending ? Loader : Plus} position="right" />
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View
|
||||
style={[
|
||||
a.mb_lg,
|
||||
a.flex_row,
|
||||
a.rounded_sm,
|
||||
a.p_md,
|
||||
a.mb_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
backgroundColor: t.palette.negative_400,
|
||||
},
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.italic,
|
||||
{color: t.palette.white},
|
||||
native({marginTop: 2}),
|
||||
]}>
|
||||
{error}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Divider />
|
||||
|
||||
<View style={[a.pt_2xl]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
a.pb_md,
|
||||
t.atoms.text_contrast_high,
|
||||
]}>
|
||||
<Trans>Your muted words</Trans>
|
||||
</Text>
|
||||
|
||||
{isPreferencesLoading ? (
|
||||
<Loader />
|
||||
) : preferencesError || !preferences ? (
|
||||
<View
|
||||
style={[
|
||||
a.py_md,
|
||||
a.px_lg,
|
||||
a.rounded_md,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Text style={[a.italic, t.atoms.text_contrast_high]}>
|
||||
<Trans>
|
||||
We're sorry, but we weren't able to load your muted words at
|
||||
this time. Please try again.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : preferences.moderationPrefs.mutedWords.length ? (
|
||||
[...preferences.moderationPrefs.mutedWords]
|
||||
.reverse()
|
||||
.map((word, i) => (
|
||||
<MutedWordRow
|
||||
key={word.value + i}
|
||||
word={word}
|
||||
style={[i % 2 === 0 && t.atoms.bg_contrast_25]}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
a.py_md,
|
||||
a.px_lg,
|
||||
a.rounded_md,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Text style={[a.italic, t.atoms.text_contrast_high]}>
|
||||
<Trans>You haven't muted any words or tags yet</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{isNative && <View style={{height: 20}} />}
|
||||
</View>
|
||||
|
||||
{isNative && <View style={{height: 20}} />}
|
||||
</View>
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
<Portal.Outlet />
|
||||
</Portal.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -437,6 +482,7 @@ function MutedWordRow({
|
||||
onConfirm={remove}
|
||||
confirmButtonCta={_(msg`Remove`)}
|
||||
confirmButtonColor="negative"
|
||||
Portal={Portal.Portal}
|
||||
/>
|
||||
|
||||
<View
|
||||
|
||||
@@ -37,6 +37,7 @@ import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {PortalComponent} from '#/components/Portal'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export type PostInteractionSettingsFormProps = {
|
||||
@@ -54,13 +55,15 @@ export type PostInteractionSettingsFormProps = {
|
||||
|
||||
export function PostInteractionSettingsControlledDialog({
|
||||
control,
|
||||
Portal,
|
||||
...rest
|
||||
}: PostInteractionSettingsFormProps & {
|
||||
control: Dialog.DialogControlProps
|
||||
Portal?: PortalComponent
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Outer control={control} Portal={Portal}>
|
||||
<Dialog.Handle />
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Edit post interaction settings`)}
|
||||
@@ -231,7 +234,6 @@ export function PostInteractionSettingsForm({
|
||||
}: PostInteractionSettingsFormProps) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const control = Dialog.useDialogContext()
|
||||
const {data: lists} = useMyListsQuery('curate')
|
||||
const [quotesEnabled, setQuotesEnabled] = React.useState(
|
||||
!(
|
||||
@@ -437,7 +439,6 @@ export function PostInteractionSettingsForm({
|
||||
<Button
|
||||
label={_(msg`Save`)}
|
||||
onPress={onSave}
|
||||
onAccessibilityEscape={control.close}
|
||||
color="primary"
|
||||
size="large"
|
||||
variant="solid"
|
||||
|
||||
@@ -43,7 +43,6 @@ 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]}>
|
||||
|
||||
@@ -44,7 +44,6 @@ 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]}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
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}
|
||||
@@ -17,13 +17,15 @@ 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,
|
||||
@@ -33,14 +35,37 @@ let MessageItem = ({
|
||||
const t = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const {message, nextMessage} = item
|
||||
const {message, nextMessage, prevMessage} = item
|
||||
const isPending = item.type === 'pending-message'
|
||||
|
||||
const isFromSelf = message.sender?.did === currentAccount?.did
|
||||
|
||||
const nextIsMessage = ChatBskyConvoDefs.isMessageView(nextMessage)
|
||||
|
||||
const isNextFromSelf =
|
||||
ChatBskyConvoDefs.isMessageView(nextMessage) &&
|
||||
nextMessage.sender?.did === currentAccount?.did
|
||||
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
|
||||
|
||||
const isLastInGroup = useMemo(() => {
|
||||
// if this message is pending, it means the next message is pending too
|
||||
@@ -48,24 +73,19 @@ let MessageItem = ({
|
||||
return false
|
||||
}
|
||||
|
||||
// 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
|
||||
// or, if there's a 5 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()
|
||||
|
||||
// 3 minutes
|
||||
return diff > 3 * 60 * 1000
|
||||
// 5 minutes
|
||||
return diff > 5 * 60 * 1000
|
||||
}
|
||||
|
||||
return true
|
||||
}, [message, nextMessage, isFromSelf, isNextFromSelf, isPending])
|
||||
}, [message, nextMessage, isPending])
|
||||
|
||||
const lastInGroupRef = useRef(isLastInGroup)
|
||||
if (lastInGroupRef.current !== isLastInGroup) {
|
||||
@@ -80,52 +100,59 @@ let MessageItem = ({
|
||||
}, [message.text, message.facets])
|
||||
|
||||
return (
|
||||
<View style={[isFromSelf ? a.mr_md : a.ml_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: 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>
|
||||
<>
|
||||
{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>
|
||||
{isLastInGroup && (
|
||||
<MessageItemMetadata
|
||||
item={item}
|
||||
style={isFromSelf ? a.text_right : a.text_left}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
MessageItem = React.memo(MessageItem)
|
||||
@@ -165,31 +192,12 @@ let MessageItemMetadata = ({
|
||||
|
||||
const diff = now.getTime() - date.getTime()
|
||||
|
||||
// if under 1 minute
|
||||
if (diff < 1000 * 60) {
|
||||
// if under 30 seconds
|
||||
if (diff < 1000 * 30) {
|
||||
return _(msg`Now`)
|
||||
}
|
||||
|
||||
// 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',
|
||||
})
|
||||
return time
|
||||
},
|
||||
[_],
|
||||
)
|
||||
@@ -242,15 +250,5 @@ 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}`
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
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 {useOpenLink} from '#/state/preferences/in-app-browser'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {useConvoActive} from 'state/messages/convo'
|
||||
import {useSession} from 'state/session'
|
||||
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>
|
||||
|
||||
@@ -4,12 +4,13 @@ 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 {Message_Stroke2_Corner0_Rounded as Message} from '../icons/Message'
|
||||
import {Link} from '../Link'
|
||||
import {canBeMessaged} from './util'
|
||||
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'
|
||||
|
||||
export function MessageProfileButton({
|
||||
profile,
|
||||
@@ -40,15 +41,9 @@ export function MessageProfileButton({
|
||||
a.align_center,
|
||||
t.atoms.bg_contrast_25,
|
||||
a.rounded_full,
|
||||
{width: 36, height: 36},
|
||||
{width: 34, height: 34},
|
||||
]}>
|
||||
<Message
|
||||
style={[
|
||||
t.atoms.text,
|
||||
{marginLeft: 1, marginBottom: 1, opacity: 0.3},
|
||||
]}
|
||||
size="md"
|
||||
/>
|
||||
<Message style={[t.atoms.text, {opacity: 0.3}]} size="md" />
|
||||
</View>
|
||||
)
|
||||
} else {
|
||||
@@ -66,12 +61,9 @@ export function MessageProfileButton({
|
||||
shape="round"
|
||||
label={_(msg`Message ${profile.handle}`)}
|
||||
to={`/messages/${convo.id}`}
|
||||
style={[a.justify_center, {width: 36, height: 36}]}
|
||||
style={[a.justify_center]}
|
||||
onPress={onPress}>
|
||||
<Message
|
||||
style={[t.atoms.text, {marginLeft: 1, marginBottom: 1}]}
|
||||
size="md"
|
||||
/>
|
||||
<ButtonIcon icon={Message} size="md" />
|
||||
</Link>
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -10,13 +10,11 @@ import {useLingui} from '@lingui/react'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
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'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {KeyboardControllerPadding} from '#/components/KeyboardControllerPadding'
|
||||
import {Button, ButtonIcon, ButtonText} from '../Button'
|
||||
import {Divider} from '../Divider'
|
||||
import {ChevronLeft_Stroke2_Corner0_Rounded as Chevron} from '../icons/Chevron'
|
||||
@@ -41,14 +39,11 @@ let ReportDialog = ({
|
||||
}): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
nativeOptions={isAndroid ? {sheet: {snapPoints: ['100%']}} : {}}>
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<Dialog.ScrollableInner label={_(msg`Report this message`)}>
|
||||
<DialogInner params={params} />
|
||||
<Dialog.Close />
|
||||
<KeyboardControllerPadding />
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
@@ -277,6 +272,7 @@ function PreviewMessage({message}: {message: ChatBskyConvoDefs.MessageView}) {
|
||||
message,
|
||||
key: '',
|
||||
nextMessage: null,
|
||||
prevMessage: null,
|
||||
}}
|
||||
style={[a.text_left, a.mb_0]}
|
||||
/>
|
||||
|
||||
@@ -2,9 +2,9 @@ import React, {useCallback} from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
|
||||
import {logEvent} from 'lib/statsig/statsig'
|
||||
import {FAB} from '#/view/com/util/fab/FAB'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {useTheme} from '#/alf'
|
||||
@@ -55,10 +55,8 @@ export function NewChat({
|
||||
accessibilityHint=""
|
||||
/>
|
||||
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
testID="newChatDialog"
|
||||
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
|
||||
<Dialog.Outer control={control} testID="newChatDialog">
|
||||
<Dialog.Handle />
|
||||
<SearchablePeopleList
|
||||
title={_(msg`Start a new chat`)}
|
||||
onSelectChat={onCreateChat}
|
||||
|
||||
@@ -5,10 +5,8 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type {TextInput as TextInputType} from 'react-native'
|
||||
import {View} from 'react-native'
|
||||
import {TextInput, View} from 'react-native'
|
||||
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
|
||||
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -16,18 +14,17 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
||||
import {useListConvosQuery} from '#/state/queries/messages/list-converations'
|
||||
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
|
||||
import {ListMethods} from '#/view/com/util/List'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {TextInput} from '#/components/dms/dialogs/TextInput'
|
||||
import {canBeMessaged} from '#/components/dms/util'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {Text} from '#/components/Typography'
|
||||
@@ -66,9 +63,9 @@ export function SearchablePeopleList({
|
||||
const {_} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const control = Dialog.useDialogContext()
|
||||
const listRef = useRef<BottomSheetFlatListMethods>(null)
|
||||
const listRef = useRef<ListMethods>(null)
|
||||
const {currentAccount} = useSession()
|
||||
const inputRef = useRef<TextInputType>(null)
|
||||
const inputRef = useRef<TextInput>(null)
|
||||
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
@@ -101,15 +98,15 @@ export function SearchablePeopleList({
|
||||
})
|
||||
}
|
||||
|
||||
_items = _items.sort(a => {
|
||||
_items = _items.sort(item => {
|
||||
// @ts-ignore
|
||||
return a.enabled ? -1 : 1
|
||||
return item.enabled ? -1 : 1
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const placeholders: Item[] = Array(10)
|
||||
.fill(0)
|
||||
.map((_, i) => ({
|
||||
.map((__, i) => ({
|
||||
type: 'placeholder',
|
||||
key: i + '',
|
||||
}))
|
||||
@@ -155,9 +152,9 @@ export function SearchablePeopleList({
|
||||
}
|
||||
|
||||
// only sort follows
|
||||
followsItems = followsItems.sort(a => {
|
||||
followsItems = followsItems.sort(item => {
|
||||
// @ts-ignore
|
||||
return a.enabled ? -1 : 1
|
||||
return item.enabled ? -1 : 1
|
||||
})
|
||||
|
||||
// then append
|
||||
@@ -177,9 +174,9 @@ export function SearchablePeopleList({
|
||||
}
|
||||
}
|
||||
|
||||
_items = _items.sort(a => {
|
||||
_items = _items.sort(item => {
|
||||
// @ts-ignore
|
||||
return a.enabled ? -1 : 1
|
||||
return item.enabled ? -1 : 1
|
||||
})
|
||||
} else {
|
||||
_items.push(...placeholders)
|
||||
@@ -242,57 +239,46 @@ export function SearchablePeopleList({
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.pt_md,
|
||||
web(a.pt_lg),
|
||||
native(a.pt_4xl),
|
||||
a.pb_xs,
|
||||
a.px_lg,
|
||||
a.border_b,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg,
|
||||
native([a.pt_lg]),
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
native(a.align_center),
|
||||
a.justify_center,
|
||||
{height: 32},
|
||||
]}>
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
size="small"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
style={[
|
||||
a.absolute,
|
||||
a.z_20,
|
||||
native({
|
||||
left: -7,
|
||||
}),
|
||||
web({
|
||||
right: -4,
|
||||
}),
|
||||
]}
|
||||
onPress={() => control.close()}>
|
||||
{isWeb ? (
|
||||
<X size="md" fill={t.palette.contrast_500} />
|
||||
) : (
|
||||
<ChevronLeft size="md" fill={t.palette.contrast_500} />
|
||||
)}
|
||||
</Button>
|
||||
<View style={[a.relative, native(a.align_center), a.justify_center]}>
|
||||
<Text
|
||||
style={[
|
||||
a.z_10,
|
||||
a.text_lg,
|
||||
a.font_bold,
|
||||
a.font_heavy,
|
||||
a.leading_tight,
|
||||
t.atoms.text_contrast_high,
|
||||
]}>
|
||||
{title}
|
||||
</Text>
|
||||
{isWeb ? (
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
size="small"
|
||||
shape="round"
|
||||
variant={isWeb ? 'ghost' : 'solid'}
|
||||
color="secondary"
|
||||
style={[
|
||||
a.absolute,
|
||||
a.z_20,
|
||||
web({right: -4}),
|
||||
native({right: 0}),
|
||||
native({height: 32, width: 32, borderRadius: 16}),
|
||||
]}
|
||||
onPress={() => control.close()}>
|
||||
<ButtonIcon icon={X} size="md" />
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={[native([a.pt_sm]), web([a.pt_xs])]}>
|
||||
<View style={[, web([a.pt_xs])]}>
|
||||
<SearchInput
|
||||
inputRef={inputRef}
|
||||
value={searchText}
|
||||
@@ -309,7 +295,6 @@ export function SearchablePeopleList({
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg,
|
||||
t.atoms.text_contrast_high,
|
||||
t.palette.contrast_500,
|
||||
_,
|
||||
title,
|
||||
searchText,
|
||||
@@ -326,14 +311,7 @@ export function SearchablePeopleList({
|
||||
keyExtractor={(item: Item) => item.key}
|
||||
style={[
|
||||
web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
|
||||
native({
|
||||
height: '100%',
|
||||
paddingHorizontal: 0,
|
||||
marginTop: 0,
|
||||
paddingTop: 0,
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
}),
|
||||
native({height: '100%'}),
|
||||
]}
|
||||
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
|
||||
keyboardDismissMode="on-drag"
|
||||
@@ -396,7 +374,8 @@ function ProfileCard({
|
||||
<View style={[a.flex_1, a.gap_2xs]}>
|
||||
<Text
|
||||
style={[t.atoms.text, a.font_bold, a.leading_tight, a.self_start]}
|
||||
numberOfLines={1}>
|
||||
numberOfLines={1}
|
||||
emoji>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text
|
||||
@@ -474,7 +453,7 @@ function SearchInput({
|
||||
value: string
|
||||
onChangeText: (text: string) => void
|
||||
onEscape: () => void
|
||||
inputRef: React.RefObject<TextInputType>
|
||||
inputRef: React.RefObject<TextInput>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
@@ -2,9 +2,9 @@ import React, {useCallback} from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
|
||||
import {logEvent} from 'lib/statsig/statsig'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {SearchablePeopleList} from './SearchablePeopleList'
|
||||
@@ -17,10 +17,8 @@ export function SendViaChatDialog({
|
||||
onSelectChat: (chatId: string) => void
|
||||
}) {
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
testID="sendViaChatChatDialog"
|
||||
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
|
||||
<Dialog.Outer control={control} testID="sendViaChatChatDialog">
|
||||
<Dialog.Handle />
|
||||
<SendViaChatDialogInner control={control} onSelectChat={onSelectChat} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
|
||||
@@ -16,3 +16,12 @@ export function canBeMessaged(profile: AppBskyActorDefs.ProfileView) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export 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}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from 'react'
|
||||
import {TextInput, View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as MagnifyingGlassIcon} from '#/components/icons/MagnifyingGlass2'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
|
||||
type SearchInputProps = Omit<TextField.InputProps, 'label'> & {
|
||||
label?: TextField.InputProps['label']
|
||||
/**
|
||||
* Called when the user presses the (X) button
|
||||
*/
|
||||
onClearText?: () => void
|
||||
}
|
||||
|
||||
export const SearchInput = React.forwardRef<TextInput, SearchInputProps>(
|
||||
function SearchInput({value, label, onClearText, ...rest}, ref) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<View style={[a.w_full, a.relative]}>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={MagnifyingGlassIcon} />
|
||||
<TextField.Input
|
||||
inputRef={ref}
|
||||
label={label || _(msg`Search`)}
|
||||
value={value}
|
||||
placeholder={_(msg`Search`)}
|
||||
returnKeyType="search"
|
||||
keyboardAppearance={t.scheme}
|
||||
selectTextOnFocus={isNative}
|
||||
autoFocus={false}
|
||||
accessibilityRole="search"
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
{...rest}
|
||||
/>
|
||||
</TextField.Root>
|
||||
|
||||
{value && value.length > 0 && (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.z_10,
|
||||
a.my_auto,
|
||||
a.inset_0,
|
||||
a.justify_center,
|
||||
a.pr_sm,
|
||||
{left: 'auto'},
|
||||
]}>
|
||||
<Button
|
||||
testID="searchTextInputClearBtn"
|
||||
onPress={onClearText}
|
||||
label={_(msg`Clear search query`)}
|
||||
hitSlop={HITSLOP_10}
|
||||
size="tiny"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary">
|
||||
<ButtonIcon icon={X} size="xs" />
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -126,7 +126,7 @@ export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
|
||||
value?: string
|
||||
onChangeText?: (value: string) => void
|
||||
isInvalid?: boolean
|
||||
inputRef?: React.RefObject<TextInput>
|
||||
inputRef?: React.RefObject<TextInput> | React.ForwardedRef<TextInput>
|
||||
}
|
||||
|
||||
export function createInput(Component: typeof TextInput) {
|
||||
|
||||
@@ -2,8 +2,8 @@ import React from 'react'
|
||||
import {Pressable, View, ViewStyle} from 'react-native'
|
||||
import Animated, {LinearTransition} from 'react-native-reanimated'
|
||||
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {HITSLOP_10} from 'lib/constants'
|
||||
import {
|
||||
atoms as a,
|
||||
flatten,
|
||||
|
||||
@@ -3,11 +3,14 @@ import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useAgent, useSession} from 'state/session'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {DialogControlProps} from '#/components/Dialog'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Resend} from '#/components/icons/ArrowRotateCounterClockwise'
|
||||
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
@@ -23,7 +26,9 @@ export function VerifyEmailIntentDialog() {
|
||||
)
|
||||
}
|
||||
|
||||
function Inner({control}: {control: DialogControlProps}) {
|
||||
function Inner({}: {control: DialogControlProps}) {
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {_} = useLingui()
|
||||
const {verifyEmailState: state} = useIntentDialogs()
|
||||
const [status, setStatus] = React.useState<
|
||||
@@ -58,43 +63,47 @@ function Inner({control}: {control: DialogControlProps}) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner label={_(msg`Verify email dialog`)}>
|
||||
<Dialog.Close />
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Verify email dialog`)}
|
||||
style={[
|
||||
gtMobile ? {width: 'auto', maxWidth: 400, minWidth: 200} : a.w_full,
|
||||
]}>
|
||||
<View style={[a.gap_xl]}>
|
||||
{status === 'loading' ? (
|
||||
<View style={[a.py_2xl, a.align_center, a.justify_center]}>
|
||||
<Loader size="xl" />
|
||||
<Loader size="xl" fill={t.atoms.text_contrast_low.color} />
|
||||
</View>
|
||||
) : status === 'success' ? (
|
||||
<>
|
||||
<Text style={[a.font_bold, a.text_2xl]}>
|
||||
<View style={[a.gap_sm, isNative && a.pb_xl]}>
|
||||
<Text style={[a.font_heavy, a.text_2xl]}>
|
||||
<Trans>Email Verified</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md, a.leading_tight]}>
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
Thanks, you have successfully verified your email address.
|
||||
Thanks, you have successfully verified your email address. You
|
||||
can close this dialog.
|
||||
</Trans>
|
||||
</Text>
|
||||
</>
|
||||
</View>
|
||||
) : status === 'failure' ? (
|
||||
<>
|
||||
<Text style={[a.font_bold, a.text_2xl]}>
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text style={[a.font_heavy, a.text_2xl]}>
|
||||
<Trans>Invalid Verification Code</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md, a.leading_tight]}>
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
The verification code you have provided is invalid. Please make
|
||||
sure that you have used the correct verification link or request
|
||||
a new one.
|
||||
</Trans>
|
||||
</Text>
|
||||
</>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Text style={[a.font_bold, a.text_2xl]}>
|
||||
<View style={[a.gap_sm, isNative && a.pb_xl]}>
|
||||
<Text style={[a.font_heavy, a.text_2xl]}>
|
||||
<Trans>Email Resent</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md, a.leading_tight]}>
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
We have sent another verification email to{' '}
|
||||
<Text style={[a.text_md, a.font_bold]}>
|
||||
@@ -103,38 +112,29 @@ function Inner({control}: {control: DialogControlProps}) {
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
</>
|
||||
</View>
|
||||
)}
|
||||
{status !== 'loading' ? (
|
||||
<View style={[a.w_full, a.flex_row, a.gap_sm, {marginLeft: 'auto'}]}>
|
||||
|
||||
{status === 'failure' && (
|
||||
<>
|
||||
<Divider />
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
onPress={() => control.close()}
|
||||
label={_(msg`Resend Verification Email`)}
|
||||
onPress={onPressResendEmail}
|
||||
variant="solid"
|
||||
color={status === 'failure' ? 'secondary' : 'primary'}
|
||||
color="secondary_inverted"
|
||||
size="large"
|
||||
style={{marginLeft: 'auto'}}>
|
||||
disabled={sending}>
|
||||
<ButtonIcon icon={sending ? Loader : Resend} position="left" />
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
<Trans>Resend Email</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
{status === 'failure' ? (
|
||||
<Button
|
||||
label={_(msg`Resend Verification Email`)}
|
||||
onPress={onPressResendEmail}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
disabled={sending}>
|
||||
<ButtonText>
|
||||
<Trans>Resend Email</Trans>
|
||||
</ButtonText>
|
||||
{sending ? <Loader size="sm" style={{color: 'white'}} /> : null}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user