diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index c608f66000..0000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: bug -assignees: '' - ---- - -**Describe the bug** - - -**To Reproduce** - -Steps to reproduce the behavior: - -1. - -**Expected behavior** - - - -**Screenshots** - - - -**Details** - - - Platform: - - Platform version: - - App version: - -**Additional context** - - diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000..4f3041b40e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,67 @@ +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 diff --git a/.github/ISSUE_TEMPLATE/bug_report_fabric.yml b/.github/ISSUE_TEMPLATE/bug_report_fabric.yml new file mode 100644 index 0000000000..83782bf69d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report_fabric.yml @@ -0,0 +1,72 @@ +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 diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 0ac6b3c416..0000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -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.** - - - -**Describe the solution you'd like** - - - -**Describe alternatives you've considered** - - - -**Additional context** - - diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000000..f296e94360 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,45 @@ +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 diff --git a/.prettierignore b/.prettierignore index 8ccbae2148..e107e129a4 100644 --- a/.prettierignore +++ b/.prettierignore @@ -15,3 +15,6 @@ android ios src/locale/locales lib/react-compiler-runtime +bskyweb/static +coverage +web-build diff --git a/src/lib/strings/__tests__/email.test.ts b/__tests__/lib/email.test.ts similarity index 100% rename from src/lib/strings/__tests__/email.test.ts rename to __tests__/lib/email.test.ts diff --git a/app.config.js b/app.config.js index d476109e39..927b3c7794 100644 --- a/app.config.js +++ b/app.config.js @@ -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', ], }, ], diff --git a/assets/fonts/inter/InterVariable-Italic.ttf b/assets/fonts/inter/InterVariable-Italic.ttf new file mode 100644 index 0000000000..ed674e77cb Binary files /dev/null and b/assets/fonts/inter/InterVariable-Italic.ttf differ diff --git a/assets/fonts/inter/InterVariable.ttf b/assets/fonts/inter/InterVariable.ttf new file mode 100644 index 0000000000..2d4b47093a Binary files /dev/null and b/assets/fonts/inter/InterVariable.ttf differ diff --git a/bskyweb/cmd/bskyweb/main.go b/bskyweb/cmd/bskyweb/main.go index 985879f4a9..5c46af418d 100644 --- a/bskyweb/cmd/bskyweb/main.go +++ b/bskyweb/cmd/bskyweb/main.go @@ -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"}, + }, }, }, } diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index fd80a5ed14..1089e3c1af 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -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") diff --git a/bskyweb/static/media/Inter-Black.66e9a87f1c921e844ed4.otf b/bskyweb/static/media/Inter-Black.66e9a87f1c921e844ed4.otf deleted file mode 100644 index 44d1779af6..0000000000 Binary files a/bskyweb/static/media/Inter-Black.66e9a87f1c921e844ed4.otf and /dev/null differ diff --git a/bskyweb/static/media/Inter-BlackItalic.27b9f0ad06fd13a7b9da.otf b/bskyweb/static/media/Inter-BlackItalic.27b9f0ad06fd13a7b9da.otf deleted file mode 100644 index 6fc475e415..0000000000 Binary files a/bskyweb/static/media/Inter-BlackItalic.27b9f0ad06fd13a7b9da.otf and /dev/null differ diff --git a/bskyweb/static/media/Inter-Bold.8d330503e1d034ad68de.otf b/bskyweb/static/media/Inter-Bold.8d330503e1d034ad68de.otf deleted file mode 100644 index 58a38073e8..0000000000 Binary files a/bskyweb/static/media/Inter-Bold.8d330503e1d034ad68de.otf and /dev/null differ diff --git a/bskyweb/static/media/Inter-BoldItalic.bb17e63f9baa0d861a20.otf b/bskyweb/static/media/Inter-BoldItalic.bb17e63f9baa0d861a20.otf deleted file mode 100644 index e67935aa5a..0000000000 Binary files a/bskyweb/static/media/Inter-BoldItalic.bb17e63f9baa0d861a20.otf and /dev/null differ diff --git a/bskyweb/static/media/Inter-ExtraBold.ff2581a193bf6b7e0b06.otf b/bskyweb/static/media/Inter-ExtraBold.ff2581a193bf6b7e0b06.otf deleted file mode 100644 index 66cd95228c..0000000000 Binary files a/bskyweb/static/media/Inter-ExtraBold.ff2581a193bf6b7e0b06.otf and /dev/null differ diff --git a/bskyweb/static/media/Inter-ExtraBoldItalic.0e50b40728d24d40fdf4.otf b/bskyweb/static/media/Inter-ExtraBoldItalic.0e50b40728d24d40fdf4.otf deleted file mode 100644 index f269814a64..0000000000 Binary files a/bskyweb/static/media/Inter-ExtraBoldItalic.0e50b40728d24d40fdf4.otf and /dev/null differ diff --git a/bskyweb/static/media/Inter-Italic.95778eb0c75dc956257e.otf b/bskyweb/static/media/Inter-Italic.95778eb0c75dc956257e.otf deleted file mode 100644 index f78848b987..0000000000 Binary files a/bskyweb/static/media/Inter-Italic.95778eb0c75dc956257e.otf and /dev/null differ diff --git a/bskyweb/static/media/Inter-Medium.296aa2d65964269836b3.otf b/bskyweb/static/media/Inter-Medium.296aa2d65964269836b3.otf deleted file mode 100644 index f44f89adac..0000000000 Binary files a/bskyweb/static/media/Inter-Medium.296aa2d65964269836b3.otf and /dev/null differ diff --git a/bskyweb/static/media/Inter-MediumItalic.0e57e17a6311368e2114.otf b/bskyweb/static/media/Inter-MediumItalic.0e57e17a6311368e2114.otf deleted file mode 100644 index 1970f57214..0000000000 Binary files a/bskyweb/static/media/Inter-MediumItalic.0e57e17a6311368e2114.otf and /dev/null differ diff --git a/bskyweb/static/media/Inter-Regular.1f5ed03b6dd9fd1f9982.otf b/bskyweb/static/media/Inter-Regular.1f5ed03b6dd9fd1f9982.otf deleted file mode 100644 index 2d0bd1d645..0000000000 Binary files a/bskyweb/static/media/Inter-Regular.1f5ed03b6dd9fd1f9982.otf and /dev/null differ diff --git a/bskyweb/static/media/Inter-SemiBold.2277990330981b8409bb.otf b/bskyweb/static/media/Inter-SemiBold.2277990330981b8409bb.otf deleted file mode 100644 index 52c84550ba..0000000000 Binary files a/bskyweb/static/media/Inter-SemiBold.2277990330981b8409bb.otf and /dev/null differ diff --git a/bskyweb/static/media/Inter-SemiBoldItalic.f62fea3df3a521d6c8a7.otf b/bskyweb/static/media/Inter-SemiBoldItalic.f62fea3df3a521d6c8a7.otf deleted file mode 100644 index b725bfc883..0000000000 Binary files a/bskyweb/static/media/Inter-SemiBoldItalic.f62fea3df3a521d6c8a7.otf and /dev/null differ diff --git a/bskyweb/static/media/InterVariable-Italic.55d6a3f35e9b605ba6f4.ttf b/bskyweb/static/media/InterVariable-Italic.55d6a3f35e9b605ba6f4.ttf new file mode 100644 index 0000000000..ed674e77cb Binary files /dev/null and b/bskyweb/static/media/InterVariable-Italic.55d6a3f35e9b605ba6f4.ttf differ diff --git a/bskyweb/static/media/InterVariable.c9f788f6e7ebaec75d7c.ttf b/bskyweb/static/media/InterVariable.c9f788f6e7ebaec75d7c.ttf new file mode 100644 index 0000000000..2d4b47093a Binary files /dev/null and b/bskyweb/static/media/InterVariable.c9f788f6e7ebaec75d7c.ttf differ diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html index 03686ef5c4..9a50fae69e 100644 --- a/bskyweb/templates/base.html +++ b/bskyweb/templates/base.html @@ -13,24 +13,8 @@ - - - - - - - - - + + {% include "scripts.html" %} - - - - + + + + diff --git a/jest/jestSetup.js b/jest/jestSetup.js index 4653490f3c..5564d81f15 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -96,6 +96,11 @@ jest.mock('expo-modules-core', () => ({ getIsReducedMotionEnabled: () => false, } } + if (moduleName === 'BottomSheet') { + return { + dismissAll: () => {}, + } + } }), requireNativeViewManager: jest.fn().mockImplementation(moduleName => { return () => null diff --git a/modules/bottom-sheet/android/build.gradle b/modules/bottom-sheet/android/build.gradle new file mode 100644 index 0000000000..a1d423044b --- /dev/null +++ b/modules/bottom-sheet/android/build.gradle @@ -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:+" +} diff --git a/modules/bottom-sheet/android/src/main/AndroidManifest.xml b/modules/bottom-sheet/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..bdae66c8f5 --- /dev/null +++ b/modules/bottom-sheet/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt new file mode 100644 index 0000000000..057e6ed2e2 --- /dev/null +++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt @@ -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 + } + } + } +} diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt new file mode 100644 index 0000000000..a5a84ec3d9 --- /dev/null +++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt @@ -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(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(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(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?) { } + + override fun dispatchPopulateAccessibilityEvent(event: AccessibilityEvent?): Boolean = false +} diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/DialogRootViewGroup.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/DialogRootViewGroup.kt new file mode 100644 index 0000000000..c022924e99 --- /dev/null +++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/DialogRootViewGroup.kt @@ -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 + } +} diff --git a/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/SheetManager.kt b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/SheetManager.kt new file mode 100644 index 0000000000..be78849988 --- /dev/null +++ b/modules/bottom-sheet/android/src/main/java/expo/modules/bottomsheet/SheetManager.kt @@ -0,0 +1,28 @@ +package expo.modules.bottomsheet + +import java.lang.ref.WeakReference + +class SheetManager { + companion object { + private val sheets = mutableSetOf>() + + 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() + } + } + } +} diff --git a/modules/bottom-sheet/expo-module.config.json b/modules/bottom-sheet/expo-module.config.json new file mode 100644 index 0000000000..81b5b078ed --- /dev/null +++ b/modules/bottom-sheet/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["ios", "android"], + "ios": { + "modules": ["BottomSheetModule"] + }, + "android": { + "modules": ["expo.modules.bottomsheet.BottomSheetModule"] + } +} diff --git a/modules/bottom-sheet/index.ts b/modules/bottom-sheet/index.ts new file mode 100644 index 0000000000..1fe3dac0eb --- /dev/null +++ b/modules/bottom-sheet/index.ts @@ -0,0 +1,13 @@ +import {BottomSheet} from './src/BottomSheet' +import { + BottomSheetSnapPoint, + BottomSheetState, + BottomSheetViewProps, +} from './src/BottomSheet.types' + +export { + BottomSheet, + BottomSheetSnapPoint, + type BottomSheetState, + type BottomSheetViewProps, +} diff --git a/modules/bottom-sheet/ios/BottomSheet.podspec b/modules/bottom-sheet/ios/BottomSheet.podspec new file mode 100644 index 0000000000..a42356f614 --- /dev/null +++ b/modules/bottom-sheet/ios/BottomSheet.podspec @@ -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 diff --git a/modules/bottom-sheet/ios/BottomSheetModule.swift b/modules/bottom-sheet/ios/BottomSheetModule.swift new file mode 100644 index 0000000000..579608e75b --- /dev/null +++ b/modules/bottom-sheet/ios/BottomSheetModule.swift @@ -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 + } + } + } +} diff --git a/modules/bottom-sheet/ios/SheetManager.swift b/modules/bottom-sheet/ios/SheetManager.swift new file mode 100644 index 0000000000..e4e843bea5 --- /dev/null +++ b/modules/bottom-sheet/ios/SheetManager.swift @@ -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(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() + } + } +} diff --git a/modules/bottom-sheet/ios/SheetView.swift b/modules/bottom-sheet/ios/SheetView.swift new file mode 100644 index 0000000000..cf2019c6a1 --- /dev/null +++ b/modules/bottom-sheet/ios/SheetView.swift @@ -0,0 +1,189 @@ +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 + ]) + } + } + } + + // 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 let contentHeight = self.innerView?.subviews.first?.frame.size.height { + self.sheetVc?.updateDetents(contentHeight: self.clampHeight(contentHeight), + preventExpansion: self.preventExpansion) + self.selectedDetentIdentifier = self.sheetVc?.getCurrentDetentIdentifier() + } + } + + 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 + } +} diff --git a/modules/bottom-sheet/ios/SheetViewController.swift b/modules/bottom-sheet/ios/SheetViewController.swift new file mode 100644 index 0000000000..56473b21cd --- /dev/null +++ b/modules/bottom-sheet/ios/SheetViewController.swift @@ -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") + } +} diff --git a/modules/bottom-sheet/ios/Util.swift b/modules/bottom-sheet/ios/Util.swift new file mode 100644 index 0000000000..c654596a74 --- /dev/null +++ b/modules/bottom-sheet/ios/Util.swift @@ -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 + } +} diff --git a/modules/bottom-sheet/src/BottomSheet.tsx b/modules/bottom-sheet/src/BottomSheet.tsx new file mode 100644 index 0000000000..9e7d0c2091 --- /dev/null +++ b/modules/bottom-sheet/src/BottomSheet.tsx @@ -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 + style: StyleProp + } +> = requireNativeViewManager('BottomSheet') + +const NativeModule = requireNativeModule('BottomSheet') + +export class BottomSheet extends React.Component< + BottomSheetViewProps, + { + open: boolean + } +> { + ref = React.createRef() + + 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 ( + + + {children} + + + ) + } +} diff --git a/modules/bottom-sheet/src/BottomSheet.types.ts b/modules/bottom-sheet/src/BottomSheet.types.ts new file mode 100644 index 0000000000..150932d423 --- /dev/null +++ b/modules/bottom-sheet/src/BottomSheet.types.ts @@ -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 +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 +} diff --git a/modules/bottom-sheet/src/BottomSheet.web.tsx b/modules/bottom-sheet/src/BottomSheet.web.tsx new file mode 100644 index 0000000000..4573604eb2 --- /dev/null +++ b/modules/bottom-sheet/src/BottomSheet.web.tsx @@ -0,0 +1,5 @@ +import {BottomSheetViewProps} from './BottomSheet.types' + +export function BottomSheet(_: BottomSheetViewProps) { + throw new Error('BottomSheetView is not available on web') +} diff --git a/package.json b/package.json index 9e6639c741..9c4ddd9899 100644 --- a/package.json +++ b/package.json @@ -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", @@ -172,11 +172,11 @@ "react-native-compressor": "^1.9.0", "react-native-date-picker": "^4.4.2", "react-native-drawer-layout": "^4.0.0-rc.10", - "react-native-gesture-handler": "~2.19.0", + "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.13.4", + "react-native-keyboard-controller": "^1.14.0", "react-native-mmkv": "3.0.2", "react-native-pager-view": "6.4.1", "react-native-picker-select": "^9.1.3", diff --git a/scripts/post-web-build.js b/scripts/post-web-build.js index 7bbee38554..3b1b62dfd8 100644 --- a/scripts/post-web-build.js +++ b/scripts/post-web-build.js @@ -23,10 +23,10 @@ const outputFile = entrypoints const ext = path.extname(file) if (ext === '.js') { - return `` + return `` } if (ext === '.css') { - return `` + return `` } return '' diff --git a/src/App.native.tsx b/src/App.native.tsx index 9339e17db3..5994cfa655 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -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' diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 53e8274d54..323f668b79 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -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' diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index 0c8eb330d7..ca4c14dc6d 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -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 diff --git a/src/alf/fonts.ts b/src/alf/fonts.ts index 46c741bc79..b46faed1cc 100644 --- a/src/alf/fonts.ts +++ b/src/alf/fonts.ts @@ -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'), + }) +} diff --git a/src/alf/tokens.ts b/src/alf/tokens.ts index f3ac17e6af..9ea585ee7c 100644 --- a/src/alf/tokens.ts +++ b/src/alf/tokens.ts @@ -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 = { diff --git a/src/alf/util/useColorModeTheme.ts b/src/alf/util/useColorModeTheme.ts index 12840c7062..561a504b2f 100644 --- a/src/alf/util/useColorModeTheme.ts +++ b/src/alf/util/useColorModeTheme.ts @@ -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 diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 17179994a9..4acb4f1dc4 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -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 hoverStyle?: StyleProp children: NonTextElements | ((context: ButtonContext) => NonTextElements) + PressableComponent?: React.ComponentType } export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean} @@ -114,6 +115,7 @@ export const Button = React.forwardRef( disabled = false, style, hoverStyle: hoverStyleProp, + PressableComponent = Pressable, ...rest }, ref, @@ -352,7 +354,7 @@ export const Button = React.forwardRef( }) } 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( } } 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( const flattenedBaseStyles = flatten([baseStyles, style]) return ( - ( {typeof children === 'function' ? children(context) : children} - + ) }, ) @@ -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) } diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts index 859f8edd77..b479bc7f06 100644 --- a/src/components/Dialog/context.ts +++ b/src/components/Dialog/context.ts @@ -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({ close: () => {}, + isNativeDialog: false, + nativeSnapPoint: BottomSheetSnapPoint.Hidden, + disableDrag: false, + setDisableDrag: () => {}, }) export function useDialogContext() { diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index bb3352c75b..49b5e10b23 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -1,84 +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, - 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 ( - - - - ) -} +export const Input = createInput(TextInput) export function Outer({ children, @@ -86,24 +50,22 @@ export function Outer({ onClose, nativeOptions, testID, + Portal = DefaultPortal, }: React.PropsWithChildren) { const t = useTheme() - const sheet = React.useRef(null) - const sheetOptions = nativeOptions?.sheet || {} - const hasSnapPoints = !!sheetOptions.snapPoints - const insets = useSafeAreaInsets() + const ref = React.useRef(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.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.Partial, + ) const callQueuedCallbacks = React.useCallback(() => { for (const cb of closeCallbacks.current) { @@ -117,25 +79,19 @@ export function Outer({ closeCallbacks.current = [] }, []) - const open = React.useCallback( - ({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(() => { + // 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(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 @@ -144,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, () => ({ @@ -159,161 +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 && ( - - - Keyboard.dismiss()}> - - - - {children} - - - - - - ) + + + + {children} + + + ) } export function Inner({children, style}: DialogInnerProps) { const insets = useSafeAreaInsets() return ( - {children} - + ) } -export const ScrollableInner = Inner +export const ScrollableInner = React.forwardRef( + 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) + }, + }) -// export const ScrollableInner = React.forwardRef< -// BottomSheetScrollViewMethods, -// DialogInnerProps -// >(function ScrollableInner({children, style}, ref) { -// const insets = useSafeAreaInsets() -// return ( -// -// {children} -// -// -// ) -// }) + 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) => { + const {contentOffset} = e.nativeEvent + if (contentOffset.y > 0 && !disableDrag) { + setDisableDrag(true) + } else if (contentOffset.y <= 1 && disableDrag) { + setDisableDrag(false) + } + } + + return ( + + {children} + + ) + }, +) export const InnerFlatList = React.forwardRef< - BottomSheetFlatListMethods, - BottomSheetFlatListProps & {webInnerStyle?: StyleProp} ->(function InnerFlatList({style, contentContainerStyle, ...props}, ref) { + ListMethods, + ListProps & {webInnerStyle?: StyleProp} +>(function InnerFlatList({style, ...props}, ref) { const insets = useSafeAreaInsets() - + const {nativeSnapPoint} = useDialogContext() return ( - } 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 ( - - + + close()} + accessibilityLabel={_(msg`Dismiss`)} + accessibilityHint={_(msg`Double tap to close the dialog`)}> + + ) } diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx index bf20bd2956..7b9cfb6931 100644 --- a/src/components/Dialog/index.web.tsx +++ b/src/components/Dialog/index.web.tsx @@ -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() { ) } + +export function Handle() { + return null +} diff --git a/src/components/Dialog/sheet-wrapper.ts b/src/components/Dialog/sheet-wrapper.ts new file mode 100644 index 0000000000..37c6633837 --- /dev/null +++ b/src/components/Dialog/sheet-wrapper.ts @@ -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 (promise: Promise): Promise => { + setFullyExpandedCount(c => c + 1) + const res = await promise + setFullyExpandedCount(c => c - 1) + return res + }, + [setFullyExpandedCount], + ) +} diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts index 1ddab02eea..caa787535b 100644 --- a/src/components/Dialog/types.ts +++ b/src/components/Dialog/types.ts @@ -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 @@ -37,6 +39,10 @@ export type DialogControlProps = DialogControlRefProps & { export type DialogContextProps = { close: DialogControlProps['close'] + isNativeDialog: boolean + nativeSnapPoint: BottomSheetSnapPoint + disableDrag: boolean + setDisableDrag: React.Dispatch> } export type DialogControlOpenOptions = { @@ -52,11 +58,10 @@ export type DialogControlOpenOptions = { export type DialogOuterProps = { control: DialogControlProps onClose?: () => void - nativeOptions?: { - sheet?: Omit - } + nativeOptions?: Omit webOptions?: {} testID?: string + Portal?: PortalComponent } type DialogInnerPropsBase = React.PropsWithChildren & T diff --git a/src/components/KeyboardControllerPadding.android.tsx b/src/components/KeyboardControllerPadding.android.tsx deleted file mode 100644 index 92ef1b0b05..0000000000 --- a/src/components/KeyboardControllerPadding.android.tsx +++ /dev/null @@ -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 -} diff --git a/src/components/KeyboardControllerPadding.tsx b/src/components/KeyboardControllerPadding.tsx deleted file mode 100644 index f3163d87cf..0000000000 --- a/src/components/KeyboardControllerPadding.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export function KeyboardControllerPadding({ - maxHeight: _, -}: { - maxHeight?: number -}) { - return null -} diff --git a/src/components/LikesDialog.tsx b/src/components/LikesDialog.tsx index 94a3f27e26..4c68596f73 100644 --- a/src/components/LikesDialog.tsx +++ b/src/components/LikesDialog.tsx @@ -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 ( - ) diff --git a/src/components/Link.tsx b/src/components/Link.tsx index c80b9f3707..447833a239 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -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 { diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx index a0a21a50f9..a22f43cf8d 100644 --- a/src/components/Menu/index.tsx +++ b/src/components/Menu/index.tsx @@ -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 }>) { const context = React.useContext(Context) + const {_} = useLingui() return ( - + - {/* Re-wrap with context since Dialogs are portal-ed to root */} - + {children} {isNative && showCancel && } + - @@ -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) diff --git a/src/components/NewskieDialog.tsx b/src/components/NewskieDialog.tsx index 1a523a839d..0e35206586 100644 --- a/src/components/NewskieDialog.tsx +++ b/src/components/NewskieDialog.tsx @@ -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' diff --git a/src/components/Portal.tsx b/src/components/Portal.tsx index 03b397b2b8..7441df005c 100644 --- a/src/components/Portal.tsx +++ b/src/components/Portal.tsx @@ -12,6 +12,8 @@ type ComponentMap = { [id: string]: Component } +export type PortalComponent = ({children}: {children?: React.ReactNode}) => null + export function createPortalGroup() { const Context = React.createContext({ outlet: null, diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index 8765cdee31..fc6919af89 100644 --- a/src/components/Prompt.tsx +++ b/src/components/Prompt.tsx @@ -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 ( - + + - - 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 ( - + {title} {description} diff --git a/src/components/ReportDialog/SelectLabelerView.tsx b/src/components/ReportDialog/SelectLabelerView.tsx index f7a8139ea5..039bbf123f 100644 --- a/src/components/ReportDialog/SelectLabelerView.tsx +++ b/src/components/ReportDialog/SelectLabelerView.tsx @@ -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' diff --git a/src/components/ReportDialog/SubmitView.tsx b/src/components/ReportDialog/SubmitView.tsx index e323d15042..ef4a9b7fbc 100644 --- a/src/components/ReportDialog/SubmitView.tsx +++ b/src/components/ReportDialog/SubmitView.tsx @@ -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 && } + {/* Maybe fix this later -h */} + {isAndroid ? : null} ) } diff --git a/src/components/ReportDialog/index.tsx b/src/components/ReportDialog/index.tsx index c87d32f9ec..5bf8aa5b4b 100644 --- a/src/components/ReportDialog/index.tsx +++ b/src/components/ReportDialog/index.tsx @@ -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 ( - ) @@ -40,10 +38,7 @@ function ReportDialogInner(props: ReportDialogProps) { } = useMyLabelersQuery() const isLoading = useDelayedLoading(500, isLabelerLoading) - const ref = React.useRef(null) - useOnKeyboardDidShow(() => { - ref.current?.scrollToEnd({animated: true}) - }) + const ref = React.useRef(null) return ( diff --git a/src/components/StarterPack/QrCodeDialog.tsx b/src/components/StarterPack/QrCodeDialog.tsx index b2af8ff73a..2feea0973a 100644 --- a/src/components/StarterPack/QrCodeDialog.tsx +++ b/src/components/StarterPack/QrCodeDialog.tsx @@ -149,7 +149,6 @@ export function QrCodeDialog({ return ( - diff --git a/src/components/StarterPack/ShareDialog.tsx b/src/components/StarterPack/ShareDialog.tsx index 9851b08567..997c6479c5 100644 --- a/src/components/StarterPack/ShareDialog.tsx +++ b/src/components/StarterPack/ShareDialog.tsx @@ -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 ( + ) @@ -84,7 +85,6 @@ function ShareDialogInner({ return ( <> - {!imageLoaded || !link ? ( diff --git a/src/components/StarterPack/Wizard/WizardEditListDialog.tsx b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx index f7b0aba344..1e9f1c52d4 100644 --- a/src/components/StarterPack/Wizard/WizardEditListDialog.tsx +++ b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx @@ -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(null) + const listRef = useRef(null) const getData = () => { if (state.currentStep === 'Feeds') return state.feeds @@ -76,10 +76,7 @@ export function WizardEditListDialog({ ) return ( - + @@ -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}]} diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx index 2c6a0b674c..917624a036 100644 --- a/src/components/TagMenu/index.tsx +++ b/src/components/TagMenu/index.tsx @@ -85,7 +85,6 @@ export function TagMenu({ - {isPreferencesLoading ? ( diff --git a/src/components/Typography.tsx b/src/components/Typography.tsx index 501e23872f..19eba35fbe 100644 --- a/src/components/Typography.tsx +++ b/src/components/Typography.tsx @@ -53,11 +53,14 @@ export function childIsString( ) } -export function renderChildrenWithEmoji(children: StringChild) { +export function renderChildrenWithEmoji( + children: StringChild, + props: Omit = {}, +) { const normalized = Array.isArray(children) ? children : [children] return ( - + {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) => ( - + {stringPart} {emojis[index] ? ( - + {emojis[index]} ) : null} @@ -163,15 +168,17 @@ export function Text({ } } + const shared = { + uiTextView: true, + selectable, + style: s, + dataSet: Object.assign({tooltip: title}, dataSet || {}), + ...rest, + } + return ( - - {isIOS && emoji ? renderChildrenWithEmoji(children) : children} + + {isIOS && emoji ? renderChildrenWithEmoji(children, shared) : children} ) } diff --git a/src/components/dialogs/BirthDateSettings.tsx b/src/components/dialogs/BirthDateSettings.tsx index 08608f9d88..81d0c6740e 100644 --- a/src/components/dialogs/BirthDateSettings.tsx +++ b/src/components/dialogs/BirthDateSettings.tsx @@ -31,7 +31,6 @@ export function BirthDateSettingsDialog({ return ( - diff --git a/src/components/dialogs/EmbedConsent.tsx b/src/components/dialogs/EmbedConsent.tsx index 765b8adc7e..824155d8bb 100644 --- a/src/components/dialogs/EmbedConsent.tsx +++ b/src/components/dialogs/EmbedConsent.tsx @@ -50,7 +50,6 @@ export function EmbedConsentDialog({ return ( - diff --git a/src/components/dialogs/GifSelect.ios.tsx b/src/components/dialogs/GifSelect.ios.tsx deleted file mode 100644 index 2f867e8657..0000000000 --- a/src/components/dialogs/GifSelect.ios.tsx +++ /dev/null @@ -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) => , - [close], - ) - - return ( - - - - - - - - - ) -} - -function GifList({ - onSelectGif, -}: { - close: () => void - onSelectGif: (gif: Gif) => void -}) { - const {_} = useLingui() - const t = useTheme() - const {gtMobile} = useBreakpoints() - const textInputRef = useRef(null) - const listRef = useRef(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 - }, - [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 ( - - {/* cover top corners */} - - - - - { - setSearch(text) - listRef.current?.scrollToOffset({offset: 0, animated: false}) - }} - returnKeyType="search" - clearButtonMode="while-editing" - inputRef={textInputRef} - maxLength={50} - /> - - - ) - }, [t.atoms.bg, _]) - - return ( - - {listHeader} - {!hasData && ( - - )} - - } - stickyHeaderIndices={[0]} - onEndReached={onEndReached} - onEndReachedThreshold={4} - keyExtractor={(item: Gif) => item.id} - keyboardDismissMode="on-drag" - ListFooterComponent={ - hasData ? ( - - ) : null - } - /> - ) -} - -function ModalError({details, close}: {details?: string; close: () => void}) { - const {_} = useLingui() - - return ( - - - - - ) -} diff --git a/src/components/dialogs/GifSelect.shared.tsx b/src/components/dialogs/GifSelect.shared.tsx deleted file mode 100644 index 90b2abaa83..0000000000 --- a/src/components/dialogs/GifSelect.shared.tsx +++ /dev/null @@ -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 ( - - ) -} diff --git a/src/components/dialogs/GifSelect.tsx b/src/components/dialogs/GifSelect.tsx index 1afc588dad..6023b5808a 100644 --- a/src/components/dialogs/GifSelect.tsx +++ b/src/components/dialogs/GifSelect.tsx @@ -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 ( + onClose={onClose} + Portal={Portal} + nativeOptions={{ + bottomInset: 0, + // use system corner radius on iOS + ...ios({cornerRadius: undefined}), + }}> @@ -80,9 +90,10 @@ function GifList({ const t = useTheme() const {gtMobile} = useBreakpoints() const textInputRef = useRef(null) - const listRef = useRef(null) + const listRef = useRef(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 ( {listHeader} {!hasData && ( ) } + +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 ( + + ) +} diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx index 81a6141038..c3aae8f0df 100644 --- a/src/components/dialogs/MutedWords.tsx +++ b/src/components/dialogs/MutedWords.tsx @@ -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 ( - - - - Add muted words and tags - - - - 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. - - - - - { - if (error) { - setError('') - } - setField(value) - }} - onSubmitEditing={submit} - /> - - - - - - Duration: - - - - - - - - - - Forever - - - - - - - - - - - 24 hours - - - - - - - - - - - - - 7 days - - - - - - - - - - - 30 days - - - - - - - - - - - Mute in: - - - - - - - - - Text & tags - - - - - - - - - - - - Tags only - - - - - - - - - - - Options: - - - - - - - Exclude users you follow - - - - - - - - - - - {error && ( - - - {error} - - - )} - - - - - + + + - Your muted words + Add muted words and tags + + + + 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. + - {isPreferencesLoading ? ( - - ) : preferencesError || !preferences ? ( - - - - We're sorry, but we weren't able to load your muted words at - this time. Please try again. - + + { + if (error) { + setError('') + } + setField(value) + }} + onSubmitEditing={submit} + /> + + + + + + Duration: - - ) : preferences.moderationPrefs.mutedWords.length ? ( - [...preferences.moderationPrefs.mutedWords] - .reverse() - .map((word, i) => ( - - )) - ) : ( - - - You haven't muted any words or tags yet + + + + + + + + + Forever + + + + + + + + + + + 24 hours + + + + + + + + + + + + + 7 days + + + + + + + + + + + 30 days + + + + + + + + + + + Mute in: + + + + + + + + Text & tags + + + + + + + + + + + + Tags only + + + + + + + + + + + Options: + + + + + + + Exclude users you follow + + + + - )} + + + + + + {error && ( + + + {error} + + + )} + + + + + + + Your muted words + + + {isPreferencesLoading ? ( + + ) : preferencesError || !preferences ? ( + + + + We're sorry, but we weren't able to load your muted words at + this time. Please try again. + + + + ) : preferences.moderationPrefs.mutedWords.length ? ( + [...preferences.moderationPrefs.mutedWords] + .reverse() + .map((word, i) => ( + + )) + ) : ( + + + You haven't muted any words or tags yet + + + )} + + + {isNative && } - {isNative && } - + + - - + + ) } @@ -437,6 +482,7 @@ function MutedWordRow({ onConfirm={remove} confirmButtonCta={_(msg`Remove`)} confirmButtonColor="negative" + Portal={Portal.Portal} /> + - diff --git a/src/components/dialogs/nuxs/NeueTypography.tsx b/src/components/dialogs/nuxs/NeueTypography.tsx index f160c87743..f29dc356dd 100644 --- a/src/components/dialogs/nuxs/NeueTypography.tsx +++ b/src/components/dialogs/nuxs/NeueTypography.tsx @@ -44,7 +44,6 @@ export function NeueTypography() { return ( - diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx index a4fa625fae..affc292c18 100644 --- a/src/components/dms/ConvoMenu.tsx +++ b/src/components/dms/ConvoMenu.tsx @@ -136,7 +136,7 @@ let ConvoMenu = ({ + onPress={() => leaveConvoControl.open()}> Leave conversation @@ -195,7 +195,7 @@ let ConvoMenu = ({ + onPress={() => reportControl.open()}> Report conversation @@ -206,7 +206,7 @@ let ConvoMenu = ({ + onPress={() => leaveConvoControl.open()}> Leave conversation diff --git a/src/components/dms/DateDivider.tsx b/src/components/dms/DateDivider.tsx new file mode 100644 index 0000000000..a9c82e8ea2 --- /dev/null +++ b/src/components/dms/DateDivider.tsx @@ -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 ( + + + + + {date} + {' '} + at {time} + + + + ) +} +DateDivider = React.memo(DateDivider) +export {DateDivider} diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index c5c472cf08..52220e2cac 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -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 ( - - - {AppBskyEmbedRecord.isView(message.embed) && ( - - )} - {rt.text.length > 0 && ( - - - - )} - + <> + {isNewDay && } + + + {AppBskyEmbedRecord.isView(message.embed) && ( + + )} + {rt.text.length > 0 && ( + + + + )} + - {isLastInGroup && ( - - )} - + {isLastInGroup && ( + + )} + + ) } 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 = ({ ) } - 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}` -} diff --git a/src/components/dms/MessageMenu.tsx b/src/components/dms/MessageMenu.tsx index 2978d2b220..8680a68bfb 100644 --- a/src/components/dms/MessageMenu.tsx +++ b/src/components/dms/MessageMenu.tsx @@ -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 = ({ + onPress={() => deleteControl.open()}> {_(msg`Delete for me`)} @@ -128,7 +128,7 @@ export let MessageMenu = ({ + onPress={() => reportControl.open()}> {_(msg`Report`)} diff --git a/src/components/dms/MessageProfileButton.tsx b/src/components/dms/MessageProfileButton.tsx index 7f440d621f..932982d05e 100644 --- a/src/components/dms/MessageProfileButton.tsx +++ b/src/components/dms/MessageProfileButton.tsx @@ -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}, ]}> - + ) } 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}> - + ) } else { diff --git a/src/components/dms/MessagesNUX.tsx b/src/components/dms/MessagesNUX.tsx deleted file mode 100644 index 5c21ee05be..0000000000 --- a/src/components/dms/MessagesNUX.tsx +++ /dev/null @@ -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 ( - - - - - ) -} - -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 ( - - - - - - Direct messages are here! - - - Privately chat with other users. - - - - - - Who can message you? - - - You can change this at any time. - - - - - - - - Everyone - - - - - - Users I follow - - - - - - No one - - - - - - - - - - - - ) -} diff --git a/src/components/dms/ReportDialog.tsx b/src/components/dms/ReportDialog.tsx index 5493a1c87f..06d69ff4be 100644 --- a/src/components/dms/ReportDialog.tsx +++ b/src/components/dms/ReportDialog.tsx @@ -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 ( - + - ) @@ -277,6 +272,7 @@ function PreviewMessage({message}: {message: ChatBskyConvoDefs.MessageView}) { message, key: '', nextMessage: null, + prevMessage: null, }} style={[a.text_left, a.mb_0]} /> diff --git a/src/components/dms/dialogs/NewChatDialog.tsx b/src/components/dms/dialogs/NewChatDialog.tsx index 19f6eb6dfc..e80fef2d7e 100644 --- a/src/components/dms/dialogs/NewChatDialog.tsx +++ b/src/components/dms/dialogs/NewChatDialog.tsx @@ -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="" /> - + + (null) + const listRef = useRef(null) const {currentAccount} = useSession() - const inputRef = useRef(null) + const inputRef = useRef(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({ - - + {title} + {isWeb ? ( + + ) : null} - + 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({ + numberOfLines={1} + emoji> {displayName} void onEscape: () => void - inputRef: React.RefObject + inputRef: React.RefObject }) { const t = useTheme() const {_} = useLingui() diff --git a/src/components/dms/dialogs/ShareViaChatDialog.tsx b/src/components/dms/dialogs/ShareViaChatDialog.tsx index 01906a430c..38b5583432 100644 --- a/src/components/dms/dialogs/ShareViaChatDialog.tsx +++ b/src/components/dms/dialogs/ShareViaChatDialog.tsx @@ -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 ( - + + ) diff --git a/src/components/dms/util.ts b/src/components/dms/util.ts index 5952b9acf4..003532d0c6 100644 --- a/src/components/dms/util.ts +++ b/src/components/dms/util.ts @@ -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}` +} diff --git a/src/components/forms/SearchInput.tsx b/src/components/forms/SearchInput.tsx new file mode 100644 index 0000000000..30791d0052 --- /dev/null +++ b/src/components/forms/SearchInput.tsx @@ -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 & { + label?: TextField.InputProps['label'] + /** + * Called when the user presses the (X) button + */ + onClearText?: () => void +} + +export const SearchInput = React.forwardRef( + function SearchInput({value, label, onClearText, ...rest}, ref) { + const t = useTheme() + const {_} = useLingui() + + return ( + + + + + + + {value && value.length > 0 && ( + + + + )} + + ) + }, +) diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index 21928d3df3..96d3481cd4 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -126,7 +126,7 @@ export type InputProps = Omit & { value?: string onChangeText?: (value: string) => void isInvalid?: boolean - inputRef?: React.RefObject + inputRef?: React.RefObject | React.ForwardedRef } export function createInput(Component: typeof TextInput) { diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle.tsx index 6dc387b239..4e3695bbf2 100644 --- a/src/components/forms/Toggle.tsx +++ b/src/components/forms/Toggle.tsx @@ -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, diff --git a/src/components/intents/VerifyEmailIntentDialog.tsx b/src/components/intents/VerifyEmailIntentDialog.tsx index e8c63af82f..c78aabb6d4 100644 --- a/src/components/intents/VerifyEmailIntentDialog.tsx +++ b/src/components/intents/VerifyEmailIntentDialog.tsx @@ -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 ( - - + {status === 'loading' ? ( - + ) : status === 'success' ? ( - <> - + + Email Verified - + - Thanks, you have successfully verified your email address. + Thanks, you have successfully verified your email address. You + can close this dialog. - + ) : status === 'failure' ? ( - <> - + + Invalid Verification Code - + The verification code you have provided is invalid. Please make sure that you have used the correct verification link or request a new one. - + ) : ( - <> - + + Email Resent - + We have sent another verification email to{' '} @@ -103,38 +112,29 @@ function Inner({control}: {control: DialogControlProps}) { . - + )} - {status !== 'loading' ? ( - + + {status === 'failure' && ( + <> + - {status === 'failure' ? ( - - ) : null} - - ) : null} + + )} + + ) } diff --git a/src/components/moderation/LabelsOnMeDialog.tsx b/src/components/moderation/LabelsOnMeDialog.tsx index e63cea93b2..34fff5a8ab 100644 --- a/src/components/moderation/LabelsOnMeDialog.tsx +++ b/src/components/moderation/LabelsOnMeDialog.tsx @@ -32,7 +32,6 @@ export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) { return ( - ) @@ -158,23 +157,25 @@ function Label({ - - {isSelfLabel ? ( + {isSelfLabel ? ( + This label was applied by you. - ) : ( - - Source:{' '} - control.close()}> - {sourceName} - - - )} - + + ) : ( + + + Source: {' '} + + control.close()}> + {sourceName} + + + )} ) @@ -236,24 +237,26 @@ function AppealForm({ return ( <> - - Appeal "{strings.name}" label - - - - This appeal will be sent to{' '} - control.close()} - style={[a.text_md, a.leading_snug]}> - {sourceName} - - . - - + + + Appeal "{strings.name}" label + + + + This appeal will be sent to{' '} + control.close()} + style={[a.text_md, a.leading_snug]}> + {sourceName} + + . + + + - - {modcause.source.type === 'user' ? ( + {modcause.source.type === 'user' ? ( + This label was applied by the author. - ) : ( - - This label was applied by{' '} - control.close()} - style={a.text_md}> - {desc.source || _(msg`an unknown labeler`)} - - . - - )} - + + ) : ( + <> + + + This label was applied by{' '} + control.close()} + style={a.text_md}> + {desc.source || _(msg`an unknown labeler`)} + + + + + )} )} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 1e51c7f255..c7608ae557 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -1,5 +1,4 @@ import { - AppBskyEmbedDefs, AppBskyEmbedExternal, AppBskyEmbedImages, AppBskyEmbedRecord, @@ -7,12 +6,13 @@ import { AppBskyEmbedVideo, AppBskyFeedPostgate, AtUri, - BlobRef, BskyAgent, ComAtprotoLabelDefs, RichText, } from '@atproto/api' +import {isNetworkError} from '#/lib/strings/errors' +import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' import {logger} from '#/logger' import {ComposerImage, compressImage} from '#/state/gallery' import {writePostgateRecord} from '#/state/queries/postgate' @@ -22,8 +22,7 @@ import { threadgateAllowUISettingToAllowRecordValue, writeThreadgateRecord, } from '#/state/queries/threadgate' -import {isNetworkError} from 'lib/strings/errors' -import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip' +import {ComposerState} from '#/view/com/composer/state/composer' import {LinkMeta} from '../link-meta/link-meta' import {uploadBlob} from './upload-blob' @@ -38,20 +37,14 @@ export interface ExternalEmbedDraft { } interface PostOpts { + composerState: ComposerState // TODO: Not used yet. rawText: string replyTo?: string quote?: { uri: string cid: string } - video?: { - blobRef: BlobRef - altText: string - captions: {lang: string; file: File}[] - aspectRatio?: AppBskyEmbedDefs.AspectRatio - } extLink?: ExternalEmbedDraft - images?: ComposerImage[] labels?: string[] threadgate: ThreadgateAllowUISetting[] postgate: AppBskyFeedPostgate.Record @@ -60,13 +53,6 @@ interface PostOpts { } export async function post(agent: BskyAgent, opts: PostOpts) { - let embed: - | AppBskyEmbedImages.Main - | AppBskyEmbedExternal.Main - | AppBskyEmbedRecord.Main - | AppBskyEmbedVideo.Main - | AppBskyEmbedRecordWithMedia.Main - | undefined let reply let rt = new RichText({text: opts.rawText.trimEnd()}, {cleanNewlines: true}) @@ -77,134 +63,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) { rt = shortenLinks(rt) rt = stripInvalidMentions(rt) - // add quote embed if present - if (opts.quote) { - embed = { - $type: 'app.bsky.embed.record', - record: { - uri: opts.quote.uri, - cid: opts.quote.cid, - }, - } as AppBskyEmbedRecord.Main - } - - // add image embed if present - if (opts.images?.length) { - logger.debug(`Uploading images`, { - count: opts.images.length, - }) - - const images: AppBskyEmbedImages.Image[] = [] - for (const image of opts.images) { - opts.onStateChange?.(`Uploading image #${images.length + 1}...`) - - logger.debug(`Compressing image`) - const {path, width, height, mime} = await compressImage(image) - - logger.debug(`Uploading image`) - const res = await uploadBlob(agent, path, mime) - - images.push({ - image: res.data.blob, - alt: image.alt, - aspectRatio: {width, height}, - }) - } - - if (opts.quote) { - embed = { - $type: 'app.bsky.embed.recordWithMedia', - record: embed, - media: { - $type: 'app.bsky.embed.images', - images, - }, - } as AppBskyEmbedRecordWithMedia.Main - } else { - embed = { - $type: 'app.bsky.embed.images', - images, - } as AppBskyEmbedImages.Main - } - } - - // add video embed if present - if (opts.video) { - const captions = await Promise.all( - opts.video.captions - .filter(caption => caption.lang !== '') - .map(async caption => { - const {data} = await agent.uploadBlob(caption.file, { - encoding: 'text/vtt', - }) - return {lang: caption.lang, file: data.blob} - }), - ) - if (opts.quote) { - embed = { - $type: 'app.bsky.embed.recordWithMedia', - record: embed, - media: { - $type: 'app.bsky.embed.video', - video: opts.video.blobRef, - alt: opts.video.altText || undefined, - captions: captions.length === 0 ? undefined : captions, - aspectRatio: opts.video.aspectRatio, - } as AppBskyEmbedVideo.Main, - } as AppBskyEmbedRecordWithMedia.Main - } else { - embed = { - $type: 'app.bsky.embed.video', - video: opts.video.blobRef, - alt: opts.video.altText || undefined, - captions: captions.length === 0 ? undefined : captions, - aspectRatio: opts.video.aspectRatio, - } as AppBskyEmbedVideo.Main - } - } - - // add external embed if present - if (opts.extLink && !opts.images?.length) { - if (opts.extLink.embed) { - embed = opts.extLink.embed - } else { - let thumb - if (opts.extLink.localThumb) { - opts.onStateChange?.('Uploading link thumbnail...') - - const {path, mime} = opts.extLink.localThumb.source - const res = await uploadBlob(agent, path, mime) - - thumb = res.data.blob - } - - if (opts.quote) { - embed = { - $type: 'app.bsky.embed.recordWithMedia', - record: embed, - media: { - $type: 'app.bsky.embed.external', - external: { - uri: opts.extLink.uri, - title: opts.extLink.meta?.title || '', - description: opts.extLink.meta?.description || '', - thumb, - }, - } as AppBskyEmbedExternal.Main, - } as AppBskyEmbedRecordWithMedia.Main - } else { - embed = { - $type: 'app.bsky.embed.external', - external: { - uri: opts.extLink.uri, - title: opts.extLink.meta?.title || '', - description: opts.extLink.meta?.description || '', - thumb, - }, - } as AppBskyEmbedExternal.Main - } - } - } + const embed = await resolveEmbed(agent, opts) // add replyTo if post is a reply to another post if (opts.replyTo) { @@ -313,3 +172,124 @@ export async function post(agent: BskyAgent, opts: PostOpts) { return res } + +async function resolveEmbed( + agent: BskyAgent, + opts: PostOpts, +): Promise< + | AppBskyEmbedImages.Main + | AppBskyEmbedVideo.Main + | AppBskyEmbedExternal.Main + | AppBskyEmbedRecord.Main + | AppBskyEmbedRecordWithMedia.Main + | undefined +> { + const media = await resolveMedia(agent, opts) + if (opts.quote) { + const quoteRecord = { + $type: 'app.bsky.embed.record', + record: { + uri: opts.quote.uri, + cid: opts.quote.cid, + }, + } + if (media) { + return { + $type: 'app.bsky.embed.recordWithMedia', + record: quoteRecord, + media, + } + } else { + return quoteRecord + } + } + if (media) { + return media + } + if (opts.extLink?.embed) { + return opts.extLink.embed + } + return undefined +} + +async function resolveMedia( + agent: BskyAgent, + opts: PostOpts, +): Promise< + | AppBskyEmbedExternal.Main + | AppBskyEmbedImages.Main + | AppBskyEmbedVideo.Main + | undefined +> { + const state = opts.composerState + const media = state.embed.media + if (media?.type === 'images') { + logger.debug(`Uploading images`, { + count: media.images.length, + }) + opts.onStateChange?.(`Uploading images...`) + const images: AppBskyEmbedImages.Image[] = await Promise.all( + media.images.map(async (image, i) => { + logger.debug(`Compressing image #${i}`) + const {path, width, height, mime} = await compressImage(image) + logger.debug(`Uploading image #${i}`) + const res = await uploadBlob(agent, path, mime) + return { + image: res.data.blob, + alt: image.alt, + aspectRatio: {width, height}, + } + }), + ) + return { + $type: 'app.bsky.embed.images', + images, + } + } + if (media?.type === 'video' && media.video.status === 'done') { + const video = media.video + const captions = await Promise.all( + video.captions + .filter(caption => caption.lang !== '') + .map(async caption => { + const {data} = await agent.uploadBlob(caption.file, { + encoding: 'text/vtt', + }) + return {lang: caption.lang, file: data.blob} + }), + ) + return { + $type: 'app.bsky.embed.video', + video: video.pendingPublish.blobRef, + alt: video.altText || undefined, + captions: captions.length === 0 ? undefined : captions, + aspectRatio: { + width: video.asset.width, + height: video.asset.height, + }, + } + } + if (opts.extLink) { + // TODO: Read this from composer state as well. + if (opts.extLink.embed) { + return undefined + } + let thumb + if (opts.extLink.localThumb) { + opts.onStateChange?.('Uploading link thumbnail...') + const {path, mime} = opts.extLink.localThumb.source + const res = await uploadBlob(agent, path, mime) + thumb = res.data.blob + } + return { + $type: 'app.bsky.embed.external', + external: { + uri: opts.extLink.uri, + title: opts.extLink.meta?.title || '', + description: opts.extLink.meta?.description || '', + thumb, + }, + } + } + return undefined +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 9bf1fb35ea..0edc9f2ad2 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -50,7 +50,7 @@ export const MAX_DM_GRAPHEME_LENGTH = 1000 // Recommended is 100 per: https://www.w3.org/WAI/GL/WCAG20/tests/test3.html // but increasing limit per user feedback -export const MAX_ALT_TEXT = 1000 +export const MAX_ALT_TEXT = 2000 export function IS_TEST_USER(handle?: string) { return handle && handle?.endsWith('.test') diff --git a/src/lib/media/video/compress.ts b/src/lib/media/video/compress.ts index dec9032a34..c2d1470c63 100644 --- a/src/lib/media/video/compress.ts +++ b/src/lib/media/video/compress.ts @@ -2,8 +2,8 @@ import {getVideoMetaData, Video} from 'react-native-compressor' import {ImagePickerAsset} from 'expo-image-picker' import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants' -import {extToMime} from '#/state/queries/video/util' import {CompressedVideo} from './types' +import {extToMime} from './util' const MIN_SIZE_FOR_COMPRESSION = 25 // 25mb diff --git a/src/lib/media/video/upload.shared.ts b/src/lib/media/video/upload.shared.ts new file mode 100644 index 0000000000..8c217eadcf --- /dev/null +++ b/src/lib/media/video/upload.shared.ts @@ -0,0 +1,61 @@ +import {BskyAgent} from '@atproto/api' +import {I18n} from '@lingui/core' +import {msg} from '@lingui/macro' + +import {VIDEO_SERVICE_DID} from '#/lib/constants' +import {UploadLimitError} from '#/lib/media/video/errors' +import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers' +import {createVideoAgent} from './util' + +export async function getServiceAuthToken({ + agent, + aud, + lxm, + exp, +}: { + agent: BskyAgent + aud?: string + lxm: string + exp?: number +}) { + const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl) + if (!pdsAud) { + throw new Error('Agent does not have a PDS URL') + } + const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({ + aud: aud ?? pdsAud, + lxm, + exp, + }) + return serviceAuth.token +} + +export async function getVideoUploadLimits(agent: BskyAgent, _: I18n['_']) { + const token = await getServiceAuthToken({ + agent, + lxm: 'app.bsky.video.getUploadLimits', + aud: VIDEO_SERVICE_DID, + }) + const videoAgent = createVideoAgent() + const {data: limits} = await videoAgent.app.bsky.video + .getUploadLimits({}, {headers: {Authorization: `Bearer ${token}`}}) + .catch(err => { + if (err instanceof Error) { + throw new UploadLimitError(err.message) + } else { + throw err + } + }) + + if (!limits.canUpload) { + if (limits.message) { + throw new UploadLimitError(limits.message) + } else { + throw new UploadLimitError( + _( + msg`You have temporarily reached the limit for video uploads. Please try again later.`, + ), + ) + } + } +} diff --git a/src/lib/media/video/upload.ts b/src/lib/media/video/upload.ts new file mode 100644 index 0000000000..720283a8da --- /dev/null +++ b/src/lib/media/video/upload.ts @@ -0,0 +1,79 @@ +import {createUploadTask, FileSystemUploadType} from 'expo-file-system' +import {AppBskyVideoDefs, BskyAgent} from '@atproto/api' +import {I18n} from '@lingui/core' +import {msg} from '@lingui/macro' +import {nanoid} from 'nanoid/non-secure' + +import {AbortError} from '#/lib/async/cancelable' +import {ServerError} from '#/lib/media/video/errors' +import {CompressedVideo} from '#/lib/media/video/types' +import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared' +import {createVideoEndpointUrl, mimeToExt} from './util' + +export async function uploadVideo({ + video, + agent, + did, + setProgress, + signal, + _, +}: { + video: CompressedVideo + agent: BskyAgent + did: string + setProgress: (progress: number) => void + signal: AbortSignal + _: I18n['_'] +}) { + if (signal.aborted) { + throw new AbortError() + } + await getVideoUploadLimits(agent, _) + + const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { + did, + name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`, + }) + + if (signal.aborted) { + throw new AbortError() + } + const token = await getServiceAuthToken({ + agent, + lxm: 'com.atproto.repo.uploadBlob', + exp: Date.now() / 1000 + 60 * 30, // 30 minutes + }) + const uploadTask = createUploadTask( + uri, + video.uri, + { + headers: { + 'content-type': video.mimeType, + Authorization: `Bearer ${token}`, + }, + httpMethod: 'POST', + uploadType: FileSystemUploadType.BINARY_CONTENT, + }, + p => setProgress(p.totalBytesSent / p.totalBytesExpectedToSend), + ) + + if (signal.aborted) { + throw new AbortError() + } + const res = await uploadTask.uploadAsync() + + if (!res?.body) { + throw new Error('No response') + } + + const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus + + if (!responseBody.jobId) { + throw new ServerError(responseBody.error || _(msg`Failed to upload video`)) + } + + if (signal.aborted) { + throw new AbortError() + } + return responseBody +} diff --git a/src/lib/media/video/upload.web.ts b/src/lib/media/video/upload.web.ts new file mode 100644 index 0000000000..d1b441a369 --- /dev/null +++ b/src/lib/media/video/upload.web.ts @@ -0,0 +1,95 @@ +import {AppBskyVideoDefs} from '@atproto/api' +import {BskyAgent} from '@atproto/api' +import {I18n} from '@lingui/core' +import {msg} from '@lingui/macro' +import {nanoid} from 'nanoid/non-secure' + +import {AbortError} from '#/lib/async/cancelable' +import {ServerError} from '#/lib/media/video/errors' +import {CompressedVideo} from '#/lib/media/video/types' +import {getServiceAuthToken, getVideoUploadLimits} from './upload.shared' +import {createVideoEndpointUrl, mimeToExt} from './util' + +export async function uploadVideo({ + video, + agent, + did, + setProgress, + signal, + _, +}: { + video: CompressedVideo + agent: BskyAgent + did: string + setProgress: (progress: number) => void + signal: AbortSignal + _: I18n['_'] +}) { + if (signal.aborted) { + throw new AbortError() + } + await getVideoUploadLimits(agent, _) + + const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { + did, + name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`, + }) + + let bytes = video.bytes + if (!bytes) { + if (signal.aborted) { + throw new AbortError() + } + bytes = await fetch(video.uri).then(res => res.arrayBuffer()) + } + + if (signal.aborted) { + throw new AbortError() + } + const token = await getServiceAuthToken({ + agent, + lxm: 'com.atproto.repo.uploadBlob', + exp: Date.now() / 1000 + 60 * 30, // 30 minutes + }) + + if (signal.aborted) { + throw new AbortError() + } + const xhr = new XMLHttpRequest() + const res = await new Promise( + (resolve, reject) => { + xhr.upload.addEventListener('progress', e => { + const progress = e.loaded / e.total + setProgress(progress) + }) + xhr.onloadend = () => { + if (signal.aborted) { + reject(new AbortError()) + } else if (xhr.readyState === 4) { + const uploadRes = JSON.parse( + xhr.responseText, + ) as AppBskyVideoDefs.JobStatus + resolve(uploadRes) + } else { + reject(new ServerError(_(msg`Failed to upload video`))) + } + } + xhr.onerror = () => { + reject(new ServerError(_(msg`Failed to upload video`))) + } + xhr.open('POST', uri) + xhr.setRequestHeader('Content-Type', video.mimeType) + xhr.setRequestHeader('Authorization', `Bearer ${token}`) + xhr.send(bytes) + }, + ) + + if (!res.jobId) { + throw new ServerError(res.error || _(msg`Failed to upload video`)) + } + + if (signal.aborted) { + throw new AbortError() + } + return res +} diff --git a/src/state/queries/video/util.ts b/src/lib/media/video/util.ts similarity index 86% rename from src/state/queries/video/util.ts rename to src/lib/media/video/util.ts index 2c1298ab63..87b422c2c9 100644 --- a/src/state/queries/video/util.ts +++ b/src/lib/media/video/util.ts @@ -1,4 +1,3 @@ -import {useMemo} from 'react' import {AtpAgent} from '@atproto/api' import {SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants' @@ -17,12 +16,10 @@ export const createVideoEndpointUrl = ( return url.href } -export function useVideoAgent() { - return useMemo(() => { - return new AtpAgent({ - service: VIDEO_SERVICE, - }) - }, []) +export function createVideoAgent() { + return new AtpAgent({ + service: VIDEO_SERVICE, + }) } export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) { diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index c9bc8fefb2..9a306ee4f4 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -145,6 +145,8 @@ export type LogEvents = { } 'post:mute': {} 'post:unmute': {} + 'post:pin': {} + 'post:unpin': {} 'profile:follow:sampled': { didBecomeMutual: boolean | undefined followeeClout: number | undefined diff --git a/src/lib/themes.ts b/src/lib/themes.ts index eb11872fa3..2aeec31398 100644 --- a/src/lib/themes.ts +++ b/src/lib/themes.ts @@ -2,6 +2,7 @@ import {Platform} from 'react-native' import {tokens} from '#/alf' import {darkPalette, dimPalette, lightPalette} from '#/alf/themes' +import {fontWeight} from '#/alf/tokens' import {colors} from './styles' import type {Theme} from './ThemeContext' @@ -90,195 +91,195 @@ export const defaultTheme: Theme = { '2xl-thin': { fontSize: 18, letterSpacing: tokens.TRACKING, - fontWeight: '400', + fontWeight: fontWeight.normal, }, '2xl': { fontSize: 18, letterSpacing: tokens.TRACKING, - fontWeight: '400', + fontWeight: fontWeight.normal, }, '2xl-medium': { fontSize: 18, letterSpacing: tokens.TRACKING, - fontWeight: '600', + fontWeight: fontWeight.bold, }, '2xl-bold': { fontSize: 18, letterSpacing: tokens.TRACKING, - fontWeight: '600', + fontWeight: fontWeight.bold, }, '2xl-heavy': { fontSize: 18, letterSpacing: tokens.TRACKING, - fontWeight: '800', + fontWeight: fontWeight.heavy, }, 'xl-thin': { fontSize: 17, letterSpacing: tokens.TRACKING, - fontWeight: '400', + fontWeight: fontWeight.normal, }, xl: { fontSize: 17, letterSpacing: tokens.TRACKING, - fontWeight: '400', + fontWeight: fontWeight.normal, }, 'xl-medium': { fontSize: 17, letterSpacing: tokens.TRACKING, - fontWeight: '600', + fontWeight: fontWeight.bold, }, 'xl-bold': { fontSize: 17, letterSpacing: tokens.TRACKING, - fontWeight: '600', + fontWeight: fontWeight.bold, }, 'xl-heavy': { fontSize: 17, letterSpacing: tokens.TRACKING, - fontWeight: '800', + fontWeight: fontWeight.heavy, }, 'lg-thin': { fontSize: 16, letterSpacing: tokens.TRACKING, - fontWeight: '400', + fontWeight: fontWeight.normal, }, lg: { fontSize: 16, letterSpacing: tokens.TRACKING, - fontWeight: '400', + fontWeight: fontWeight.normal, }, 'lg-medium': { fontSize: 16, letterSpacing: tokens.TRACKING, - fontWeight: '600', + fontWeight: fontWeight.bold, }, 'lg-bold': { fontSize: 16, letterSpacing: tokens.TRACKING, - fontWeight: '600', + fontWeight: fontWeight.bold, }, 'lg-heavy': { fontSize: 16, letterSpacing: tokens.TRACKING, - fontWeight: '800', + fontWeight: fontWeight.heavy, }, 'md-thin': { fontSize: 15, letterSpacing: tokens.TRACKING, - fontWeight: '400', + fontWeight: fontWeight.normal, }, md: { fontSize: 15, letterSpacing: tokens.TRACKING, - fontWeight: '400', + fontWeight: fontWeight.normal, }, 'md-medium': { fontSize: 15, letterSpacing: tokens.TRACKING, - fontWeight: '600', + fontWeight: fontWeight.bold, }, 'md-bold': { fontSize: 15, letterSpacing: tokens.TRACKING, - fontWeight: '600', + fontWeight: fontWeight.bold, }, 'md-heavy': { fontSize: 15, letterSpacing: tokens.TRACKING, - fontWeight: '800', + fontWeight: fontWeight.heavy, }, 'sm-thin': { fontSize: 14, letterSpacing: tokens.TRACKING, - fontWeight: '400', + fontWeight: fontWeight.normal, }, sm: { fontSize: 14, letterSpacing: tokens.TRACKING, - fontWeight: '400', + fontWeight: fontWeight.normal, }, 'sm-medium': { fontSize: 14, letterSpacing: tokens.TRACKING, - fontWeight: '600', + fontWeight: fontWeight.bold, }, 'sm-bold': { fontSize: 14, letterSpacing: tokens.TRACKING, - fontWeight: '600', + fontWeight: fontWeight.bold, }, 'sm-heavy': { fontSize: 14, letterSpacing: tokens.TRACKING, - fontWeight: '800', + fontWeight: fontWeight.heavy, }, 'xs-thin': { fontSize: 13, letterSpacing: tokens.TRACKING, - fontWeight: '400', + fontWeight: fontWeight.normal, }, xs: { fontSize: 13, letterSpacing: tokens.TRACKING, - fontWeight: '400', + fontWeight: fontWeight.normal, }, 'xs-medium': { fontSize: 13, letterSpacing: tokens.TRACKING, - fontWeight: '600', + fontWeight: fontWeight.bold, }, 'xs-bold': { fontSize: 13, letterSpacing: tokens.TRACKING, - fontWeight: '600', + fontWeight: fontWeight.bold, }, 'xs-heavy': { fontSize: 13, letterSpacing: tokens.TRACKING, - fontWeight: '800', + fontWeight: fontWeight.heavy, }, 'title-2xl': { fontSize: 34, letterSpacing: tokens.TRACKING, - fontWeight: '600', + fontWeight: fontWeight.bold, }, 'title-xl': { fontSize: 28, letterSpacing: tokens.TRACKING, - fontWeight: '600', + fontWeight: fontWeight.bold, }, 'title-lg': { fontSize: 22, - fontWeight: '600', + fontWeight: fontWeight.bold, }, title: { - fontWeight: '600', + fontWeight: fontWeight.bold, fontSize: 20, letterSpacing: tokens.TRACKING, }, 'title-sm': { - fontWeight: '600', + fontWeight: fontWeight.bold, fontSize: 17, letterSpacing: tokens.TRACKING, }, 'post-text': { fontSize: 16, letterSpacing: tokens.TRACKING, - fontWeight: '400', + fontWeight: fontWeight.normal, }, 'post-text-lg': { fontSize: 20, letterSpacing: tokens.TRACKING, - fontWeight: '400', + fontWeight: fontWeight.normal, }, 'button-lg': { - fontWeight: '600', + fontWeight: fontWeight.bold, fontSize: 18, letterSpacing: tokens.TRACKING, }, button: { - fontWeight: '600', + fontWeight: fontWeight.bold, fontSize: 14, letterSpacing: tokens.TRACKING, }, diff --git a/src/screens/Messages/List/index.tsx b/src/screens/Messages/ChatList.tsx similarity index 98% rename from src/screens/Messages/List/index.tsx rename to src/screens/Messages/ChatList.tsx index efd717f0b4..9912456e13 100644 --- a/src/screens/Messages/List/index.tsx +++ b/src/screens/Messages/ChatList.tsx @@ -22,7 +22,6 @@ import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {DialogControlProps, useDialogControl} from '#/components/Dialog' import {NewChat} from '#/components/dms/dialogs/NewChatDialog' -import {MessagesNUX} from '#/components/dms/MessagesNUX' import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus' import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotateCounterClockwise' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' @@ -33,7 +32,7 @@ import {Link} from '#/components/Link' import {ListFooter} from '#/components/Lists' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' -import {ChatListItem} from './ChatListItem' +import {ChatListItem} from './components/ChatListItem' type Props = NativeStackScreenProps @@ -151,8 +150,6 @@ export function MessagesScreen({navigation, route}: Props) { if (conversations.length < 1) { return ( - - {gtMobile ? ( - {!gtMobile && ( ( return ( - - - - - + + + + + + + ) }, diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx index 663418f220..73472ec332 100644 --- a/src/screens/Onboarding/StepProfile/index.tsx +++ b/src/screens/Onboarding/StepProfile/index.tsx @@ -32,6 +32,7 @@ import { import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' +import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import {IconCircle} from '#/components/IconCircle' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' import {CircleInfo_Stroke2_Corner0_Rounded} from '#/components/icons/CircleInfo' @@ -89,15 +90,18 @@ export function StepProfile() { requestNotificationsPermission('StartOnboarding') }, [gate, requestNotificationsPermission]) + const sheetWrapper = useSheetWrapper() const openPicker = React.useCallback( async (opts?: ImagePickerOptions) => { - const response = await launchImageLibraryAsync({ - exif: false, - mediaTypes: MediaTypeOptions.Images, - quality: 1, - ...opts, - legacy: true, - }) + const response = await sheetWrapper( + launchImageLibraryAsync({ + exif: false, + mediaTypes: MediaTypeOptions.Images, + quality: 1, + ...opts, + legacy: true, + }), + ) return (response.assets ?? []) .slice(0, 1) @@ -121,7 +125,7 @@ export function StepProfile() { size: getDataUriSize(image.uri), })) }, - [_, setError], + [_, setError, sheetWrapper], ) const onContinue = React.useCallback(async () => { @@ -168,9 +172,11 @@ export function StepProfile() { setError('') - const items = await openPicker({ - aspect: [1, 1], - }) + const items = await sheetWrapper( + openPicker({ + aspect: [1, 1], + }), + ) let image = items[0] if (!image) return @@ -196,7 +202,13 @@ export function StepProfile() { image, useCreatedAvatar: false, })) - }, [requestPhotoAccessIfNeeded, setAvatar, openPicker, setError]) + }, [ + requestPhotoAccessIfNeeded, + setAvatar, + openPicker, + setError, + sheetWrapper, + ]) const onSecondaryPress = React.useCallback(() => { if (avatar.useCreatedAvatar) { @@ -286,7 +298,6 @@ export function StepProfile() { - {sanitizeDisplayName( profile.displayName || sanitizeHandle(profile.handle), diff --git a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx index 7b44e58693..8c95413a8c 100644 --- a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx +++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx @@ -25,7 +25,7 @@ import {usePreferencesQuery} from '#/state/queries/preferences' import {useRequireAuth, useSession} from '#/state/session' import {ProfileMenu} from '#/view/com/profile/ProfileMenu' import * as Toast from '#/view/com/util/Toast' -import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf' +import {atoms as a, tokens, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import {DialogOuterProps} from '#/components/Dialog' import { @@ -61,7 +61,6 @@ let ProfileHeaderLabeler = ({ const profile: Shadow = useProfileShadow(profileUnshadowed) const t = useTheme() - const {gtMobile} = useBreakpoints() const {_} = useLingui() const {currentAccount, hasSession} = useSession() const {openModal} = useModalControls() @@ -167,7 +166,7 @@ let ProfileHeaderLabeler = ({ style={[a.px_lg, a.pt_md, a.pb_sm]} pointerEvents={isIOS ? 'auto' : 'box-none'}> {isMe ? ( - - {isProcessing ? ( - <> - {processingState} - - - - - ) : ( - - - {canPost ? ( - - ) : ( - - - Post - + + + + + + + + {isProcessing ? ( + <> + {processingState} + + - )} + + ) : ( + + + {canPost ? ( + + ) : ( + + + Post + + + )} + + )} + + + {isAltTextRequiredAndMissing && ( + + + + + + One or more images is missing alt text. + )} - + setError('')} + clearVideo={clearVideo} + /> + + + {replyTo ? : undefined} - {isAltTextRequiredAndMissing && ( - - - + + onPressPublish()} + onNewLink={onNewLink} + onError={setError} + accessible={true} + accessibilityLabel={_(msg`Write post`)} + accessibilityHint={_( + msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`, + )} + /> + + + + {images.length === 0 && extLink && ( + + { + if (extGif) { + dispatch({type: 'embed_remove_gif'}) + } else { + dispatch({type: 'embed_remove_link'}) + } + setExtLink(undefined) + setExtGif(undefined) + }} + /> + - - One or more images is missing alt text. - + )} + + {hasVideo && ( + + {videoState.asset && + (videoState.status === 'compressing' ? ( + + ) : videoState.video ? ( + + ) : null)} + + dispatch({ + type: 'embed_update_video', + videoAction: { + type: 'update_alt_text', + altText, + signal: videoState.abortController.signal, + }, + }) + } + captions={videoState.captions} + setCaptions={updater => { + dispatch({ + type: 'embed_update_video', + videoAction: { + type: 'update_captions', + updater, + signal: videoState.abortController.signal, + }, + }) + }} + Portal={Portal.Portal} + /> + + )} + + + {quote ? ( + + + + + {quote.uri !== initQuote?.uri && ( + { + dispatch({type: 'embed_remove_quote'}) + setQuote(undefined) + }} + /> + )} + + ) : null} - )} - setError('')} - videoUploadDispatch={videoUploadDispatch} - /> - - - {replyTo ? : undefined} + + + {replyTo ? null : ( + + )} - - onPressPublish()} - onNewLink={onNewLink} - onError={setError} - accessible={true} - accessibilityLabel={_(msg`Write post`)} - accessibilityHint={_( - msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`, + + {videoState.status !== 'idle' && videoState.status !== 'done' ? ( + + ) : ( + + + 0} + setError={setError} + /> + + + {!isMobile ? ( + + ) : null} + )} - /> - - - - {images.length === 0 && extLink && ( - - { - setExtLink(undefined) - setExtGif(undefined) - }} - /> - - )} - - {hasVideo && ( - - {videoUploadState.asset && - (videoUploadState.status === 'compressing' ? ( - - ) : videoUploadState.video ? ( - - ) : null)} - - - )} - - - {quote ? ( - - - - - {quote.uri !== initQuote?.uri && ( - setQuote(undefined)} /> - )} - - ) : null} + + + + - - - - {replyTo ? null : ( - - )} - - {videoUploadState.status !== 'idle' && - videoUploadState.status !== 'done' ? ( - - ) : ( - - - - - - {!isMobile ? ( - - ) : null} - - )} - - - - - - + + + + ) } @@ -1051,40 +1139,31 @@ const styles = StyleSheet.create({ marginHorizontal: 10, marginBottom: 4, }, - bottomBar: { - flexDirection: 'row', - paddingVertical: 4, - // should be 8 but due to visual alignment we have to fudge it - paddingLeft: 7, - paddingRight: 16, - alignItems: 'center', - borderTopWidth: StyleSheet.hairlineWidth, - }, }) function ErrorBanner({ error: standardError, - videoUploadState, + videoState, clearError, - videoUploadDispatch, + clearVideo, }: { error: string - videoUploadState: VideoUploadState + videoState: VideoState | NoVideoState clearError: () => void - videoUploadDispatch: VideoUploadDispatch + clearVideo: () => void }) { const t = useTheme() const {_} = useLingui() const videoError = - videoUploadState.status !== 'idle' ? videoUploadState.error : undefined + videoState.status === 'error' ? videoState.error : undefined const error = standardError || videoError const onClearError = () => { if (standardError) { clearError() } else { - videoUploadDispatch({type: 'Reset'}) + clearVideo() } } @@ -1119,7 +1198,7 @@ function ErrorBanner({ - {videoError && videoUploadState.jobStatus?.jobId && ( + {videoError && videoState.jobId && ( - Job ID: {videoUploadState.jobStatus.jobId} + Job ID: {videoState.jobId} )} @@ -1154,12 +1233,10 @@ function ToolbarWrapper({ ) } -function VideoUploadToolbar({state}: {state: VideoUploadState}) { +function VideoUploadToolbar({state}: {state: VideoState}) { const t = useTheme() const {_} = useLingui() - const progress = state.jobStatus?.progress - ? state.jobStatus.progress / 100 - : state.progress + const progress = state.progress const shouldRotate = state.status === 'processing' && (progress === 0 || progress === 1) let wheelProgress = shouldRotate ? 0.33 : progress @@ -1195,16 +1272,15 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) { case 'processing': text = _('Processing video...') break + case 'error': + text = _('Error') + wheelProgress = 100 + break case 'done': text = _('Video uploaded') break } - if (state.error) { - text = _('Error') - wheelProgress = 100 - } - return ( @@ -1212,7 +1288,11 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) { size={30} borderWidth={1} borderColor={t.atoms.border_contrast_low.borderColor} - color={state.error ? t.palette.negative_500 : t.palette.primary_500} + color={ + state.status === 'error' + ? t.palette.negative_500 + : t.palette.primary_500 + } progress={wheelProgress} /> diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx index a05607c76c..90d20d94f7 100644 --- a/src/view/com/composer/GifAltText.tsx +++ b/src/view/com/composer/GifAltText.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useState} from 'react' +import React, {useState} from 'react' import {TouchableOpacity, View} from 'react-native' import {AppBskyEmbedExternal} from '@atproto/api' import {msg, Trans} from '@lingui/macro' @@ -11,15 +11,18 @@ import { EmbedPlayerParams, parseEmbedPlayerFromUrl, } from '#/lib/strings/embed-player' -import {enforceLen} from '#/lib/strings/helpers' import {isAndroid} from '#/platform/detection' import {Gif} from '#/state/queries/tenor' +import {AltTextCounterWrapper} from '#/view/com/composer/AltTextCounterWrapper' import {atoms as a, native, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' +import {DialogControlProps} from '#/components/Dialog' import * as TextField from '#/components/forms/TextField' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {PortalComponent} from '#/components/Portal' import {Text} from '#/components/Typography' import {GifEmbed} from '../util/post-embeds/GifEmbed' import {AltTextReminder} from './photos/Gallery' @@ -28,10 +31,12 @@ export function GifAltText({ link: linkProp, gif, onSubmit, + Portal, }: { link: ExternalEmbedDraft gif?: Gif onSubmit: (alt: string) => void + Portal: PortalComponent }) { const control = Dialog.useDialogControl() const {_} = useLingui() @@ -49,18 +54,11 @@ export function GifAltText({ } }, [linkProp]) - const onPressSubmit = useCallback( - (alt: string) => { - control.close(() => { - onSubmit(alt) - }) - }, - [onSubmit, control], - ) + const parsedAlt = parseAltFromGIFDescription(link.description) + const [altText, setAltText] = useState(parsedAlt.alt) if (!gif || !params) return null - const parsedAlt = parseAltFromGIFDescription(link.description) return ( <> + onClose={() => { + onSubmit(altText) + }} + Portal={Portal}> @@ -113,61 +115,83 @@ export function GifAltText({ } function AltTextInner({ - onSubmit, + altText, + setAltText, + control, link, params, - initialValue: initalValue, }: { - onSubmit: (text: string) => void + altText: string + setAltText: (text: string) => void + control: DialogControlProps link: AppBskyEmbedExternal.ViewExternal params: EmbedPlayerParams - initialValue: string }) { - const {_} = useLingui() - const [altText, setAltText] = useState(initalValue) - const control = Dialog.useDialogContext() - - const onPressSubmit = useCallback(() => { - onSubmit(altText) - }, [onSubmit, altText]) + const t = useTheme() + const {_, i18n} = useLingui() return ( - - - Descriptive alt text - - - - setAltText(enforceLen(text, MAX_ALT_TEXT)) - } - value={altText} - multiline - numberOfLines={3} - autoFocus - onKeyPress={({nativeEvent}) => { - if (nativeEvent.key === 'Escape') { - control.close() - } - }} - /> - + + + + Descriptive alt text + + + { + setAltText(text) + }} + defaultValue={altText} + multiline + numberOfLines={3} + autoFocus + onKeyPress={({nativeEvent}) => { + if (nativeEvent.key === 'Escape') { + control.close() + } + }} + /> + + + + {altText.length > MAX_ALT_TEXT && ( + + + + + Alt text will be truncated. Limit:{' '} + {i18n.number(MAX_ALT_TEXT)} characters. + + + + )} - + + + + {/* below the text input to force tab order */} @@ -185,6 +209,8 @@ function AltTextInner({ + {/* Maybe fix this later -h */} + {isAndroid ? : null} ) } diff --git a/src/view/com/composer/char-progress/CharProgress.tsx b/src/view/com/composer/char-progress/CharProgress.tsx index a205fe0963..1c93274100 100644 --- a/src/view/com/composer/char-progress/CharProgress.tsx +++ b/src/view/com/composer/char-progress/CharProgress.tsx @@ -1,48 +1,55 @@ import React from 'react' -import {View} from 'react-native' +import {StyleProp, TextStyle, View, ViewStyle} from 'react-native' // @ts-ignore no type definition -prf import ProgressCircle from 'react-native-progress/Circle' // @ts-ignore no type definition -prf import ProgressPie from 'react-native-progress/Pie' -import {MAX_GRAPHEME_LENGTH} from 'lib/constants' -import {usePalette} from 'lib/hooks/usePalette' -import {s} from 'lib/styles' +import {MAX_GRAPHEME_LENGTH} from '#/lib/constants' +import {usePalette} from '#/lib/hooks/usePalette' +import {atoms as a} from '#/alf' import {Text} from '../../util/text/Text' -const DANGER_LENGTH = MAX_GRAPHEME_LENGTH - -export function CharProgress({count}: {count: number}) { +export function CharProgress({ + count, + max, + style, + textStyle, + size, +}: { + count: number + max?: number + style?: StyleProp + textStyle?: StyleProp + size?: number +}) { + const maxLength = max || MAX_GRAPHEME_LENGTH const pal = usePalette('default') - const textColor = count > DANGER_LENGTH ? '#e60000' : pal.colors.text - const circleColor = count > DANGER_LENGTH ? '#e60000' : pal.colors.link + const textColor = count > maxLength ? '#e60000' : pal.colors.text + const circleColor = count > maxLength ? '#e60000' : pal.colors.link return ( - <> - - {MAX_GRAPHEME_LENGTH - count} + + + {maxLength - count} - - {count > DANGER_LENGTH ? ( - - ) : ( - - )} - - + {count > maxLength ? ( + + ) : ( + + )} + ) } diff --git a/src/view/com/composer/photos/EditImageDialog.web.tsx b/src/view/com/composer/photos/EditImageDialog.web.tsx index 0afb83ed96..ebe528abc0 100644 --- a/src/view/com/composer/photos/EditImageDialog.web.tsx +++ b/src/view/com/composer/photos/EditImageDialog.web.tsx @@ -20,6 +20,7 @@ import {EditImageDialogProps} from './EditImageDialog' export const EditImageDialog = (props: EditImageDialogProps) => { return ( + ) diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index 369f08d745..3958a85c0d 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -21,6 +21,8 @@ import {ComposerImage, cropImage} from '#/state/gallery' import {Text} from '#/view/com/util/text/Text' import {useTheme} from '#/alf' import * as Dialog from '#/components/Dialog' +import {PortalComponent} from '#/components/Portal' +import {ComposerAction} from '../state/composer' import {EditImageDialog} from './EditImageDialog' import {ImageAltTextDialog} from './ImageAltTextDialog' @@ -28,7 +30,8 @@ const IMAGE_GAP = 8 interface GalleryProps { images: ComposerImage[] - onChange: (next: ComposerImage[]) => void + dispatch: (action: ComposerAction) => void + Portal: PortalComponent } export let Gallery = (props: GalleryProps): React.ReactNode => { @@ -56,7 +59,12 @@ interface GalleryInnerProps extends GalleryProps { containerInfo: Dimensions } -const GalleryInner = ({images, containerInfo, onChange}: GalleryInnerProps) => { +const GalleryInner = ({ + images, + containerInfo, + dispatch, + Portal, +}: GalleryInnerProps) => { const {isMobile} = useWebMediaQueries() const {altTextControlStyle, imageControlsStyle, imageStyle} = @@ -96,7 +104,7 @@ const GalleryInner = ({images, containerInfo, onChange}: GalleryInnerProps) => { return images.length !== 0 ? ( <> - {images.map((image, index) => { + {images.map(image => { return ( { imageControlsStyle={imageControlsStyle} imageStyle={imageStyle} onChange={next => { - onChange( - images.map(i => (i.source === image.source ? next : i)), - ) + dispatch({type: 'embed_update_image', image: next}) }} onRemove={() => { - const next = images.slice() - next.splice(index, 1) - - onChange(next) + dispatch({type: 'embed_remove_image', image}) }} + Portal={Portal} /> ) })} @@ -131,6 +135,7 @@ type GalleryItemProps = { imageStyle?: ViewStyle onChange: (next: ComposerImage) => void onRemove: () => void + Portal: PortalComponent } const GalleryItem = ({ @@ -140,6 +145,7 @@ const GalleryItem = ({ imageStyle, onChange, onRemove, + Portal, }: GalleryItemProps): React.ReactNode => { const {_} = useLingui() const t = useTheme() @@ -234,6 +240,7 @@ const GalleryItem = ({ control={altTextControl} image={image} onChange={onChange} + Portal={Portal} /> void + Portal: PortalComponent } -export const ImageAltTextDialog = (props: Props): React.ReactNode => { - return ( - - +export const ImageAltTextDialog = ({ + control, + image, + onChange, + Portal, +}: Props): React.ReactNode => { + const [altText, setAltText] = React.useState(image.alt) - + return ( + { + onChange({ + ...image, + alt: enforceLen(altText, MAX_ALT_TEXT, true), + }) + }} + Portal={Portal}> + + ) } const ImageAltTextInner = ({ + altText, + setAltText, control, image, - onChange, -}: Props): React.ReactNode => { - const {_} = useLingui() +}: { + altText: string + setAltText: (text: string) => void + control: DialogControlProps + image: Props['image'] +}): React.ReactNode => { + const {_, i18n} = useLingui() const t = useTheme() - const windim = useWindowDimensions() - const [altText, setAltText] = React.useState(image.alt) - - const onPressSubmit = React.useCallback(() => { - control.close() - onChange({...image, alt: altText.trim()}) - }, [control, image, altText, onChange]) - const imageStyle = React.useMemo(() => { const maxWidth = isWeb ? 450 : windim.width const source = image.transformed ?? image.source @@ -89,33 +112,62 @@ const ImageAltTextInner = ({ - - - Descriptive alt text - - - setAltText(text)} - value={altText} - multiline - numberOfLines={3} - autoFocus - /> - + + + + Descriptive alt text + + + { + setAltText(text) + }} + defaultValue={altText} + multiline + numberOfLines={3} + autoFocus + /> + + + + {altText.length > MAX_ALT_TEXT && ( + + + + + Alt text will be truncated. Limit: {i18n.number(MAX_ALT_TEXT)}{' '} + characters. + + + + )} - + + + + + {/* Maybe fix this later -h */} + {isAndroid ? : null} ) } diff --git a/src/view/com/composer/photos/SelectGifBtn.tsx b/src/view/com/composer/photos/SelectGifBtn.tsx index d13df0a110..d482e07837 100644 --- a/src/view/com/composer/photos/SelectGifBtn.tsx +++ b/src/view/com/composer/photos/SelectGifBtn.tsx @@ -9,14 +9,16 @@ import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' import {GifSelectDialog} from '#/components/dialogs/GifSelect' import {GifSquare_Stroke2_Corner0_Rounded as GifIcon} from '#/components/icons/Gif' +import {PortalComponent} from '#/components/Portal' type Props = { onClose: () => void onSelectGif: (gif: Gif) => void disabled?: boolean + Portal?: PortalComponent } -export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) { +export function SelectGifBtn({onClose, onSelectGif, disabled, Portal}: Props) { const {_} = useLingui() const ref = useRef<{open: () => void}>(null) const t = useTheme() @@ -46,6 +48,7 @@ export function SelectGifBtn({onClose, onSelectGif, disabled}: Props) { controlRef={ref} onClose={onClose} onSelectGif={onSelectGif} + Portal={Portal} /> ) diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx index 34ead3d9a9..37bfbafe60 100644 --- a/src/view/com/composer/photos/SelectPhotoBtn.tsx +++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx @@ -9,6 +9,7 @@ import {isNative} from '#/platform/detection' import {ComposerImage, createComposerImage} from '#/state/gallery' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' +import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image' type Props = { @@ -21,23 +22,26 @@ export function SelectPhotoBtn({size, disabled, onAdd}: Props) { const {_} = useLingui() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() const t = useTheme() + const sheetWrapper = useSheetWrapper() const onPressSelectPhotos = useCallback(async () => { if (isNative && !(await requestPhotoAccessIfNeeded())) { return } - const images = await openPicker({ - selectionLimit: 4 - size, - allowsMultipleSelection: true, - }) + const images = await sheetWrapper( + openPicker({ + selectionLimit: 4 - size, + allowsMultipleSelection: true, + }), + ) const results = await Promise.all( images.map(img => createComposerImage(img)), ) onAdd(results) - }, [requestPhotoAccessIfNeeded, size, onAdd]) + }, [requestPhotoAccessIfNeeded, size, onAdd, sheetWrapper]) return ( - + @@ -198,9 +198,7 @@ function SubtitleFileRow({ language: string file: File otherLanguages: {code2: string; code3: string; name: string}[] - setCaptions: React.Dispatch< - React.SetStateAction<{lang: string; file: File}[]> - > + setCaptions: (updater: (prev: CaptionsTrack[]) => CaptionsTrack[]) => void style: StyleProp }) { const {_} = useLingui() diff --git a/src/view/com/post-thread/PostThreadComposePrompt.tsx b/src/view/com/post-thread/PostThreadComposePrompt.tsx index 67981618e1..c5582922a6 100644 --- a/src/view/com/post-thread/PostThreadComposePrompt.tsx +++ b/src/view/com/post-thread/PostThreadComposePrompt.tsx @@ -5,12 +5,12 @@ import {useLingui} from '@lingui/react' import {PressableScale} from '#/lib/custom-animations/PressableScale' import {useHaptics} from '#/lib/haptics' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {useHapticsDisabled} from '#/state/preferences' import {useProfileQuery} from '#/state/queries/profile' import {useSession} from '#/state/session' import {UserAvatar} from '#/view/com/util/UserAvatar' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {useInteractionState} from '#/components/hooks/useInteractionState' import {Text} from '#/components/Typography' export function PostThreadComposePrompt({ @@ -21,10 +21,15 @@ export function PostThreadComposePrompt({ const {currentAccount} = useSession() const {data: profile} = useProfileQuery({did: currentAccount?.did}) const {_} = useLingui() - const {isTabletOrDesktop} = useWebMediaQueries() + const {gtMobile} = useBreakpoints() const t = useTheme() const playHaptics = useHaptics() const isHapticsDisabled = useHapticsDisabled() + const { + state: hovered, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() const onPress = () => { playHaptics('Light') @@ -42,13 +47,15 @@ export function PostThreadComposePrompt({ accessibilityLabel={_(msg`Compose reply`)} accessibilityHint={_(msg`Opens composer`)} style={[ - {paddingTop: 8, paddingBottom: isTabletOrDesktop ? 8 : 11}, + gtMobile ? a.py_xs : {paddingTop: 8, paddingBottom: 11}, a.px_sm, a.border_t, t.atoms.border_contrast_low, t.atoms.bg, ]} - onPress={onPress}> + onPress={onPress} + onHoverIn={onHoverIn} + onHoverOut={onHoverOut}> diff --git a/src/view/com/post-thread/PostThreadFollowBtn.tsx b/src/view/com/post-thread/PostThreadFollowBtn.tsx index b75731f6f3..1808e91a31 100644 --- a/src/view/com/post-thread/PostThreadFollowBtn.tsx +++ b/src/view/com/post-thread/PostThreadFollowBtn.tsx @@ -1,14 +1,9 @@ import React from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' import {AppBskyActorDefs} from '@atproto/api' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' -import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {s} from '#/lib/styles' import {logger} from '#/logger' import {Shadow, useProfileShadow} from '#/state/cache/profile-shadow' import { @@ -16,8 +11,11 @@ import { useProfileQuery, } from '#/state/queries/profile' import {useRequireAuth} from '#/state/session' -import {Text} from '#/view/com/util/text/Text' import * as Toast from '#/view/com/util/Toast' +import {atoms as a, useBreakpoints} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' export function PostThreadFollowBtn({did}: {did: string}) { const {data: profile, isLoading} = useProfileQuery({did}) @@ -36,9 +34,7 @@ function PostThreadFollowBtnLoaded({ }) { const navigation = useNavigation() const {_} = useLingui() - const pal = usePalette('default') - const palInverted = usePalette('inverted') - const {isTabletOrDesktop} = useWebMediaQueries() + const {gtMobile} = useBreakpoints() const profile: Shadow = useProfileShadow(profileUnshadowed) const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( @@ -113,51 +109,32 @@ function PostThreadFollowBtnLoaded({ if (!showFollowBtn) return null return ( - - - - {isTabletOrDesktop && ( - - )} - - {!isFollowing ? ( - isFollowedBy ? ( - Follow Back - ) : ( - Follow - ) - ) : ( - Following - )} - - - - + ) } - -const styles = StyleSheet.create({ - btnOuter: { - marginLeft: 'auto', - }, - btn: { - flexDirection: 'row', - borderRadius: 50, - paddingVertical: 8, - paddingHorizontal: 14, - }, -}) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index ead9df1161..4701f225c4 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -14,14 +14,12 @@ import {useLingui} from '@lingui/react' import {MAX_POST_LINES} from '#/lib/constants' import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {makeProfileLink} from '#/lib/routes/links' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {countLines} from '#/lib/strings/helpers' import {niceDate} from '#/lib/strings/time' import {s} from '#/lib/styles' -import {isWeb} from '#/platform/detection' import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow' import {useLanguagePrefs} from '#/state/preferences' import {useOpenLink} from '#/state/preferences/in-app-browser' @@ -30,9 +28,10 @@ import {useSession} from '#/state/session' import {useComposerControls} from '#/state/shell/composer' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn' -import {atoms as a} from '#/alf' +import {atoms as a, useTheme} from '#/alf' import {AppModerationCause} from '#/components/Pills' import {RichText} from '#/components/RichText' +import {Text as NewText} from '#/components/Typography' import {ContentHider} from '../../../components/moderation/ContentHider' import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe' import {PostAlerts} from '../../../components/moderation/PostAlerts' @@ -180,6 +179,7 @@ let PostThreadItemLoaded = ({ hideTopBorder?: boolean threadgateRecord?: AppBskyFeedThreadgate.Record }): React.ReactNode => { + const t = useTheme() const pal = usePalette('default') const {_, i18n} = useLingui() const langPrefs = useLanguagePrefs() @@ -268,8 +268,14 @@ let PostThreadItemLoaded = ({ return ( <> {rootUri !== post.uri && ( - - + + - - - - - - - - - {sanitizeDisplayName( - post.author.displayName || - sanitizeHandle(post.author.handle), - moderation.ui('displayName'), - )} - - - - - - - {sanitizeHandle(post.author.handle, '@')} - - - + a.px_lg, + t.atoms.border_contrast_low, + // root post styles + rootUri === post.uri && [a.pt_lg], + ]}> + + + + + + {sanitizeDisplayName( + post.author.displayName || + sanitizeHandle(post.author.handle), + moderation.ui('displayName'), + )} + + + + + {sanitizeHandle(post.author.handle, '@')} + + {currentAccount?.did !== post.author.did && ( - + + + )} - - + + + childContainerStyle={[a.pt_sm]}> {richText?.text ? ( - - - + ) : undefined} {post.embed && ( - + + {post.repostCount != null && post.repostCount !== 0 ? ( - - + - + style={[a.text_md, t.atoms.text_contrast_medium]}> + {formatCount(i18n, post.repostCount)} - {' '} + {' '} - + ) : null} {post.quoteCount != null && post.quoteCount !== 0 && !post.viewer?.embeddingDisabled ? ( - - + - + style={[a.text_md, t.atoms.text_contrast_medium]}> + {formatCount(i18n, post.quoteCount)} - {' '} + {' '} - + ) : null} {post.likeCount != null && post.likeCount !== 0 ? ( - - + - + style={[a.text_md, t.atoms.text_contrast_medium]}> + {formatCount(i18n, post.likeCount)} - {' '} + {' '} - + ) : null} ) : null} - + - + {!isThreadedChild && showParentReplyLine && ( {/* If we are in threaded mode, the avatar is rendered in PostMeta */} {!isThreadedChild && ( - + )} - + - + {richText?.text ? ( - + ) { - const {isMobile} = useWebMediaQueries() - const pal = usePalette('default') + const t = useTheme() if (treeView && depth > 0) { return ( {Array.from(Array(depth - 1)).map((_, n: number) => ( ))} {children} @@ -691,8 +682,9 @@ function PostOuterWrapper({ return ( - + + {niceDate(i18n, post.indexedAt)} - + {isRootPost && ( )} {needsTranslation && ( <> - · + + · + - Translate - + )} @@ -773,31 +760,9 @@ const styles = StyleSheet.create({ borderTopWidth: StyleSheet.hairlineWidth, paddingLeft: 8, }, - outerHighlighted: { - borderTopWidth: 0, - paddingTop: 4, - paddingLeft: 8, - paddingRight: 8, - }, - outerHighlightedRoot: { - borderTopWidth: StyleSheet.hairlineWidth, - paddingTop: 16, - }, noTopBorder: { borderTopWidth: 0, }, - layout: { - flexDirection: 'row', - paddingHorizontal: 8, - }, - layoutAvi: {}, - layoutContent: { - flex: 1, - marginLeft: 10, - }, - layoutContentThreaded: { - flex: 1, - }, meta: { flexDirection: 'row', paddingVertical: 2, @@ -805,42 +770,6 @@ const styles = StyleSheet.create({ metaExpandedLine1: { paddingVertical: 0, }, - alert: { - marginBottom: 6, - }, - postTextContainer: { - flexDirection: 'row', - alignItems: 'center', - flexWrap: 'wrap', - paddingBottom: 4, - paddingRight: 10, - overflow: 'hidden', - }, - postTextLargeContainer: { - paddingHorizontal: 0, - paddingRight: 0, - paddingBottom: 10, - }, - translateLink: { - marginBottom: 6, - }, - contentHider: { - marginBottom: 6, - }, - contentHiderChild: { - marginTop: 6, - }, - expandedInfo: { - flexDirection: 'row', - padding: 10, - borderTopWidth: StyleSheet.hairlineWidth, - borderBottomWidth: StyleSheet.hairlineWidth, - marginTop: 5, - marginBottom: 10, - }, - expandedInfoItem: { - marginRight: 10, - }, loadMore: { flexDirection: 'row', alignItems: 'center', diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx index aaa8a93e6b..f01fb5e172 100644 --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -1,12 +1,10 @@ import React, {memo} from 'react' -import {TouchableOpacity} from 'react-native' import {AppBskyActorDefs} from '@atproto/api' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' -import {HITSLOP_10} from '#/lib/constants' +import {HITSLOP_20} from '#/lib/constants' import {makeProfileLink} from '#/lib/routes/links' import {shareUrl} from '#/lib/sharing' import {toShareUrl} from '#/lib/strings/url-helpers' @@ -22,8 +20,9 @@ import { import {useSession} from '#/state/session' import {EventStopper} from '#/view/com/util/EventStopper' import * as Toast from '#/view/com/util/Toast' -import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonIcon} from '#/components/Button' import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' +import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle' import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' @@ -45,9 +44,6 @@ let ProfileMenu = ({ }): React.ReactNode => { const {_} = useLingui() const {currentAccount, hasSession} = useSession() - const t = useTheme() - // TODO ALF this - const alf = useTheme() const {openModal} = useModalControls() const reportDialogControl = useReportDialogControl() const queryClient = useQueryClient() @@ -175,28 +171,19 @@ let ProfileMenu = ({ - {({props, state}) => { + {({props}) => { return ( - - - + label={_(msg`More options`)} + hitSlop={HITSLOP_20} + variant="solid" + color="secondary" + size="small" + shape="round"> + + ) }} diff --git a/src/view/com/util/Html.tsx b/src/view/com/util/Html.tsx deleted file mode 100644 index f77fb16034..0000000000 --- a/src/view/com/util/Html.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import * as React from 'react' -import {StyleSheet, View} from 'react-native' -import { - H1 as ExpoH1, - H2 as ExpoH2, - H3 as ExpoH3, - H4 as ExpoH4, -} from '@expo/html-elements' - -import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {useTheme} from '#/lib/ThemeContext' -import {TextLink} from './Link' -import {Text} from './text/Text' - -/** - * These utilities are used to define long documents in an html-like - * DSL. See for instance /locale/en/privacy-policy.tsx - */ - -interface IsChildProps { - isChild?: boolean -} - -// type ReactNodeWithIsChildProp = -// | React.ReactElement -// | React.ReactElement[] -// | React.ReactNode - -export function H1({children}: React.PropsWithChildren<{}>) { - const styles = useStyles() - const pal = usePalette('default') - const typography = useTheme().typography['title-xl'] - // @ts-ignore Expo's TextStyle definition seems to have gotten away from RN's -prf - return {children} -} - -export function H2({children}: React.PropsWithChildren<{}>) { - const styles = useStyles() - const pal = usePalette('default') - const typography = useTheme().typography['title-lg'] - // @ts-ignore Expo's TextStyle definition seems to have gotten away from RN's -prf - return {children} -} - -export function H3({children}: React.PropsWithChildren<{}>) { - const styles = useStyles() - const pal = usePalette('default') - const typography = useTheme().typography.title - // @ts-ignore Expo's TextStyle definition seems to have gotten away from RN's -prf - return {children} -} - -export function H4({children}: React.PropsWithChildren<{}>) { - const styles = useStyles() - const pal = usePalette('default') - const typography = useTheme().typography['title-sm'] - // @ts-ignore Expo's TextStyle definition seems to have gotten away from RN's -prf - return {children} -} - -export function P({children}: React.PropsWithChildren<{}>) { - const styles = useStyles() - const pal = usePalette('default') - return ( - - {children} - - ) -} - -export function UL({children, isChild}: React.PropsWithChildren) { - const styles = useStyles() - return ( - - {markChildProps(children)} - - ) -} - -export function OL({children, isChild}: React.PropsWithChildren) { - const styles = useStyles() - return ( - - {markChildProps(children)} - - ) -} - -export function LI({ - children, - value, -}: React.PropsWithChildren<{value?: string}>) { - const styles = useStyles() - const pal = usePalette('default') - return ( - - {value || <>•} - - {markChildProps(children)} - - - ) -} - -export function A({children, href}: React.PropsWithChildren<{href: string}>) { - const styles = useStyles() - const pal = usePalette('default') - return ( - - ) -} - -export function STRONG({children}: React.PropsWithChildren<{}>) { - const pal = usePalette('default') - return ( - - {children} - - ) -} - -export function EM({children}: React.PropsWithChildren<{}>) { - const styles = useStyles() - const pal = usePalette('default') - return ( - - {children} - - ) -} - -function markChildProps(children: React.ReactNode) { - return React.Children.map(children, child => { - if (React.isValidElement(child)) { - return React.cloneElement( - child as React.ReactElement, - {isChild: true}, - ) - } - return child - }) -} - -const useStyles = () => { - const {isDesktop} = useWebMediaQueries() - return StyleSheet.create({ - h1: { - marginTop: 20, - marginBottom: 10, - letterSpacing: 0.8, - }, - h2: { - marginTop: 20, - marginBottom: 10, - letterSpacing: 0.8, - }, - h3: { - marginTop: 0, - marginBottom: 10, - }, - h4: { - marginTop: 0, - marginBottom: 10, - fontWeight: '600', - }, - p: { - marginBottom: 10, - }, - ul: { - marginBottom: 10, - paddingLeft: isDesktop ? 18 : 4, - }, - ulChild: { - paddingTop: 10, - marginBottom: 0, - }, - ol: { - marginBottom: 10, - paddingLeft: isDesktop ? 18 : 4, - }, - olChild: { - paddingTop: 10, - marginBottom: 0, - }, - li: { - flexDirection: 'row', - paddingRight: 20, - marginBottom: 10, - }, - liBullet: { - paddingRight: 10, - }, - liText: {}, - a: { - marginBottom: 10, - }, - em: { - fontStyle: 'italic', - }, - }) -} diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 2b4376b698..43555ccb47 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -20,6 +20,7 @@ import {isAndroid, isNative, isWeb} from '#/platform/detection' import {precacheProfile} from '#/state/queries/profile' import {HighPriorityImage} from '#/view/com/util/images/Image' import {tokens, useTheme} from '#/alf' +import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import { Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled, Camera_Stroke2_Corner0_Rounded as Camera, @@ -271,6 +272,7 @@ let EditableUserAvatar = ({ const {_} = useLingui() const {requestCameraAccessIfNeeded} = useCameraPermission() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() + const sheetWrapper = useSheetWrapper() const aviStyle = useMemo(() => { if (type === 'algo' || type === 'list') { @@ -306,9 +308,11 @@ let EditableUserAvatar = ({ return } - const items = await openPicker({ - aspect: [1, 1], - }) + const items = await sheetWrapper( + openPicker({ + aspect: [1, 1], + }), + ) const item = items[0] if (!item) { return @@ -332,7 +336,7 @@ let EditableUserAvatar = ({ logger.error('Failed to crop banner', {error: e}) } } - }, [onSelectNewAvatar, requestPhotoAccessIfNeeded]) + }, [onSelectNewAvatar, requestPhotoAccessIfNeeded, sheetWrapper]) const onRemoveAvatar = React.useCallback(() => { onSelectNewAvatar(null) diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 13f4081fce..622cb2129d 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -17,6 +17,7 @@ import {logger} from '#/logger' import {isAndroid, isNative} from '#/platform/detection' import {EventStopper} from '#/view/com/util/EventStopper' import {tokens, useTheme as useAlfTheme} from '#/alf' +import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import { Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled, Camera_Stroke2_Corner0_Rounded as Camera, @@ -43,6 +44,7 @@ export function UserBanner({ const {_} = useLingui() const {requestCameraAccessIfNeeded} = useCameraPermission() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() + const sheetWrapper = useSheetWrapper() const onOpenCamera = React.useCallback(async () => { if (!(await requestCameraAccessIfNeeded())) { @@ -60,7 +62,7 @@ export function UserBanner({ if (!(await requestPhotoAccessIfNeeded())) { return } - const items = await openPicker() + const items = await sheetWrapper(openPicker()) if (!items[0]) { return } @@ -80,7 +82,7 @@ export function UserBanner({ logger.error('Failed to crop banner', {error: e}) } } - }, [onSelectNewBanner, requestPhotoAccessIfNeeded]) + }, [onSelectNewBanner, requestPhotoAccessIfNeeded, sheetWrapper]) const onRemoveBanner = React.useCallback(() => { onSelectNewBanner?.(null) diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx index 64fa504ebe..1d4cf8ff07 100644 --- a/src/view/com/util/ViewHeader.tsx +++ b/src/view/com/util/ViewHeader.tsx @@ -100,7 +100,7 @@ export function ViewHeader({ ) : null} - + {title} diff --git a/src/view/com/util/fab/FAB.web.tsx b/src/view/com/util/fab/FAB.web.tsx index 0a8831fa92..601d505a8d 100644 --- a/src/view/com/util/fab/FAB.web.tsx +++ b/src/view/com/util/fab/FAB.web.tsx @@ -1,7 +1,8 @@ import React from 'react' import {View} from 'react-native' + +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {FABInner, FABProps} from './FABInner' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' export const FAB = (_opts: FABProps) => { const {isDesktop} = useWebMediaQueries() diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx index 5d8aac81af..bc89ddb603 100644 --- a/src/view/com/util/fab/FABInner.tsx +++ b/src/view/com/util/fab/FABInner.tsx @@ -1,9 +1,10 @@ import React, {ComponentProps} from 'react' import {StyleSheet, TouchableWithoutFeedback} from 'react-native' -import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated' +import Animated from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {LinearGradient} from 'expo-linear-gradient' +import {PressableScale} from '#/lib/custom-animations/PressableScale' import {useHaptics} from '#/lib/haptics' import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' @@ -11,7 +12,6 @@ import {clamp} from '#/lib/numbers' import {gradients} from '#/lib/styles' import {isWeb} from '#/platform/detection' import {useHapticsDisabled} from '#/state/preferences' -import {useInteractionState} from '#/components/hooks/useInteractionState' export interface FABProps extends ComponentProps { @@ -25,11 +25,6 @@ export function FABInner({testID, icon, onPress, ...props}: FABProps) { const playHaptic = useHaptics() const isHapticsDisabled = useHapticsDisabled() const fabMinimalShellTransform = useMinimalShellFabTransform() - const { - state: pressed, - onIn: onPressIn, - onOut: onPressOut, - } = useInteractionState() const size = isTablet ? styles.sizeLarge : styles.sizeRegular @@ -37,43 +32,36 @@ export function FABInner({testID, icon, onPress, ...props}: FABProps) { ? {right: 50, bottom: 50} : {right: 24, bottom: clamp(insets.bottom, 15, 60) + 15} - const scale = useAnimatedStyle(() => ({ - transform: [{scale: withTiming(pressed ? 0.95 : 1)}], - })) - return ( - { - playHaptic('Light') - setTimeout( - () => { - onPress?.(e) - }, - isHapticsDisabled ? 0 : 75, - ) - }} - {...props}> - - - - {icon} - - - - + + { + playHaptic('Light') + setTimeout( + () => { + onPress?.(e) + }, + isHapticsDisabled ? 0 : 75, + ) + }} + targetScale={0.9} + {...props}> + + {icon} + + + ) } diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index fe6efc02fa..cd1f2d3de6 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -22,6 +22,7 @@ import {getCurrentRoute} from '#/lib/routes/helpers' import {makeProfileLink} from '#/lib/routes/links' import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' import {shareUrl} from '#/lib/sharing' +import {logEvent} from '#/lib/statsig/statsig' import {richTextToString} from '#/lib/strings/rich-text-helpers' import {toShareUrl} from '#/lib/strings/url-helpers' import {useTheme} from '#/lib/ThemeContext' @@ -239,8 +240,8 @@ let PostDropdownBtn = ({ Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') }, [_, richText]) - const onPressTranslate = React.useCallback(() => { - openLink(translatorUrl) + const onPressTranslate = React.useCallback(async () => { + await openLink(translatorUrl) }, [openLink, translatorUrl]) const onHidePost = React.useCallback(() => { @@ -350,6 +351,7 @@ let PostDropdownBtn = ({ ]) const onPressPin = useCallback(() => { + logEvent(isPinned ? 'post:unpin' : 'post:pin', {}) pinPostMutate({ postUri, postCid, @@ -437,7 +439,7 @@ let PostDropdownBtn = ({ + onPress={() => sendViaChatControl.open()}> Send via direct message @@ -465,7 +467,7 @@ let PostDropdownBtn = ({ + onPress={() => embedPostControl.open()}> {_(msg`Embed post`)} @@ -540,7 +542,7 @@ let PostDropdownBtn = ({ ? _(msg`Hide reply for me`) : _(msg`Hide post for me`) } - onPress={hidePromptControl.open}> + onPress={() => hidePromptControl.open()}> {isReply ? _(msg`Hide reply for me`) @@ -628,7 +630,9 @@ let PostDropdownBtn = ({ + postInteractionSettingsDialogControl.open() + } {...(isAuthor ? Platform.select({ web: { @@ -647,7 +651,7 @@ let PostDropdownBtn = ({ + onPress={() => deletePromptControl.open()}> {_(msg`Delete post`)} diff --git a/src/view/com/util/forms/SearchInput.tsx b/src/view/com/util/forms/SearchInput.tsx deleted file mode 100644 index 5a21d8fdd0..0000000000 --- a/src/view/com/util/forms/SearchInput.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import React from 'react' -import { - StyleProp, - StyleSheet, - TextInput, - TouchableOpacity, - View, - ViewStyle, -} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {HITSLOP_10} from 'lib/constants' -import {MagnifyingGlassIcon} from 'lib/icons' -import {useTheme} from 'lib/ThemeContext' -import {usePalette} from 'lib/hooks/usePalette' -import {useLingui} from '@lingui/react' -import {msg} from '@lingui/macro' - -interface Props { - query: string - setIsInputFocused?: (v: boolean) => void - onChangeQuery: (v: string) => void - onPressCancelSearch: () => void - onSubmitQuery: () => void - style?: StyleProp -} - -export interface SearchInputRef { - focus?: () => void -} - -export const SearchInput = React.forwardRef( - function SearchInput( - { - query, - setIsInputFocused, - onChangeQuery, - onPressCancelSearch, - onSubmitQuery, - style, - }, - ref, - ) { - const theme = useTheme() - const pal = usePalette('default') - const {_} = useLingui() - const textInput = React.useRef(null) - - const onPressCancelSearchInner = React.useCallback(() => { - onPressCancelSearch() - textInput.current?.blur() - }, [onPressCancelSearch, textInput]) - - React.useImperativeHandle(ref, () => ({ - focus: () => textInput.current?.focus(), - blur: () => textInput.current?.blur(), - })) - - return ( - - - setIsInputFocused?.(true)} - onBlur={() => setIsInputFocused?.(false)} - onChangeText={onChangeQuery} - onSubmitEditing={onSubmitQuery} - accessibilityRole="search" - accessibilityLabel={_(msg`Search`)} - accessibilityHint="" - autoCorrect={false} - autoCapitalize="none" - /> - {query ? ( - - - - ) : undefined} - - ) - }, -) - -const styles = StyleSheet.create({ - container: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - borderRadius: 30, - paddingHorizontal: 12, - paddingVertical: 8, - }, - icon: { - marginRight: 6, - alignSelf: 'center', - }, - input: { - flex: 1, - fontSize: 17, - minWidth: 0, // overflow mitigation for firefox - }, - cancelBtn: { - paddingLeft: 10, - }, -}) diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx index 0ecdf25b93..936746f51d 100644 --- a/src/view/com/util/post-ctrls/RepostButton.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.tsx @@ -86,9 +86,11 @@ let RepostButton = ({ ) : undefined} - + - + - + ) diff --git a/src/view/com/util/text/Text.tsx b/src/view/com/util/text/Text.tsx index 3d885480cc..42ea79b8fb 100644 --- a/src/view/com/util/text/Text.tsx +++ b/src/view/com/util/text/Text.tsx @@ -77,13 +77,16 @@ export function Text({ flattened.fontSize = flattened.fontSize * fonts.scaleMultiplier } + const shared = { + uiTextView: true, + selectable, + style: flattened, + ...props, + } + return ( - - {isIOS && emoji ? renderChildrenWithEmoji(children) : children} + + {isIOS && emoji ? renderChildrenWithEmoji(children, shared) : children} ) } @@ -104,14 +107,16 @@ export function Text({ flattened.fontSize = flattened.fontSize * fonts.scaleMultiplier } + const shared = { + selectable, + style: flattened, + dataSet: Object.assign({tooltip: title}, dataSet || {}), + ...props, + } + return ( - - {isIOS && emoji ? renderChildrenWithEmoji(children) : children} + + {isIOS && emoji ? renderChildrenWithEmoji(children, shared) : children} ) } diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index 310c3dc602..87af59f7f6 100644 --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -6,6 +6,12 @@ import {useLingui} from '@lingui/react' import {useFocusEffect} from '@react-navigation/native' import debounce from 'lodash.debounce' +import {usePalette} from '#/lib/hooks/usePalette' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {ComposeIcon2} from '#/lib/icons' +import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' +import {cleanError} from '#/lib/strings/errors' +import {s} from '#/lib/styles' import {isNative, isWeb} from '#/platform/detection' import { SavedFeedItem, @@ -16,25 +22,19 @@ import { import {useSession} from '#/state/session' import {useSetMinimalShellMode} from '#/state/shell' import {useComposerControls} from '#/state/shell/composer' -import {usePalette} from 'lib/hooks/usePalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {ComposeIcon2} from 'lib/icons' -import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' -import {cleanError} from 'lib/strings/errors' -import {s} from 'lib/styles' -import {ErrorMessage} from 'view/com/util/error/ErrorMessage' -import {FAB} from 'view/com/util/fab/FAB' -import {SearchInput} from 'view/com/util/forms/SearchInput' -import {TextLink} from 'view/com/util/Link' -import {List} from 'view/com/util/List' -import {FeedFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' -import {Text} from 'view/com/util/text/Text' -import {ViewHeader} from 'view/com/util/ViewHeader' +import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' +import {FAB} from '#/view/com/util/fab/FAB' +import {TextLink} from '#/view/com/util/Link' +import {List} from '#/view/com/util/List' +import {FeedFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {Text} from '#/view/com/util/text/Text' +import {ViewHeader} from '#/view/com/util/ViewHeader' import {NoFollowingFeed} from '#/screens/Feeds/NoFollowingFeed' import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType' import {atoms as a, useTheme} from '#/alf' import {Divider} from '#/components/Divider' import * as FeedCard from '#/components/FeedCard' +import {SearchInput} from '#/components/forms/SearchInput' import {IconCircle} from '#/components/IconCircle' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline' @@ -481,11 +481,12 @@ export function FeedsScreen(_props: Props) { onChangeSearchFocus(true)} + onBlur={() => onChangeSearchFocus(false)} /> diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx index 66c354b3be..6fce998141 100644 --- a/src/view/screens/Search/Search.tsx +++ b/src/view/screens/Search/Search.tsx @@ -62,12 +62,10 @@ import {makeSearchQuery, parseSearchQuery} from '#/screens/Search/utils' import {atoms as a, useBreakpoints, useTheme as useThemeNew, web} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as FeedCard from '#/components/FeedCard' -import * as TextField from '#/components/forms/TextField' +import {SearchInput} from '#/components/forms/SearchInput' import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron' -import {MagnifyingGlass2_Stroke2_Corner0_Rounded as MagnifyingGlass} from '#/components/icons/MagnifyingGlass2' import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu' import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2' -import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' function Loader() { const pal = usePalette('default') @@ -340,18 +338,28 @@ function SearchLanguageDropdown({ onChange(value: string): void }) { const t = useThemeNew() + const {_} = useLingui() const {contentLanguages} = useLanguagePrefs() const items = React.useMemo(() => { - return LANGUAGES.filter(l => Boolean(l.code2)) - .map(l => ({ - label: l.name, - inputLabel: l.name, - value: l.code2, - key: l.code2 + l.code3, - })) - .sort(a => (contentLanguages.includes(a.value) ? -1 : 1)) - }, [contentLanguages]) + return [ + { + label: _(msg`Any language`), + inputLabel: _(msg`Any language`), + value: '', + key: '*', + }, + ].concat( + LANGUAGES.filter(l => Boolean(l.code2)) + .map(l => ({ + label: l.name, + inputLabel: l.name, + value: l.code2, + key: l.code2 + l.code3, + })) + .sort(a => (contentLanguages.includes(a.value) ? -1 : 1)), + ) + }, [_, contentLanguages]) const style = { backgroundColor: t.atoms.bg_contrast_25.backgroundColor, @@ -624,7 +632,7 @@ export function SearchScreen( * Arbitrary sizing, so guess and check, used for sticky header alignment and * sizing. */ - const headerHeight = 56 + (showFilters ? 40 : 0) + const headerHeight = 64 + (showFilters ? 40 : 0) useFocusEffect( useNonReactiveCallback(() => { @@ -838,18 +846,18 @@ export function SearchScreen( - + {!gtMobile && ( )} - + + + {showFiltersButton && ( @@ -961,78 +970,6 @@ export function SearchScreen( ) } -let SearchInputBox = ({ - textInput, - searchText, - showAutocomplete, - onFocus, - onChangeText, - onSubmit, - onPressClearQuery, -}: { - textInput: React.RefObject - searchText: string - showAutocomplete: boolean - onFocus: () => void - onChangeText: (text: string) => void - onSubmit: () => void - onPressClearQuery: () => void -}): React.ReactNode => { - const {_} = useLingui() - const t = useThemeNew() - - return ( - - - - - - - {showAutocomplete && searchText.length > 0 && ( - - - - )} - - ) -} -SearchInputBox = React.memo(SearchInputBox) - let AutocompleteResults = ({ isAutocompleteFetching, autocompleteData, diff --git a/src/view/screens/Settings/DisableEmail2FADialog.tsx b/src/view/screens/Settings/DisableEmail2FADialog.tsx index a27cff9a3e..e4341fcd21 100644 --- a/src/view/screens/Settings/DisableEmail2FADialog.tsx +++ b/src/view/screens/Settings/DisableEmail2FADialog.tsx @@ -79,7 +79,6 @@ export function DisableEmail2FADialog({ return ( - diff --git a/src/view/screens/Settings/ExportCarDialog.tsx b/src/view/screens/Settings/ExportCarDialog.tsx index a6ddb38204..1d8d264712 100644 --- a/src/view/screens/Settings/ExportCarDialog.tsx +++ b/src/view/screens/Settings/ExportCarDialog.tsx @@ -53,7 +53,6 @@ export function ExportCarDialog({ return ( - diff --git a/src/view/screens/Storybook/Dialogs.tsx b/src/view/screens/Storybook/Dialogs.tsx index 3a9f67de81..a0a2a27551 100644 --- a/src/view/screens/Storybook/Dialogs.tsx +++ b/src/view/screens/Storybook/Dialogs.tsx @@ -2,8 +2,8 @@ import React from 'react' import {View} from 'react-native' import {useNavigation} from '@react-navigation/native' +import {NavigationProp} from '#/lib/routes/types' import {useDialogStateControlContext} from '#/state/dialogs' -import {NavigationProp} from 'lib/routes/types' import {atoms as a} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' @@ -179,19 +179,13 @@ export function Dialogs() { - -

Dialog

A basic dialog

- - - + @@ -230,8 +224,6 @@ export function Dialogs() { - - @@ -356,8 +348,6 @@ export function Dialogs() { {shouldRenderUnmountTest && ( - -

Unmount Test Dialog

Will unmount in about 5 seconds

diff --git a/src/view/shell/Composer.ios.tsx b/src/view/shell/Composer.ios.tsx index 18410bf397..02efad8786 100644 --- a/src/view/shell/Composer.ios.tsx +++ b/src/view/shell/Composer.ios.tsx @@ -1,19 +1,28 @@ -import React, {useLayoutEffect} from 'react' +import React from 'react' import {Modal, View} from 'react-native' -import {StatusBar} from 'expo-status-bar' -import * as SystemUI from 'expo-system-ui' +import {useDialogStateControlContext} from '#/state/dialogs' import {useComposerState} from '#/state/shell/composer' import {atoms as a, useTheme} from '#/alf' -import {getBackgroundColor, useThemeName} from '#/alf/util/useColorModeTheme' import {ComposePost, useComposerCancelRef} from '../com/composer/Composer' export function Composer({}: {winHeight: number}) { + const {setFullyExpandedCount} = useDialogStateControlContext() const t = useTheme() const state = useComposerState() const ref = useComposerCancelRef() const open = !!state + const prevOpen = React.useRef(open) + + React.useEffect(() => { + if (open && !prevOpen.current) { + setFullyExpandedCount(c => c + 1) + } else if (!open && prevOpen.current) { + setFullyExpandedCount(c => c - 1) + } + prevOpen.current = open + }, [open, setFullyExpandedCount]) return ( ref.current?.onPressCancel()}> - - - + ) } - -function Providers({ - children, - open, -}: { - children: React.ReactNode - open: boolean -}) { - // on iOS, it's a native formSheet. We use FullWindowOverlay to make - // the dialogs appear over it - return ( - <> - {children} - - - ) -} - -// Generally, the backdrop of the app is the theme color, but when this is open -// we want it to be black due to the modal being a form sheet. -function IOSModalBackground({active}: {active: boolean}) { - const theme = useThemeName() - - useLayoutEffect(() => { - SystemUI.setBackgroundColorAsync('black') - - return () => { - SystemUI.setBackgroundColorAsync(getBackgroundColor(theme)) - } - }, [theme]) - - // Set the status bar to light - however, only if the modal is active - // If we rely on this component being mounted to set this, - // there'll be a delay before it switches back to default. - return active ? : null -} diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index ca2cbb3e08..4212b28af9 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -590,7 +590,9 @@ function MenuItem({ ? styles.menuItemCountTens : undefined, ]}> - + {count}
@@ -666,7 +668,6 @@ const styles = StyleSheet.create({ }, menuItemCountLabel: { fontSize: 12, - fontWeight: '600', fontVariant: ['tabular-nums'], color: colors.white, }, diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index b43dbcce32..2780944f11 100644 --- a/src/view/shell/desktop/Search.tsx +++ b/src/view/shell/desktop/Search.tsx @@ -25,11 +25,11 @@ import {s} from '#/lib/styles' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' import {precacheProfile} from '#/state/queries/profile' -import {SearchInput} from '#/view/com/util/forms/SearchInput' import {Link} from '#/view/com/util/Link' import {Text} from '#/view/com/util/text/Text' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a} from '#/alf' +import {SearchInput} from '#/components/forms/SearchInput' let SearchLinkCard = ({ label, @@ -184,10 +184,10 @@ export function DesktopSearch() { return ( {query !== '' && isActive && moderationOpts && ( diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index aed92cbb7a..8bc3de24d5 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -13,6 +13,14 @@ import * as NavigationBar from 'expo-navigation-bar' import {StatusBar} from 'expo-status-bar' import {useNavigation, useNavigationState} from '@react-navigation/native' +import {useDedupe} from '#/lib/hooks/useDedupe' +import {useNotificationsHandler} from '#/lib/hooks/useNotificationHandler' +import {usePalette} from '#/lib/hooks/usePalette' +import {useNotificationsRegistration} from '#/lib/notifications/notifications' +import {isStateAtTabRoot} from '#/lib/routes/helpers' +import {useTheme} from '#/lib/ThemeContext' +import {isAndroid, isIOS} from '#/platform/detection' +import {useDialogStateControlContext} from '#/state/dialogs' import {useSession} from '#/state/session' import { useIsDrawerOpen, @@ -20,17 +28,9 @@ import { useSetDrawerOpen, } from '#/state/shell' import {useCloseAnyActiveElement} from '#/state/util' -import {useDedupe} from 'lib/hooks/useDedupe' -import {useNotificationsHandler} from 'lib/hooks/useNotificationHandler' -import {usePalette} from 'lib/hooks/usePalette' -import {useNotificationsRegistration} from 'lib/notifications/notifications' -import {isStateAtTabRoot} from 'lib/routes/helpers' -import {useTheme} from 'lib/ThemeContext' -import {isAndroid} from 'platform/detection' -import {useDialogStateContext} from 'state/dialogs' -import {Lightbox} from 'view/com/lightbox/Lightbox' -import {ModalsContainer} from 'view/com/modals/Modal' -import {ErrorBoundary} from 'view/com/util/ErrorBoundary' +import {Lightbox} from '#/view/com/lightbox/Lightbox' +import {ModalsContainer} from '#/view/com/modals/Modal' +import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' import {MutedWordsDialog} from '#/components/dialogs/MutedWords' import {SigninDialog} from '#/components/dialogs/Signin' import {Outlet as PortalOutlet} from '#/components/Portal' @@ -61,7 +61,6 @@ function ShellInner() { const canGoBack = useNavigationState(state => !isStateAtTabRoot(state)) const {hasSession} = useSession() const closeAnyActiveElement = useCloseAnyActiveElement() - const {importantForAccessibility} = useDialogStateContext() useNotificationsRegistration() useNotificationsHandler() @@ -101,9 +100,7 @@ function ShellInner() { return ( <> - + { @@ -140,7 +138,14 @@ export const Shell: React.FC = function ShellImpl() { }, [theme]) return ( - + 0) + ? 'light' + : 'dark' + } + animated + /> diff --git a/web/index.html b/web/index.html index 71e5ac0892..2a406429c7 100644 --- a/web/index.html +++ b/web/index.html @@ -18,24 +18,8 @@ %WEB_TITLE% - - - - - - - - - + +