diff --git a/Dockerfile b/Dockerfile index fcd2413cdc..3ad05b6ec6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,7 @@ RUN \. "$NVM_DIR/nvm.sh" && \ nvm use $NODE_VERSION && \ npm install --global yarn && \ yarn && \ - yarn intl:compile && \ + yarn intl:build && \ yarn build-web # DEBUG diff --git a/app.config.js b/app.config.js index 6ae059104e..a3303144de 100644 --- a/app.config.js +++ b/app.config.js @@ -11,6 +11,17 @@ const DARK_SPLASH_CONFIG = { resizeMode: 'cover', } +const SPLASH_CONFIG_ANDROID = { + backgroundColor: '#0c7cff', + image: './assets/splash.png', + resizeMode: 'cover', +} +const DARK_SPLASH_CONFIG_ANDROID = { + backgroundColor: '#0f141b', + image: './assets/splash-dark.png', + resizeMode: 'cover', +} + module.exports = function (config) { /** * App version number. Should be incremented as part of a release cycle. @@ -70,8 +81,8 @@ module.exports = function (config) { }, }, androidStatusBar: { - barStyle: 'dark-content', - backgroundColor: '#ffffff', + barStyle: 'light-content', + backgroundColor: '#00000000', }, android: { icon: './assets/icon.png', @@ -101,8 +112,8 @@ module.exports = function (config) { }, ], splash: { - ...SPLASH_CONFIG, - dark: DARK_SPLASH_CONFIG, + ...SPLASH_CONFIG_ANDROID, + dark: DARK_SPLASH_CONFIG_ANDROID, }, }, web: { @@ -131,10 +142,12 @@ module.exports = function (config) { 'expo-notifications', { icon: './assets/icon-android-notification.png', - color: '#ffffff', + color: '#1185fe', }, ], './plugins/withAndroidManifestPlugin.js', + './plugins/withAndroidManifestFCMIconPlugin.js', + './plugins/withAndroidStylesWindowBackgroundPlugin.js', './plugins/shareExtension/withShareExtensions.js', ].filter(Boolean), extra: { diff --git a/assets/icons/bubbleQuestion_stroke2_corner0_rounded.svg b/assets/icons/bubbleQuestion_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..0bfcc48a0e --- /dev/null +++ b/assets/icons/bubbleQuestion_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/filter_stroke2_corner0_rounded.svg b/assets/icons/filter_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..1fbcfc5711 --- /dev/null +++ b/assets/icons/filter_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/speakerVolumeFull_stroke2_corner0_rounded.svg b/assets/icons/speakerVolumeFull_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..81357a12e3 --- /dev/null +++ b/assets/icons/speakerVolumeFull_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/trash_stroke2_corner0_rounded.svg b/assets/icons/trash_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..d4b32f81fe --- /dev/null +++ b/assets/icons/trash_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/warning_stroke2_corner0_rounded.svg b/assets/icons/warning_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..d5b6f13d5f --- /dev/null +++ b/assets/icons/warning_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/bskyweb/cmd/bskyweb/mailmodo.go b/bskyweb/cmd/bskyweb/mailmodo.go deleted file mode 100644 index e892971f9c..0000000000 --- a/bskyweb/cmd/bskyweb/mailmodo.go +++ /dev/null @@ -1,70 +0,0 @@ -package main - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/json" - "fmt" - "net/http" - "time" -) - -type Mailmodo struct { - httpClient *http.Client - APIKey string - BaseURL string - ListName string -} - -func NewMailmodo(apiKey, listName string) *Mailmodo { - return &Mailmodo{ - APIKey: apiKey, - BaseURL: "https://api.mailmodo.com/api/v1", - httpClient: &http.Client{}, - ListName: listName, - } -} - -func (m *Mailmodo) request(ctx context.Context, httpMethod string, apiMethod string, data any) error { - endpoint := fmt.Sprintf("%s/%s", m.BaseURL, apiMethod) - js, err := json.Marshal(data) - if err != nil { - return fmt.Errorf("Mailmodo JSON encoding failed: %w", err) - } - req, err := http.NewRequestWithContext(ctx, httpMethod, endpoint, bytes.NewBuffer(js)) - if err != nil { - return fmt.Errorf("Mailmodo HTTP creating request %s %s failed: %w", httpMethod, apiMethod, err) - } - req.Header.Set("mmApiKey", m.APIKey) - req.Header.Set("Content-Type", "application/json") - - res, err := m.httpClient.Do(req) - if err != nil { - return fmt.Errorf("Mailmodo HTTP making request %s %s failed: %w", httpMethod, apiMethod, err) - } - defer res.Body.Close() - - status := struct { - Success bool `json:"success"` - Message string `json:"message"` - }{} - if err := json.NewDecoder(res.Body).Decode(&status); err != nil { - return fmt.Errorf("Mailmodo HTTP parsing response %s %s failed: %w", httpMethod, apiMethod, err) - } - if !status.Success { - return fmt.Errorf("Mailmodo API response %s %s failed: %s", httpMethod, apiMethod, status.Message) - } - return nil -} - -func (m *Mailmodo) AddToList(ctx context.Context, email string) error { - return m.request(ctx, "POST", "addToList", map[string]any{ - "listName": m.ListName, - "email": email, - "data": map[string]any{ - "email_hashed": fmt.Sprintf("%x", sha256.Sum256([]byte(email))), - }, - "created_at": time.Now().UTC().Format(time.RFC3339), - }) -} diff --git a/bskyweb/cmd/bskyweb/main.go b/bskyweb/cmd/bskyweb/main.go index a2952cae2b..5185ff573a 100644 --- a/bskyweb/cmd/bskyweb/main.go +++ b/bskyweb/cmd/bskyweb/main.go @@ -40,18 +40,6 @@ func run(args []string) { // retain old PDS env var for easy transition EnvVars: []string{"ATP_APPVIEW_HOST", "ATP_PDS_HOST"}, }, - &cli.StringFlag{ - Name: "mailmodo-api-key", - Usage: "Mailmodo API key", - Required: false, - EnvVars: []string{"MAILMODO_API_KEY"}, - }, - &cli.StringFlag{ - Name: "mailmodo-list-name", - Usage: "Mailmodo contact list to add email addresses to", - Required: false, - EnvVars: []string{"MAILMODO_LIST_NAME"}, - }, &cli.StringFlag{ Name: "http-address", Usage: "Specify the local IP/port to bind to", diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 6b76acc948..e159d780a2 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -2,11 +2,9 @@ package main import ( "context" - "encoding/json" "errors" "fmt" "io/fs" - "io/ioutil" "net/http" "os" "os/signal" @@ -29,25 +27,19 @@ import ( ) type Server struct { - echo *echo.Echo - httpd *http.Server - mailmodo *Mailmodo - xrpcc *xrpc.Client + echo *echo.Echo + httpd *http.Server + xrpcc *xrpc.Client } func serve(cctx *cli.Context) error { debug := cctx.Bool("debug") httpAddress := cctx.String("http-address") appviewHost := cctx.String("appview-host") - mailmodoAPIKey := cctx.String("mailmodo-api-key") - mailmodoListName := cctx.String("mailmodo-list-name") // Echo e := echo.New() - // Mailmodo client. - mailmodo := NewMailmodo(mailmodoAPIKey, mailmodoListName) - // create a new session (no auth) xrpcc := &xrpc.Client{ Client: cliutil.NewHttpClient(), @@ -77,9 +69,8 @@ func serve(cctx *cli.Context) error { // server // server := &Server{ - echo: e, - mailmodo: mailmodo, - xrpcc: xrpcc, + echo: e, + xrpcc: xrpcc, } // Create the HTTP server. @@ -221,9 +212,6 @@ func serve(cctx *cli.Context) error { e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric) e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric) - // Mailmodo - e.POST("/api/waitlist", server.apiWaitlist) - // Start the server. log.Infof("starting server address=%s", httpAddress) go func() { @@ -398,36 +386,3 @@ func (srv *Server) WebProfile(c echo.Context) error { data["requestHost"] = req.Host return c.Render(http.StatusOK, "profile.html", data) } - -func (srv *Server) apiWaitlist(c echo.Context) error { - type jsonError struct { - Error string `json:"error"` - } - - // Read the API request. - type apiRequest struct { - Email string `json:"email"` - } - - bodyReader := http.MaxBytesReader(c.Response(), c.Request().Body, 16*1024) - payload, err := ioutil.ReadAll(bodyReader) - if err != nil { - return err - } - var req apiRequest - if err := json.Unmarshal(payload, &req); err != nil { - return c.JSON(http.StatusBadRequest, jsonError{Error: "Invalid API request"}) - } - - if req.Email == "" { - return c.JSON(http.StatusBadRequest, jsonError{Error: "Please enter a valid email address."}) - } - - if err := srv.mailmodo.AddToList(c.Request().Context(), req.Email); err != nil { - log.Errorf("adding email to waitlist failed: %s", err) - return c.JSON(http.StatusBadRequest, jsonError{ - Error: "Storing email in waitlist failed. Please enter a valid email address.", - }) - } - return c.JSON(http.StatusOK, map[string]bool{"success": true}) -} diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html index 413d7ff691..c7c5ec0f0b 100644 --- a/bskyweb/templates/base.html +++ b/bskyweb/templates/base.html @@ -43,6 +43,9 @@ height: calc(100% + env(safe-area-inset-top)); scrollbar-gutter: stable both-edges; } + html, body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + } /* Buttons and inputs have a font set by UA, so we'll have to reset that */ button, input, textarea { @@ -213,6 +216,7 @@ } /* NativeDropdown component */ + .radix-dropdown-item:focus, .nativeDropdown-item:focus { outline: none; } diff --git a/index.web.js b/index.web.js index 4dee831cda..9623734512 100644 --- a/index.web.js +++ b/index.web.js @@ -1,3 +1,5 @@ +import '#/platform/markBundleStartTime' + import '#/platform/polyfills' import {registerRootComponent} from 'expo' import {doPolyfill} from '#/lib/api/api-polyfill' diff --git a/package.json b/package.json index c0c597e00e..785251ae67 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsky.app", - "version": "1.71.0", + "version": "1.72.0", "private": true, "engines": { "node": ">=18" @@ -44,7 +44,7 @@ "update-extensions": "scripts/updateExtensions.sh" }, "dependencies": { - "@atproto/api": "^0.10.4", + "@atproto/api": "^0.10.5", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "https://github.com/bluesky-social/react-native-bottom-sheet.git#discord-fork-4.6.1", "@emoji-mart/react": "^1.1.1", @@ -57,6 +57,7 @@ "@lingui/react": "^4.5.0", "@mattermost/react-native-paste-input": "^0.6.4", "@miblanchard/react-native-slider": "^2.3.1", + "@radix-ui/react-dropdown-menu": "^2.0.6", "@react-native-async-storage/async-storage": "^1.22.3", "@react-native-masked-view/masked-view": "0.3.0", "@react-native-menu/menu": "^0.8.0", @@ -143,8 +144,10 @@ "react": "18.2.0", "react-avatar-editor": "^13.0.0", "react-dom": "^18.2.0", + "react-keyed-flatten-children": "^3.0.0", "react-native": "~0.73.5", "react-native-date-picker": "^4.4.0", + "react-native-date-picker": "^4.4.0", "react-native-drawer-layout": "^4.0.0-alpha.3", "react-native-gesture-handler": "~2.15.0", "react-native-get-random-values": "~1.8.0", @@ -166,6 +169,8 @@ "react-native-webview": "~13.8.1", "react-responsive": "^9.0.2", "sentry-expo": "~7.2.0", + "statsig-react": "^1.36.0", + "statsig-react-native-expo": "^4.6.1", "tippy.js": "^6.3.7", "tlds": "^1.234.0", "zeego": "^1.6.2", diff --git a/plugins/withAndroidManifestFCMIconPlugin.js b/plugins/withAndroidManifestFCMIconPlugin.js new file mode 100644 index 0000000000..066a975d87 --- /dev/null +++ b/plugins/withAndroidManifestFCMIconPlugin.js @@ -0,0 +1,37 @@ +const {withAndroidManifest} = require('expo/config-plugins') + +module.exports = function withAndroidManifestFCMIconPlugin(appConfig) { + return withAndroidManifest(appConfig, function (decoratedAppConfig) { + try { + function addOrModifyMetaData(metaData, name, resource) { + const elem = metaData.find(elem => elem.$['android:name'] === name) + if (elem === undefined) { + metaData.push({ + $: { + 'android:name': name, + 'android:resource': resource, + }, + }) + } else { + elem.$['android:resource'] = resource + } + } + const androidManifest = decoratedAppConfig.modResults.manifest + const metaData = androidManifest.application[0]['meta-data'] + addOrModifyMetaData( + metaData, + 'com.google.firebase.messaging.default_notification_color', + '@color/notification_icon_color', + ) + addOrModifyMetaData( + metaData, + 'com.google.firebase.messaging.default_notification_icon', + '@drawable/notification_icon', + ) + return decoratedAppConfig + } catch (e) { + console.error(`withAndroidManifestFCMIconPlugin failed`, e) + } + return decoratedAppConfig + }) +} diff --git a/plugins/withAndroidStylesWindowBackgroundPlugin.js b/plugins/withAndroidStylesWindowBackgroundPlugin.js new file mode 100644 index 0000000000..427f43df07 --- /dev/null +++ b/plugins/withAndroidStylesWindowBackgroundPlugin.js @@ -0,0 +1,20 @@ +const {withAndroidStyles, AndroidConfig} = require('@expo/config-plugins') + +module.exports = function withAndroidStylesWindowBackgroundPlugin(appConfig) { + return withAndroidStyles(appConfig, function (decoratedAppConfig) { + try { + decoratedAppConfig.modResults = AndroidConfig.Styles.assignStylesValue( + decoratedAppConfig.modResults, + { + add: true, + parent: AndroidConfig.Styles.getAppThemeLightNoActionBarGroup(), + name: 'android:windowBackground', + value: '@drawable/splashscreen', + }, + ) + } catch (e) { + console.error(`withAndroidStylesWindowBackgroundPlugin failed`, e) + } + return decoratedAppConfig + }) +} diff --git a/src/App.native.tsx b/src/App.native.tsx index aae9b29d3f..5c4ac3380e 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -42,9 +42,12 @@ import { import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread' import * as persisted from '#/state/persisted' import {Provider as PortalProvider} from '#/components/Portal' +import {Provider as StatsigProvider} from '#/lib/statsig/statsig' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useIntentHandler} from 'lib/hooks/useIntentHandler' +import {StatusBar} from 'expo-status-bar' +import {isAndroid} from 'platform/detection' SplashScreen.preventAutoHideAsync() @@ -68,26 +71,29 @@ function InnerApp() { return ( + {isAndroid && } {/**/} - - - - - {/* All components should be within this provider */} - - - - - - - - - - + + + + + + {/* All components should be within this provider */} + + + + + + + + + + + {/**/} diff --git a/src/App.web.tsx b/src/App.web.tsx index 6ac32a0116..eb2e425930 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -32,6 +32,7 @@ import { import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread' import * as persisted from '#/state/persisted' import {Provider as PortalProvider} from '#/components/Portal' +import {Provider as StatsigProvider} from '#/lib/statsig/statsig' import {useIntentHandler} from 'lib/hooks/useIntentHandler' function InnerApp() { @@ -54,21 +55,23 @@ function InnerApp() { - - - - - {/* All components should be within this provider */} - - - - - - - - - - + + + + + + {/* All components should be within this provider */} + + + + + + + + + + + ) diff --git a/src/Navigation.tsx b/src/Navigation.tsx index b30f8f9822..8a9f69b5de 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -78,6 +78,7 @@ import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStack import {msg} from '@lingui/macro' import {i18n, MessageDescriptor} from '@lingui/core' import HashtagScreen from '#/screens/Hashtag' +import {logEvent} from './lib/statsig/statsig' const navigationRef = createNavigationContainerRef() @@ -649,11 +650,14 @@ function logModuleInitTime() { return } didInit = true + const initMs = Math.round( // @ts-ignore Emitted by Metro in the bundle prelude performance.now() - global.__BUNDLE_START_TIME__, ) console.log(`Time to first paint: ${initMs} ms`) + logEvent('init', initMs) + if (__DEV__) { // This log is noisy, so keep false committed const shouldLog = false diff --git a/src/components/Button.tsx b/src/components/Button.tsx index a6d0ee1dc2..33f580bc98 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -27,7 +27,7 @@ export type ButtonColor = | 'gradient_sunset' | 'gradient_nordic' | 'gradient_bonfire' -export type ButtonSize = 'tiny' | 'small' | 'large' +export type ButtonSize = 'tiny' | 'small' | 'medium' | 'large' export type ButtonShape = 'round' | 'square' | 'default' export type VariantProps = { /** @@ -274,6 +274,8 @@ export function Button({ if (shape === 'default') { if (size === 'large') { baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_md) + } else if (size === 'medium') { + baseStyles.push({paddingVertical: 12}, a.px_2xl, a.rounded_sm, a.gap_md) } else if (size === 'small') { baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm) } else if (size === 'tiny') { diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts index eb717d8e2b..859f8edd77 100644 --- a/src/components/Dialog/context.ts +++ b/src/components/Dialog/context.ts @@ -31,14 +31,17 @@ export function useDialogControl(): DialogOuterProps['control'] { } }, [id, activeDialogs]) - return { - id, - ref: control, - open: () => { - control.current.open() - }, - close: cb => { - control.current.close(cb) - }, - } + return React.useMemo( + () => ({ + id, + ref: control, + open: () => { + control.current.open() + }, + close: cb => { + control.current.close(cb) + }, + }), + [id, control], + ) } diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts index 7c8a6e26c6..4ee039dcb4 100644 --- a/src/components/Dialog/types.ts +++ b/src/components/Dialog/types.ts @@ -22,6 +22,7 @@ export type DialogControlRefProps = { export type DialogControlProps = DialogControlRefProps & { id: string ref: React.RefObject + isOpen?: boolean } export type DialogContextProps = { diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx index 12a935807e..58aa74b388 100644 --- a/src/components/Lists.tsx +++ b/src/components/Lists.tsx @@ -1,6 +1,7 @@ import React from 'react' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {View} from 'react-native' +import {CenteredView} from 'view/com/util/Views' import {Loader} from '#/components/Loader' import {Trans} from '@lingui/macro' import {cleanError} from 'lib/strings/errors' @@ -143,7 +144,7 @@ export function ListMaybePlaceholder({ }) { const navigation = useNavigation() const t = useTheme() - const {gtMobile} = useBreakpoints() + const {gtMobile, gtTablet} = useBreakpoints() const canGoBack = navigation.canGoBack() const onGoBack = React.useCallback(() => { @@ -165,14 +166,16 @@ export function ListMaybePlaceholder({ if (!isEmpty) return null return ( - + ]} + sideBorders={gtMobile} + topBorder={!gtTablet}> {isLoading ? ( @@ -241,6 +244,6 @@ export function ListMaybePlaceholder({ )} - + ) } diff --git a/src/components/Menu/context.tsx b/src/components/Menu/context.tsx new file mode 100644 index 0000000000..9fc91f6815 --- /dev/null +++ b/src/components/Menu/context.tsx @@ -0,0 +1,8 @@ +import React from 'react' + +import type {ContextType} from '#/components/Menu/types' + +export const Context = React.createContext({ + // @ts-ignore + control: null, +}) diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx new file mode 100644 index 0000000000..ee96a5667e --- /dev/null +++ b/src/components/Menu/index.tsx @@ -0,0 +1,190 @@ +import React from 'react' +import {View, Pressable} from 'react-native' +import flattenReactChildren from 'react-keyed-flatten-children' + +import {atoms as a, useTheme} from '#/alf' +import * as Dialog from '#/components/Dialog' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {Text} from '#/components/Typography' + +import {Context} from '#/components/Menu/context' +import { + ContextType, + TriggerProps, + ItemProps, + GroupProps, + ItemTextProps, + ItemIconProps, +} from '#/components/Menu/types' + +export {useDialogControl as useMenuControl} from '#/components/Dialog' + +export function useMemoControlContext() { + return React.useContext(Context) +} + +export function Root({ + children, + control, +}: React.PropsWithChildren<{ + control?: Dialog.DialogOuterProps['control'] +}>) { + const defaultControl = Dialog.useDialogControl() + const context = React.useMemo( + () => ({ + control: control || defaultControl, + }), + [control, defaultControl], + ) + + return {children} +} + +export function Trigger({children, label}: TriggerProps) { + const {control} = React.useContext(Context) + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + const { + state: pressed, + onIn: onPressIn, + onOut: onPressOut, + } = useInteractionState() + + return children({ + isNative: true, + control, + state: { + hovered: false, + focused, + pressed, + }, + props: { + onPress: control.open, + onFocus, + onBlur, + onPressIn, + onPressOut, + accessibilityLabel: label, + }, + }) +} + +export function Outer({children}: React.PropsWithChildren<{}>) { + const context = React.useContext(Context) + + return ( + + + + {/* Re-wrap with context since Dialogs are portal-ed to root */} + + + {children} + + + + + ) +} + +export function Item({children, label, style, onPress, ...rest}: ItemProps) { + const t = useTheme() + const {control} = React.useContext(Context) + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + const { + state: pressed, + onIn: onPressIn, + onOut: onPressOut, + } = useInteractionState() + + return ( + { + onPress(e) + + if (!e.defaultPrevented) { + control?.close() + } + }} + onFocus={onFocus} + onBlur={onBlur} + onPressIn={onPressIn} + onPressOut={onPressOut} + style={[ + a.flex_row, + a.align_center, + a.gap_sm, + a.px_md, + a.rounded_md, + a.border, + t.atoms.bg_contrast_25, + t.atoms.border_contrast_low, + {minHeight: 44, paddingVertical: 10}, + style, + (focused || pressed) && [t.atoms.bg_contrast_50], + ]}> + {children} + + ) +} + +export function ItemText({children, style}: ItemTextProps) { + const t = useTheme() + return ( + + {children} + + ) +} + +export function ItemIcon({icon: Comp}: ItemIconProps) { + const t = useTheme() + return +} + +export function Group({children, style}: GroupProps) { + const t = useTheme() + return ( + + {flattenReactChildren(children).map((child, i) => { + return React.isValidElement(child) && child.type === Item ? ( + + {i > 0 ? ( + + ) : null} + {React.cloneElement(child, { + // @ts-ignore + style: { + borderRadius: 0, + borderWidth: 0, + }, + })} + + ) : null + })} + + ) +} + +export function Divider() { + return null +} diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx new file mode 100644 index 0000000000..054e51b01e --- /dev/null +++ b/src/components/Menu/index.web.tsx @@ -0,0 +1,247 @@ +import React from 'react' +import {View, Pressable} from 'react-native' +import * as DropdownMenu from '@radix-ui/react-dropdown-menu' + +import * as Dialog from '#/components/Dialog' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {atoms as a, useTheme, flatten, web} from '#/alf' +import {Text} from '#/components/Typography' + +import { + ContextType, + TriggerProps, + ItemProps, + GroupProps, + ItemTextProps, + ItemIconProps, +} from '#/components/Menu/types' +import {Context} from '#/components/Menu/context' + +export function useMenuControl(): Dialog.DialogControlProps { + const id = React.useId() + const [isOpen, setIsOpen] = React.useState(false) + + return React.useMemo( + () => ({ + id, + ref: {current: null}, + isOpen, + open() { + setIsOpen(true) + }, + close() { + setIsOpen(false) + }, + }), + [id, isOpen, setIsOpen], + ) +} + +export function useMemoControlContext() { + return React.useContext(Context) +} + +export function Root({ + children, + control, +}: React.PropsWithChildren<{ + control?: Dialog.DialogOuterProps['control'] +}>) { + const defaultControl = useMenuControl() + const context = React.useMemo( + () => ({ + control: control || defaultControl, + }), + [control, defaultControl], + ) + const onOpenChange = React.useCallback( + (open: boolean) => { + if (context.control.isOpen && !open) { + context.control.close() + } else if (!context.control.isOpen && open) { + context.control.open() + } + }, + [context.control], + ) + + return ( + + + {children} + + + ) +} + +export function Trigger({children, label, style}: TriggerProps) { + const {control} = React.useContext(Context) + const { + state: hovered, + onIn: onMouseEnter, + onOut: onMouseLeave, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + + return ( + + control.open()} + {...web({ + onMouseEnter, + onMouseLeave, + })}> + {children({ + isNative: false, + control, + state: { + hovered, + focused, + pressed: false, + }, + props: {}, + })} + + + ) +} + +export function Outer({children}: React.PropsWithChildren<{}>) { + const t = useTheme() + + return ( + + + + {children} + + + {/* Disabled until we can fix positioning + + */} + + + ) +} + +export function Item({children, label, onPress, ...rest}: ItemProps) { + const t = useTheme() + const {control} = React.useContext(Context) + const { + state: hovered, + onIn: onMouseEnter, + onOut: onMouseLeave, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + + return ( + + { + onPress(e) + + /** + * Ported forward from Radix + * @see https://www.radix-ui.com/primitives/docs/components/dropdown-menu#item + */ + if (!e.defaultPrevented) { + control.close() + } + }} + onFocus={onFocus} + onBlur={onBlur} + // need `flatten` here for Radix compat + style={flatten([ + a.flex_row, + a.align_center, + a.gap_sm, + a.py_sm, + a.rounded_xs, + {minHeight: 32, paddingHorizontal: 10}, + web({outline: 0}), + (hovered || focused) && [ + web({outline: '0 !important'}), + t.name === 'light' + ? t.atoms.bg_contrast_25 + : t.atoms.bg_contrast_50, + ], + ])} + {...web({ + onMouseEnter, + onMouseLeave, + })}> + {children} + + + ) +} + +export function ItemText({children, style}: ItemTextProps) { + const t = useTheme() + return ( + + {children} + + ) +} + +export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) { + const t = useTheme() + return ( + + ) +} + +export function Group({children}: GroupProps) { + return children +} + +export function Divider() { + const t = useTheme() + return ( + + ) +} diff --git a/src/components/Menu/types.ts b/src/components/Menu/types.ts new file mode 100644 index 0000000000..2f52e63906 --- /dev/null +++ b/src/components/Menu/types.ts @@ -0,0 +1,72 @@ +import React from 'react' +import {GestureResponderEvent, PressableProps} from 'react-native' + +import {Props as SVGIconProps} from '#/components/icons/common' +import * as Dialog from '#/components/Dialog' +import {TextStyleProp, ViewStyleProp} from '#/alf' + +export type ContextType = { + control: Dialog.DialogOuterProps['control'] +} + +export type TriggerProps = ViewStyleProp & { + children(props: TriggerChildProps): React.ReactNode + label: string +} +export type TriggerChildProps = + | { + isNative: true + control: Dialog.DialogOuterProps['control'] + state: { + /** + * Web only, `false` on native + */ + hovered: false + focused: boolean + pressed: boolean + } + /** + * We don't necessarily know what these will be spread on to, so we + * should add props one-by-one. + * + * On web, these properties are applied to a parent `Pressable`, so this + * object is empty. + */ + props: { + onPress: () => void + onFocus: () => void + onBlur: () => void + onPressIn: () => void + onPressOut: () => void + accessibilityLabel: string + } + } + | { + isNative: false + control: Dialog.DialogOuterProps['control'] + state: { + hovered: boolean + focused: boolean + /** + * Native only, `false` on web + */ + pressed: false + } + props: {} + } + +export type ItemProps = React.PropsWithChildren< + Omit & + ViewStyleProp & { + label: string + onPress: (e: GestureResponderEvent) => void + } +> + +export type ItemTextProps = React.PropsWithChildren +export type ItemIconProps = React.PropsWithChildren<{ + icon: React.ComponentType + position?: 'left' | 'right' +}> + +export type GroupProps = React.PropsWithChildren diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index 8e55bd8347..3b245c440f 100644 --- a/src/components/Prompt.tsx +++ b/src/components/Prompt.tsx @@ -3,7 +3,7 @@ import {View, PressableProps} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useTheme, atoms as a} from '#/alf' +import {useTheme, atoms as a, useBreakpoints} from '#/alf' import {Text} from '#/components/Typography' import {Button} from '#/components/Button' @@ -25,6 +25,7 @@ export function Outer({ }: React.PropsWithChildren<{ control: Dialog.DialogOuterProps['control'] }>) { + const {gtMobile} = useBreakpoints() const titleId = React.useId() const descriptionId = React.useId() @@ -38,12 +39,12 @@ export function Outer({ - + style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}> {children} - + ) @@ -71,8 +72,16 @@ export function Description({children}: React.PropsWithChildren<{}>) { } export function Actions({children}: React.PropsWithChildren<{}>) { + const {gtMobile} = useBreakpoints() + return ( - + {children} ) @@ -82,12 +91,13 @@ export function Cancel({ children, }: React.PropsWithChildren<{onPress?: PressableProps['onPress']}>) { const {_} = useLingui() + const {gtMobile} = useBreakpoints() const {close} = Dialog.useDialogContext() return (