Files
bsky-social-app/src/components/Menu/index.tsx
T
Eric Bailey 2223babc2b [APP-1759] Live Now Open Beta (#9699)
* Add report dialog to LiveStatus dialog

* Mr Worldwide™

* Bug fix bug fix

* Copy update

* Simplify parsing

* Bump api sdk

* update dev-env

* Update yarn.lock

* Bump api sdk

* Refactor useActorStatus, add disablement and appeal dialog

* Fix types

* Guard against chat profile views

* Go live (disabled)

* Fix types

* Update config and gates

* Add allowed services

* Merge conflicts

* Fix gradient handling for nudge

* Only show nudge on your own profile

* Fix live avi overflow breakage

* Add TODO

* Feedback

* Update nux image

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-01-14 16:57:21 -06:00

361 lines
8.0 KiB
TypeScript

import {cloneElement, Fragment, isValidElement, useMemo} from 'react'
import {
Pressable,
type StyleProp,
type TextStyle,
View,
type ViewStyle,
} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import flattenReactChildren from 'react-keyed-flatten-children'
import {isAndroid, isIOS, isNative} from '#/platform/detection'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {
Context,
ItemContext,
useMenuContext,
useMenuItemContext,
} from '#/components/Menu/context'
import {
type ContextType,
type GroupProps,
type ItemIconProps,
type ItemProps,
type ItemTextProps,
type TriggerProps,
} from '#/components/Menu/types'
import {Text} from '#/components/Typography'
export {
type DialogControlProps as MenuControlProps,
useDialogControl as useMenuControl,
} from '#/components/Dialog'
export {useMenuContext}
export function Root({
children,
control,
}: React.PropsWithChildren<{
control?: Dialog.DialogControlProps
}>) {
const defaultControl = Dialog.useDialogControl()
const context = useMemo<ContextType>(
() => ({
control: control || defaultControl,
}),
[control, defaultControl],
)
return <Context.Provider value={context}>{children}</Context.Provider>
}
export function Trigger({
children,
label,
role = 'button',
hint,
}: TriggerProps) {
const context = useMenuContext()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const {
state: pressed,
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
return children({
isNative: true,
control: context.control,
state: {
hovered: false,
focused,
pressed,
},
props: {
ref: null,
onPress: context.control.open,
onFocus,
onBlur,
onPressIn,
onPressOut,
accessibilityHint: hint,
accessibilityLabel: label,
accessibilityRole: role,
},
})
}
export function Outer({
children,
showCancel,
}: React.PropsWithChildren<{
showCancel?: boolean
style?: StyleProp<ViewStyle>
}>) {
const context = useMenuContext()
const {_} = useLingui()
return (
<Dialog.Outer
control={context.control}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
{/* Re-wrap with context since Dialogs are portal-ed to root */}
<Context.Provider value={context}>
<Dialog.ScrollableInner label={_(msg`Menu`)}>
<View style={[a.gap_lg]}>
{children}
{isNative && showCancel && <Cancel />}
</View>
</Dialog.ScrollableInner>
</Context.Provider>
</Dialog.Outer>
)
}
export function Item({children, label, style, onPress, ...rest}: ItemProps) {
const t = useTheme()
const context = useMenuContext()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const {
state: pressed,
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
return (
<Pressable
{...rest}
accessibilityHint=""
accessibilityLabel={label}
onFocus={onFocus}
onBlur={onBlur}
onPress={async e => {
if (isAndroid) {
/**
* Below fix for iOS doesn't work for Android, this does.
*/
onPress?.(e)
context.control.close()
} else if (isIOS) {
/**
* Fixes a subtle bug on iOS
* {@link https://github.com/bluesky-social/social-app/pull/5849/files#diff-de516ef5e7bd9840cd639213301df38cf03acfcad5bda85a1d63efd249ba79deL124-L127}
*/
context.control.close(() => {
onPress?.(e)
})
}
}}
onPressIn={e => {
onPressIn()
rest.onPressIn?.(e)
}}
onPressOut={e => {
onPressOut()
rest.onPressOut?.(e)
}}
style={[
a.flex_row,
a.align_center,
a.gap_sm,
a.px_md,
a.rounded_md,
a.overflow_hidden,
a.border,
t.atoms.bg_contrast_25,
t.atoms.border_contrast_low,
{minHeight: 44, paddingVertical: 10},
style,
(focused || pressed) && !rest.disabled && [t.atoms.bg_contrast_50],
]}>
<ItemContext.Provider value={{disabled: Boolean(rest.disabled)}}>
{children}
</ItemContext.Provider>
</Pressable>
)
}
export function ItemText({children, style}: ItemTextProps) {
const t = useTheme()
const {disabled} = useMenuItemContext()
return (
<Text
numberOfLines={1}
ellipsizeMode="middle"
style={[
a.flex_1,
a.text_md,
a.font_semi_bold,
t.atoms.text_contrast_high,
style,
disabled && t.atoms.text_contrast_low,
]}>
{children}
</Text>
)
}
export function ItemIcon({icon: Comp, fill}: ItemIconProps) {
const t = useTheme()
const {disabled} = useMenuItemContext()
return (
<Comp
size="lg"
fill={
fill
? fill({disabled})
: disabled
? t.atoms.text_contrast_low.color
: t.atoms.text_contrast_medium.color
}
/>
)
}
export function ItemRadio({selected}: {selected: boolean}) {
const t = useTheme()
return (
<View
style={[
a.justify_center,
a.align_center,
a.rounded_full,
t.atoms.border_contrast_high,
{
borderWidth: 1,
height: 20,
width: 20,
},
]}>
{selected ? (
<View
style={[
a.absolute,
a.rounded_full,
{height: 14, width: 14},
selected
? {
backgroundColor: t.palette.primary_500,
}
: {},
]}
/>
) : null}
</View>
)
}
/**
* NATIVE ONLY - for adding non-pressable items to the menu
*
* @platform ios, android
*/
export function ContainerItem({
children,
style,
}: {
children: React.ReactNode
style?: StyleProp<ViewStyle>
}) {
const t = useTheme()
return (
<View
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,
{paddingVertical: 10},
style,
]}>
{children}
</View>
)
}
export function LabelText({
children,
style,
}: {
children: React.ReactNode
style?: StyleProp<TextStyle>
}) {
const t = useTheme()
return (
<Text
style={[
a.font_semi_bold,
t.atoms.text_contrast_medium,
{marginBottom: -8},
style,
]}>
{children}
</Text>
)
}
export function Group({children, style}: GroupProps) {
const t = useTheme()
return (
<View
style={[
a.rounded_md,
a.overflow_hidden,
a.border,
t.atoms.border_contrast_low,
style,
]}>
{flattenReactChildren(children).map((child, i) => {
return isValidElement(child) &&
(child.type === Item || child.type === ContainerItem) ? (
<Fragment key={i}>
{i > 0 ? (
<View style={[a.border_b, t.atoms.border_contrast_low]} />
) : null}
{cloneElement(child, {
// @ts-expect-error cloneElement is not aware of the types
style: {
borderRadius: 0,
borderWidth: 0,
},
})}
</Fragment>
) : null
})}
</View>
)
}
function Cancel() {
const {_} = useLingui()
const context = useMenuContext()
return (
<Button
label={_(msg`Close this dialog`)}
size="small"
variant="ghost"
color="secondary"
onPress={() => context.control.close()}>
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
)
}
export function Divider() {
return null
}