Merge branch 'main' into 3p-moderators

This commit is contained in:
Paul Frazee
2024-03-07 23:02:14 -08:00
172 changed files with 9422 additions and 2463 deletions
+20 -28
View File
@@ -165,7 +165,7 @@ export function Button({
if (!disabled) {
baseStyles.push(a.border, {
borderColor: tokens.color.blue_500,
borderColor: t.palette.primary_500,
})
hoverStyles.push(a.border, {
backgroundColor: light
@@ -174,7 +174,7 @@ export function Button({
})
} else {
baseStyles.push(a.border, {
borderColor: light ? tokens.color.blue_200 : tokens.color.blue_900,
borderColor: light ? t.palette.primary_200 : t.palette.primary_900,
})
}
} else if (variant === 'ghost') {
@@ -191,20 +191,14 @@ export function Button({
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: light
? tokens.color.gray_50
: tokens.color.gray_900,
backgroundColor: t.palette.contrast_50,
})
hoverStyles.push({
backgroundColor: light
? tokens.color.gray_100
: tokens.color.gray_950,
backgroundColor: t.palette.contrast_100,
})
} else {
baseStyles.push({
backgroundColor: light
? tokens.color.gray_200
: tokens.color.gray_950,
backgroundColor: t.palette.contrast_200,
})
}
} else if (variant === 'outline') {
@@ -214,21 +208,19 @@ export function Button({
if (!disabled) {
baseStyles.push(a.border, {
borderColor: light ? tokens.color.gray_300 : tokens.color.gray_700,
borderColor: t.palette.contrast_300,
})
hoverStyles.push(a.border, t.atoms.bg_contrast_50)
hoverStyles.push(t.atoms.bg_contrast_50)
} else {
baseStyles.push(a.border, {
borderColor: light ? tokens.color.gray_200 : tokens.color.gray_800,
borderColor: t.palette.contrast_200,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push(t.atoms.bg)
hoverStyles.push({
backgroundColor: light
? tokens.color.gray_100
: tokens.color.gray_900,
backgroundColor: t.palette.contrast_100,
})
}
}
@@ -236,14 +228,14 @@ export function Button({
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: t.palette.negative_400,
backgroundColor: t.palette.negative_500,
})
hoverStyles.push({
backgroundColor: t.palette.negative_500,
backgroundColor: t.palette.negative_600,
})
} else {
baseStyles.push({
backgroundColor: t.palette.negative_600,
backgroundColor: t.palette.negative_700,
})
}
} else if (variant === 'outline') {
@@ -253,7 +245,7 @@ export function Button({
if (!disabled) {
baseStyles.push(a.border, {
borderColor: t.palette.negative_400,
borderColor: t.palette.negative_500,
})
hoverStyles.push(a.border, {
backgroundColor: light
@@ -273,7 +265,7 @@ export function Button({
hoverStyles.push({
backgroundColor: light
? t.palette.negative_100
: t.palette.negative_950,
: t.palette.negative_975,
})
}
}
@@ -460,31 +452,31 @@ export function useSharedButtonTextStyles() {
if (variant === 'solid' || variant === 'gradient') {
if (!disabled) {
baseStyles.push({
color: light ? tokens.color.gray_700 : tokens.color.gray_100,
color: t.palette.contrast_700,
})
} else {
baseStyles.push({
color: light ? tokens.color.gray_400 : tokens.color.gray_700,
color: t.palette.contrast_400,
})
}
} else if (variant === 'outline') {
if (!disabled) {
baseStyles.push({
color: light ? tokens.color.gray_600 : tokens.color.gray_300,
color: t.palette.contrast_600,
})
} else {
baseStyles.push({
color: light ? tokens.color.gray_400 : tokens.color.gray_700,
color: t.palette.contrast_300,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push({
color: light ? tokens.color.gray_600 : tokens.color.gray_300,
color: t.palette.contrast_600,
})
} else {
baseStyles.push({
color: light ? tokens.color.gray_400 : tokens.color.gray_600,
color: t.palette.contrast_300,
})
}
}
+18 -8
View File
@@ -3,7 +3,7 @@ import React from 'react'
import {useDialogStateContext} from '#/state/dialogs'
import {
DialogContextProps,
DialogControlProps,
DialogControlRefProps,
DialogOuterProps,
} from '#/components/Dialog/types'
@@ -17,11 +17,12 @@ export function useDialogContext() {
export function useDialogControl(): DialogOuterProps['control'] {
const id = React.useId()
const control = React.useRef<DialogControlProps>({
const control = React.useRef<DialogControlRefProps>({
open: () => {},
close: () => {},
})
const {activeDialogs} = useDialogStateContext()
const {activeDialogs, openDialogs} = useDialogStateContext()
const isOpen = openDialogs.includes(id)
React.useEffect(() => {
activeDialogs.current.set(id, control)
@@ -31,9 +32,18 @@ export function useDialogControl(): DialogOuterProps['control'] {
}
}, [id, activeDialogs])
return {
ref: control,
open: () => control.current.open(),
close: cb => control.current.close(cb),
}
return React.useMemo<DialogOuterProps['control']>(
() => ({
id,
ref: control,
isOpen,
open: () => {
control.current.open()
},
close: cb => {
control.current.close(cb)
},
}),
[id, control, isOpen],
)
}
+114 -56
View File
@@ -1,17 +1,22 @@
import React, {useImperativeHandle} from 'react'
import {View, Dimensions} from 'react-native'
import {View, Dimensions, Keyboard, Pressable} from 'react-native'
import BottomSheet, {
BottomSheetBackdrop,
BottomSheetFlatList,
BottomSheetBackdropProps,
BottomSheetScrollView,
BottomSheetTextInput,
BottomSheetView,
useBottomSheet,
WINDOW_HEIGHT,
} from '@gorhom/bottom-sheet'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import Animated, {useAnimatedStyle} from 'react-native-reanimated'
import {useTheme, atoms as a, flatten} from '#/alf'
import {Portal} from '#/components/Portal'
import {createInput} from '#/components/forms/TextField'
import {logger} from '#/logger'
import {useDialogStateControlContext} from '#/state/dialogs'
import {
DialogOuterProps,
@@ -26,6 +31,47 @@ export * from '#/components/Dialog/types'
export const Input = createInput(BottomSheetTextInput)
export const FlatList = BottomSheetFlatList
function Backdrop(props: BottomSheetBackdropProps) {
const t = useTheme()
const bottomSheet = useBottomSheet()
const animatedStyle = useAnimatedStyle(() => {
const opacity =
(Math.abs(WINDOW_HEIGHT - props.animatedPosition.value) - 50) / 1000
return {
opacity: Math.min(Math.max(opacity, 0), 0.55),
}
})
const onPress = React.useCallback(() => {
bottomSheet.close()
}, [bottomSheet])
return (
<Animated.View
style={[
t.atoms.bg_contrast_300,
{
top: 0,
left: 0,
right: 0,
bottom: 0,
position: 'absolute',
},
animatedStyle,
]}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Dialog backdrop"
accessibilityHint="Press the backdrop to close the dialog"
style={{flex: 1}}
onPress={onPress}
/>
</Animated.View>
)
}
export function Outer({
children,
control,
@@ -38,6 +84,7 @@ export function Outer({
const hasSnapPoints = !!sheetOptions.snapPoints
const insets = useSafeAreaInsets()
const closeCallback = React.useRef<() => void>()
const {setDialogIsOpen} = useDialogStateControlContext()
/*
* Used to manage open/closed, but index is otherwise handled internally by `BottomSheet`
@@ -51,14 +98,15 @@ export function Outer({
const open = React.useCallback<DialogControlProps['open']>(
({index} = {}) => {
setDialogIsOpen(control.id, true)
// can be set to any index of `snapPoints`, but `0` is the first i.e. "open"
setOpenIndex(index || 0)
},
[setOpenIndex],
[setOpenIndex, setDialogIsOpen, control.id],
)
const close = React.useCallback<DialogControlProps['close']>(cb => {
if (cb) {
if (cb && typeof cb === 'function') {
closeCallback.current = cb
}
sheet.current?.close()
@@ -73,63 +121,66 @@ export function Outer({
[open, close],
)
const onChange = React.useCallback(
(index: number) => {
if (index === -1) {
closeCallback.current?.()
closeCallback.current = undefined
onClose?.()
setOpenIndex(-1)
}
},
[onClose, setOpenIndex],
)
const onCloseInner = React.useCallback(() => {
Keyboard.dismiss()
try {
closeCallback.current?.()
} catch (e: any) {
logger.error(`Dialog closeCallback failed`, {
message: e.message,
})
} finally {
closeCallback.current = undefined
}
setDialogIsOpen(control.id, false)
onClose?.()
setOpenIndex(-1)
}, [control.id, onClose, setDialogIsOpen])
const context = React.useMemo(() => ({close}), [close])
return (
isOpen && (
<Portal>
<BottomSheet
enableDynamicSizing={!hasSnapPoints}
enablePanDownToClose
keyboardBehavior="interactive"
android_keyboardInputMode="adjustResize"
keyboardBlurBehavior="restore"
topInset={insets.top}
{...sheetOptions}
snapPoints={sheetOptions.snapPoints || ['100%']}
ref={sheet}
index={openIndex}
backgroundStyle={{backgroundColor: 'transparent'}}
backdropComponent={props => (
<BottomSheetBackdrop
opacity={0.4}
appearsOnIndex={0}
disappearsOnIndex={-1}
{...props}
style={[flatten(props.style), t.atoms.bg_contrast_300]}
/>
)}
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
handleStyle={{display: 'none'}}
onChange={onChange}>
<Context.Provider value={context}>
<View
style={[
a.absolute,
a.inset_0,
t.atoms.bg,
{
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
height: Dimensions.get('window').height * 2,
},
]}
/>
{children}
</Context.Provider>
</BottomSheet>
<View
// iOS
accessibilityViewIsModal
// Android
importantForAccessibility="yes"
style={[a.absolute, a.inset_0]}>
<BottomSheet
enableDynamicSizing={!hasSnapPoints}
enablePanDownToClose
keyboardBehavior="interactive"
android_keyboardInputMode="adjustResize"
keyboardBlurBehavior="restore"
topInset={insets.top}
{...sheetOptions}
snapPoints={sheetOptions.snapPoints || ['100%']}
ref={sheet}
index={openIndex}
backgroundStyle={{backgroundColor: 'transparent'}}
backdropComponent={Backdrop}
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
handleStyle={{display: 'none'}}
onClose={onCloseInner}>
<Context.Provider value={context}>
<View
style={[
a.absolute,
a.inset_0,
t.atoms.bg,
{
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
height: Dimensions.get('window').height * 2,
},
]}
/>
{children}
</Context.Provider>
</BottomSheet>
</View>
</Portal>
)
)
@@ -179,8 +230,15 @@ export function ScrollableInner({children, style}: DialogInnerProps) {
export function Handle() {
const t = useTheme()
const onTouchStart = React.useCallback(() => {
Keyboard.dismiss()
}, [])
return (
<View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}>
<View
style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}
onTouchStart={onTouchStart}>
<View
style={[
a.rounded_sm,
+8 -4
View File
@@ -12,6 +12,7 @@ import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types'
import {Context} from '#/components/Dialog/context'
import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {useDialogStateControlContext} from '#/state/dialogs'
export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
export * from '#/components/Dialog/types'
@@ -30,18 +31,21 @@ export function Outer({
const {gtMobile} = useBreakpoints()
const [isOpen, setIsOpen] = React.useState(false)
const [isVisible, setIsVisible] = React.useState(true)
const {setDialogIsOpen} = useDialogStateControlContext()
const open = React.useCallback(() => {
setIsOpen(true)
}, [setIsOpen])
setDialogIsOpen(control.id, true)
}, [setIsOpen, setDialogIsOpen, control.id])
const close = React.useCallback(async () => {
setIsVisible(false)
await new Promise(resolve => setTimeout(resolve, 150))
setIsOpen(false)
setIsVisible(true)
setDialogIsOpen(control.id, false)
onClose?.()
}, [onClose, setIsOpen])
}, [onClose, setIsOpen, setDialogIsOpen, control.id])
useImperativeHandle(
control.ref,
@@ -189,9 +193,9 @@ export function Close() {
<Button
size="small"
variant="ghost"
color="primary"
color="secondary"
shape="round"
onPress={close}
onPress={() => close()}
label={_(msg`Close active dialog`)}>
<ButtonIcon icon={X} size="md" />
</Button>
+21 -9
View File
@@ -6,8 +6,27 @@ import {ViewStyleProp} from '#/alf'
type A11yProps = Required<AccessibilityProps>
/**
* Mutated by useImperativeHandle to provide a public API for controlling the
* dialog. The methods here will actually become the handlers defined within
* the `Dialog.Outer` component.
*/
export type DialogControlRefProps = {
open: (options?: DialogControlOpenOptions) => void
close: (callback?: () => void) => void
}
/**
* The return type of the useDialogControl hook.
*/
export type DialogControlProps = DialogControlRefProps & {
id: string
ref: React.RefObject<DialogControlRefProps>
isOpen: boolean
}
export type DialogContextProps = {
close: () => void
close: DialogControlProps['close']
}
export type DialogControlOpenOptions = {
@@ -20,15 +39,8 @@ export type DialogControlOpenOptions = {
index?: number
}
export type DialogControlProps = {
open: (options?: DialogControlOpenOptions) => void
close: (callback?: () => void) => void
}
export type DialogOuterProps = {
control: {
ref: React.RefObject<DialogControlProps>
} & DialogControlProps
control: DialogControlProps
onClose?: () => void
nativeOptions?: {
sheet?: Omit<BottomSheetProps, 'children'>
+7 -7
View File
@@ -49,7 +49,7 @@ type BaseLinkProps = Pick<
*
* Note: atm this only works for `InlineLink`s with a string child.
*/
warnOnMismatchingTextChild?: boolean
disableMismatchWarning?: boolean
/**
* Callback for when the link is pressed. Prevent default and return `false`
@@ -69,7 +69,7 @@ export function useLink({
to,
displayText,
action = 'push',
warnOnMismatchingTextChild,
disableMismatchWarning,
onPress: outerOnPress,
}: BaseLinkProps & {
displayText: string
@@ -90,7 +90,7 @@ export function useLink({
if (exitEarlyIfFalse === false) return
const requiresWarning = Boolean(
warnOnMismatchingTextChild &&
!disableMismatchWarning &&
displayText &&
isExternal &&
linkRequiresWarning(href, displayText),
@@ -148,7 +148,7 @@ export function useLink({
},
[
outerOnPress,
warnOnMismatchingTextChild,
disableMismatchWarning,
displayText,
isExternal,
href,
@@ -167,7 +167,7 @@ export function useLink({
}
}
export type LinkProps = Omit<BaseLinkProps, 'warnOnMismatchingTextChild'> &
export type LinkProps = Omit<BaseLinkProps, 'disableMismatchWarning'> &
Omit<ButtonProps, 'onPress' | 'disabled' | 'label'>
/**
@@ -226,7 +226,7 @@ export function InlineLink({
children,
to,
action = 'push',
warnOnMismatchingTextChild,
disableMismatchWarning,
style,
onPress: outerOnPress,
download,
@@ -239,7 +239,7 @@ export function InlineLink({
to,
displayText: stringChildren ? children : '',
action,
warnOnMismatchingTextChild,
disableMismatchWarning,
onPress: outerOnPress,
})
const {
+249
View File
@@ -0,0 +1,249 @@
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'
import {Button} from '#/components/Button'
import {Text} from '#/components/Typography'
import {StackActions} from '@react-navigation/native'
import {useNavigation} from '@react-navigation/core'
import {NavigationProp} from 'lib/routes/types'
import {router} from '#/routes'
export function ListFooter({
isFetching,
isError,
error,
onRetry,
}: {
isFetching: boolean
isError: boolean
error?: string
onRetry?: () => Promise<unknown>
}) {
const t = useTheme()
return (
<View
style={[
a.w_full,
a.align_center,
a.justify_center,
a.border_t,
a.pb_lg,
t.atoms.border_contrast_low,
{height: 100},
]}>
{isFetching ? (
<Loader size="xl" />
) : (
<ListFooterMaybeError
isError={isError}
error={error}
onRetry={onRetry}
/>
)}
</View>
)
}
function ListFooterMaybeError({
isError,
error,
onRetry,
}: {
isError: boolean
error?: string
onRetry?: () => Promise<unknown>
}) {
const t = useTheme()
if (!isError) return null
return (
<View style={[a.w_full, a.px_lg]}>
<View
style={[
a.flex_row,
a.gap_md,
a.p_md,
a.rounded_sm,
a.align_center,
t.atoms.bg_contrast_25,
]}>
<Text
style={[a.flex_1, a.text_sm, t.atoms.text_contrast_medium]}
numberOfLines={2}>
{error ? (
cleanError(error)
) : (
<Trans>Oops, something went wrong!</Trans>
)}
</Text>
<Button
variant="gradient"
label="Press to retry"
style={[
a.align_center,
a.justify_center,
a.rounded_sm,
a.overflow_hidden,
a.px_md,
a.py_sm,
]}
onPress={onRetry}>
Retry
</Button>
</View>
</View>
)
}
export function ListHeaderDesktop({
title,
subtitle,
}: {
title: string
subtitle?: string
}) {
const {gtTablet} = useBreakpoints()
const t = useTheme()
if (!gtTablet) return null
return (
<View style={[a.w_full, a.py_lg, a.px_xl, a.gap_xs]}>
<Text style={[a.text_3xl, a.font_bold]}>{title}</Text>
{subtitle ? (
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
{subtitle}
</Text>
) : undefined}
</View>
)
}
export function ListMaybePlaceholder({
isLoading,
isEmpty,
isError,
empty,
error,
notFoundType = 'page',
onRetry,
}: {
isLoading: boolean
isEmpty: boolean
isError: boolean
empty?: string
error?: string
notFoundType?: 'page' | 'results'
onRetry?: () => Promise<unknown>
}) {
const navigation = useNavigation<NavigationProp>()
const t = useTheme()
const {gtMobile, gtTablet} = useBreakpoints()
const canGoBack = navigation.canGoBack()
const onGoBack = React.useCallback(() => {
if (canGoBack) {
navigation.goBack()
} else {
navigation.navigate('HomeTab')
// Checking the state for routes ensures that web doesn't encounter errors while going back
if (navigation.getState()?.routes) {
navigation.dispatch(StackActions.push(...router.matchPath('/')))
} else {
navigation.navigate('HomeTab')
navigation.dispatch(StackActions.popToTop())
}
}
}, [navigation, canGoBack])
if (!isEmpty) return null
return (
<CenteredView
style={[
a.flex_1,
a.align_center,
!gtMobile ? a.justify_between : a.gap_5xl,
t.atoms.border_contrast_low,
{paddingTop: 175, paddingBottom: 110},
]}
sideBorders={gtMobile}
topBorder={!gtTablet}>
{isLoading ? (
<View style={[a.w_full, a.align_center, {top: 100}]}>
<Loader size="xl" />
</View>
) : (
<>
<View style={[a.w_full, a.align_center, a.gap_lg]}>
<Text style={[a.font_bold, a.text_3xl]}>
{isError ? (
<Trans>Oops!</Trans>
) : isEmpty ? (
<>
{notFoundType === 'results' ? (
<Trans>No results found</Trans>
) : (
<Trans>Page not found</Trans>
)}
</>
) : undefined}
</Text>
{isError ? (
<Text
style={[a.text_md, a.text_center, t.atoms.text_contrast_high]}>
{error ? error : <Trans>Something went wrong!</Trans>}
</Text>
) : isEmpty ? (
<Text
style={[a.text_md, a.text_center, t.atoms.text_contrast_high]}>
{empty ? (
empty
) : (
<Trans>
We're sorry! We can't find the page you were looking for.
</Trans>
)}
</Text>
) : undefined}
</View>
<View
style={[a.gap_md, !gtMobile ? [a.w_full, a.px_lg] : {width: 350}]}>
{isError && onRetry && (
<Button
variant="solid"
color="primary"
label="Click here"
onPress={onRetry}
size="large"
style={[
a.rounded_sm,
a.overflow_hidden,
{paddingVertical: 10},
]}>
Retry
</Button>
)}
<Button
variant="solid"
color={isError && onRetry ? 'secondary' : 'primary'}
label="Click here"
onPress={onGoBack}
size="large"
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
Go Back
</Button>
</View>
</>
)}
</CenteredView>
)
}
+8
View File
@@ -0,0 +1,8 @@
import React from 'react'
import type {ContextType} from '#/components/Menu/types'
export const Context = React.createContext<ContextType>({
// @ts-ignore
control: null,
})
+190
View File
@@ -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<ContextType>(
() => ({
control: control || defaultControl,
}),
[control, defaultControl],
)
return <Context.Provider value={context}>{children}</Context.Provider>
}
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 (
<Dialog.Outer control={context.control}>
<Dialog.Handle />
{/* Re-wrap with context since Dialogs are portal-ed to root */}
<Context.Provider value={context}>
<Dialog.ScrollableInner label="Menu TODO">
<View style={[a.gap_lg]}>{children}</View>
<View style={{height: a.gap_lg.gap}} />
</Dialog.ScrollableInner>
</Context.Provider>
</Dialog.Outer>
)
}
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 (
<Pressable
{...rest}
accessibilityHint=""
accessibilityLabel={label}
onPress={e => {
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}
</Pressable>
)
}
export function ItemText({children, style}: ItemTextProps) {
const t = useTheme()
return (
<Text
numberOfLines={1}
ellipsizeMode="middle"
style={[
a.flex_1,
a.text_md,
a.font_bold,
t.atoms.text_contrast_medium,
{paddingTop: 3},
style,
]}>
{children}
</Text>
)
}
export function ItemIcon({icon: Comp}: ItemIconProps) {
const t = useTheme()
return <Comp size="lg" fill={t.atoms.text_contrast_medium.color} />
}
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 React.isValidElement(child) && child.type === Item ? (
<React.Fragment key={i}>
{i > 0 ? (
<View style={[a.border_b, t.atoms.border_contrast_low]} />
) : null}
{React.cloneElement(child, {
// @ts-ignore
style: {
borderRadius: 0,
borderWidth: 0,
},
})}
</React.Fragment>
) : null
})}
</View>
)
}
export function Divider() {
return null
}
+247
View File
@@ -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<ContextType>(
() => ({
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 (
<Context.Provider value={context}>
<DropdownMenu.Root
open={context.control.isOpen}
onOpenChange={onOpenChange}>
{children}
</DropdownMenu.Root>
</Context.Provider>
)
}
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 (
<DropdownMenu.Trigger asChild>
<Pressable
accessibilityHint=""
accessibilityLabel={label}
onFocus={onFocus}
onBlur={onBlur}
style={flatten([style, web({outline: 0})])}
onPointerDown={() => {
control.open()
}}
{...web({
onMouseEnter,
onMouseLeave,
})}>
{children({
isNative: false,
control,
state: {
hovered,
focused,
pressed: false,
},
props: {},
})}
</Pressable>
</DropdownMenu.Trigger>
)
}
export function Outer({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
return (
<DropdownMenu.Portal>
<DropdownMenu.Content sideOffset={5} loop aria-label="Test">
<View
style={[
a.rounded_sm,
a.p_xs,
t.name === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25,
t.atoms.shadow_md,
]}>
{children}
</View>
<DropdownMenu.Arrow
className="DropdownMenuArrow"
fill={
(t.name === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25)
.backgroundColor
}
/>
</DropdownMenu.Content>
</DropdownMenu.Portal>
)
}
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 (
<DropdownMenu.Item asChild>
<Pressable
{...rest}
className="radix-dropdown-item"
accessibilityHint=""
accessibilityLabel={label}
onPress={e => {
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}
</Pressable>
</DropdownMenu.Item>
)
}
export function ItemText({children, style}: ItemTextProps) {
const t = useTheme()
return (
<Text style={[a.flex_1, a.font_bold, t.atoms.text_contrast_high, style]}>
{children}
</Text>
)
}
export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
const t = useTheme()
return (
<Comp
size="md"
fill={t.atoms.text_contrast_medium.color}
style={[
position === 'left' && {
marginLeft: -2,
},
position === 'right' && {
marginRight: -2,
marginLeft: 12,
},
]}
/>
)
}
export function Group({children}: GroupProps) {
return children
}
export function Divider() {
const t = useTheme()
return (
<DropdownMenu.Separator
style={flatten([
a.my_xs,
t.atoms.bg_contrast_100,
{
height: 1,
},
])}
/>
)
}
+72
View File
@@ -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<PressableProps, 'style'> &
ViewStyleProp & {
label: string
onPress: (e: GestureResponderEvent) => void
}
>
export type ItemTextProps = React.PropsWithChildren<TextStyleProp & {}>
export type ItemIconProps = React.PropsWithChildren<{
icon: React.ComponentType<SVGIconProps>
position?: 'left' | 'right'
}>
export type GroupProps = React.PropsWithChildren<ViewStyleProp & {}>
+1 -1
View File
@@ -89,7 +89,7 @@ export function Cancel({
color="secondary"
size="small"
label={_(msg`Cancel`)}
onPress={close}>
onPress={() => close()}>
{children}
</Button>
)
+110 -24
View File
@@ -1,11 +1,15 @@
import React from 'react'
import {RichText as RichTextAPI, AppBskyRichtextFacet} from '@atproto/api'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {atoms as a, TextStyleProp, flatten} from '#/alf'
import {atoms as a, TextStyleProp, flatten, useTheme, web, native} from '#/alf'
import {InlineLink} from '#/components/Link'
import {Text, TextProps} from '#/components/Typography'
import {toShortUrl} from 'lib/strings/url-helpers'
import {getAgent} from '#/state/session'
import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
import {isNative} from '#/platform/detection'
import {useInteractionState} from '#/components/hooks/useInteractionState'
const WORD_WRAP = {wordWrap: 1}
@@ -15,37 +19,25 @@ export function RichText({
style,
numberOfLines,
disableLinks,
resolveFacets = false,
selectable,
enableTags = false,
authorHandle,
}: TextStyleProp &
Pick<TextProps, 'selectable'> & {
value: RichTextAPI | string
testID?: string
numberOfLines?: number
disableLinks?: boolean
resolveFacets?: boolean
enableTags?: boolean
authorHandle?: string
}) {
const detected = React.useRef(false)
const [richText, setRichText] = React.useState<RichTextAPI>(() =>
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
const richText = React.useMemo(
() =>
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
[value],
)
const styles = [a.leading_snug, flatten(style)]
React.useEffect(() => {
if (!resolveFacets) return
async function detectFacets() {
const rt = new RichTextAPI({text: richText.text})
await rt.detectFacets(getAgent())
setRichText(rt)
}
if (!detected.current) {
detected.current = true
detectFacets()
}
}, [richText, setRichText, resolveFacets])
const {text, facets} = richText
if (!facets?.length) {
@@ -85,6 +77,7 @@ export function RichText({
for (const segment of richText.segments()) {
const link = segment.link
const mention = segment.mention
const tag = segment.tag
if (
mention &&
AppBskyRichtextFacet.validateMention(mention).success &&
@@ -112,12 +105,27 @@ export function RichText({
to={link.uri}
style={[...styles, {pointerEvents: 'auto'}]}
// @ts-ignore TODO
dataSet={WORD_WRAP}
warnOnMismatchingLabel>
dataSet={WORD_WRAP}>
{toShortUrl(segment.text)}
</InlineLink>,
)
}
} else if (
!disableLinks &&
enableTags &&
tag &&
AppBskyRichtextFacet.validateTag(tag).success
) {
els.push(
<RichTextTag
key={key}
text={segment.text}
tag={tag.tag}
style={styles}
selectable={selectable}
authorHandle={authorHandle}
/>,
)
} else {
els.push(segment.text)
}
@@ -136,3 +144,81 @@ export function RichText({
</Text>
)
}
function RichTextTag({
text,
tag,
style,
selectable,
authorHandle,
}: {
text: string
tag: string
selectable?: boolean
authorHandle?: string
} & TextStyleProp) {
const t = useTheme()
const {_} = useLingui()
const control = useTagMenuControl()
const {
state: hovered,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const {
state: pressed,
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
const open = React.useCallback(() => {
control.open()
}, [control])
/*
* N.B. On web, this is wrapped in another pressable comopnent with a11y
* labels, etc. That's why only some of these props are applied here.
*/
return (
<React.Fragment>
<TagMenu control={control} tag={tag} authorHandle={authorHandle}>
<Text
selectable={selectable}
{...native({
accessibilityLabel: _(msg`Hashtag: #${tag}`),
accessibilityHint: _(msg`Click here to open tag menu for #${tag}`),
accessibilityRole: isNative ? 'button' : undefined,
onPress: open,
onPressIn: onPressIn,
onPressOut: onPressOut,
})}
{...web({
onMouseEnter: onHoverIn,
onMouseLeave: onHoverOut,
})}
// @ts-ignore
onFocus={onFocus}
onBlur={onBlur}
style={[
style,
{
pointerEvents: 'auto',
color: t.palette.primary_500,
},
web({
cursor: 'pointer',
}),
(hovered || focused || pressed) && {
...web({outline: 0}),
textDecorationLine: 'underline',
textDecorationColor: t.palette.primary_500,
},
]}>
{text}
</Text>
</TagMenu>
</React.Fragment>
)
}
+275
View File
@@ -0,0 +1,275 @@
import React from 'react'
import {View} from 'react-native'
import {useNavigation} from '@react-navigation/native'
import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro'
import {atoms as a, native, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {Text} from '#/components/Typography'
import {Button, ButtonText} from '#/components/Button'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {Divider} from '#/components/Divider'
import {Link} from '#/components/Link'
import {makeSearchLink} from '#/lib/routes/links'
import {NavigationProp} from '#/lib/routes/types'
import {
usePreferencesQuery,
useUpsertMutedWordsMutation,
useRemoveMutedWordMutation,
} from '#/state/queries/preferences'
import {Loader} from '#/components/Loader'
import {isInvalidHandle} from '#/lib/strings/handles'
export function useTagMenuControl() {
return Dialog.useDialogControl()
}
export function TagMenu({
children,
control,
tag,
authorHandle,
}: React.PropsWithChildren<{
control: Dialog.DialogOuterProps['control']
/**
* This should be the sanitized tag value from the facet itself, not the
* "display" value with a leading `#`.
*/
tag: string
authorHandle?: string
}>) {
const {_} = useLingui()
const t = useTheme()
const navigation = useNavigation<NavigationProp>()
const {isLoading: isPreferencesLoading, data: preferences} =
usePreferencesQuery()
const {
mutateAsync: upsertMutedWord,
variables: optimisticUpsert,
reset: resetUpsert,
} = useUpsertMutedWordsMutation()
const {
mutateAsync: removeMutedWord,
variables: optimisticRemove,
reset: resetRemove,
} = useRemoveMutedWordMutation()
const displayTag = '#' + tag
const isMuted = Boolean(
(preferences?.mutedWords?.find(
m => m.value === tag && m.targets.includes('tag'),
) ??
optimisticUpsert?.find(
m => m.value === tag && m.targets.includes('tag'),
)) &&
!(optimisticRemove?.value === tag),
)
return (
<>
{children}
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.Inner label={_(msg`Tag menu: ${displayTag}`)}>
{isPreferencesLoading ? (
<View style={[a.w_full, a.align_center]}>
<Loader size="lg" />
</View>
) : (
<>
<View
style={[
a.rounded_md,
a.border,
a.mb_md,
t.atoms.border_contrast_low,
t.atoms.bg_contrast_25,
]}>
<Link
label={_(msg`Search for all posts with tag ${displayTag}`)}
to={makeSearchLink({query: displayTag})}
onPress={e => {
e.preventDefault()
control.close(() => {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
})
})
return false
}}>
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.justify_start,
a.gap_md,
a.px_lg,
a.py_md,
]}>
<Search size="lg" style={[t.atoms.text_contrast_medium]} />
<Text
numberOfLines={1}
ellipsizeMode="middle"
style={[
a.flex_1,
a.text_md,
a.font_bold,
native({top: 2}),
t.atoms.text_contrast_medium,
]}>
<Trans>
See{' '}
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
{displayTag}
</Text>{' '}
posts
</Trans>
</Text>
</View>
</Link>
{authorHandle && !isInvalidHandle(authorHandle) && (
<>
<Divider />
<Link
label={_(
msg`Search for all posts by @${authorHandle} with tag ${displayTag}`,
)}
to={makeSearchLink({
query: displayTag,
from: authorHandle,
})}
onPress={e => {
e.preventDefault()
control.close(() => {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
author: authorHandle,
})
})
return false
}}>
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.justify_start,
a.gap_md,
a.px_lg,
a.py_md,
]}>
<Person
size="lg"
style={[t.atoms.text_contrast_medium]}
/>
<Text
numberOfLines={1}
ellipsizeMode="middle"
style={[
a.flex_1,
a.text_md,
a.font_bold,
native({top: 2}),
t.atoms.text_contrast_medium,
]}>
<Trans>
See{' '}
<Text
style={[a.text_md, a.font_bold, t.atoms.text]}>
{displayTag}
</Text>{' '}
posts by this user
</Trans>
</Text>
</View>
</Link>
</>
)}
{preferences ? (
<>
<Divider />
<Button
label={
isMuted
? _(msg`Unmute all ${displayTag} posts`)
: _(msg`Mute all ${displayTag} posts`)
}
onPress={() => {
control.close(() => {
if (isMuted) {
resetUpsert()
removeMutedWord({
value: tag,
targets: ['tag'],
})
} else {
resetRemove()
upsertMutedWord([{value: tag, targets: ['tag']}])
}
})
}}>
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.justify_start,
a.gap_md,
a.px_lg,
a.py_md,
]}>
<Mute
size="lg"
style={[t.atoms.text_contrast_medium]}
/>
<Text
numberOfLines={1}
ellipsizeMode="middle"
style={[
a.flex_1,
a.text_md,
a.font_bold,
native({top: 2}),
t.atoms.text_contrast_medium,
]}>
{isMuted ? _(msg`Unmute`) : _(msg`Mute`)}{' '}
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
{displayTag}
</Text>{' '}
<Trans>posts</Trans>
</Text>
</View>
</Button>
</>
) : null}
</View>
<Button
label={_(msg`Close this dialog`)}
size="small"
variant="ghost"
color="secondary"
onPress={() => control.close()}>
<ButtonText>Cancel</ButtonText>
</Button>
</>
)}
</Dialog.Inner>
</Dialog.Outer>
</>
)
}
+149
View File
@@ -0,0 +1,149 @@
import React from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {isInvalidHandle} from '#/lib/strings/handles'
import {EventStopper} from '#/view/com/util/EventStopper'
import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown'
import {NavigationProp} from '#/lib/routes/types'
import {
usePreferencesQuery,
useUpsertMutedWordsMutation,
useRemoveMutedWordMutation,
} from '#/state/queries/preferences'
import {enforceLen} from '#/lib/strings/helpers'
import {web} from '#/alf'
import * as Dialog from '#/components/Dialog'
export function useTagMenuControl(): Dialog.DialogControlProps {
return {
id: '',
// @ts-ignore
ref: null,
open: () => {
throw new Error(`TagMenu controls are only available on native platforms`)
},
close: () => {
throw new Error(`TagMenu controls are only available on native platforms`)
},
}
}
export function TagMenu({
children,
tag,
authorHandle,
}: React.PropsWithChildren<{
/**
* This should be the sanitized tag value from the facet itself, not the
* "display" value with a leading `#`.
*/
tag: string
authorHandle?: string
}>) {
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
const {data: preferences} = usePreferencesQuery()
const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} =
useUpsertMutedWordsMutation()
const {mutateAsync: removeMutedWord, variables: optimisticRemove} =
useRemoveMutedWordMutation()
const isMuted = Boolean(
(preferences?.mutedWords?.find(
m => m.value === tag && m.targets.includes('tag'),
) ??
optimisticUpsert?.find(
m => m.value === tag && m.targets.includes('tag'),
)) &&
!(optimisticRemove?.value === tag),
)
const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle')
const dropdownItems = React.useMemo(() => {
return [
{
label: _(msg`See ${truncatedTag} posts`),
onPress() {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
})
},
testID: 'tagMenuSearch',
icon: {
ios: {
name: 'magnifyingglass',
},
android: '',
web: 'magnifying-glass',
},
},
authorHandle &&
!isInvalidHandle(authorHandle) && {
label: _(msg`See ${truncatedTag} posts by user`),
onPress() {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
author: authorHandle,
})
},
testID: 'tagMenuSeachByUser',
icon: {
ios: {
name: 'magnifyingglass',
},
android: '',
web: ['far', 'user'],
},
},
preferences && {
label: 'separator',
},
preferences && {
label: isMuted
? _(msg`Unmute ${truncatedTag}`)
: _(msg`Mute ${truncatedTag}`),
onPress() {
if (isMuted) {
removeMutedWord({value: tag, targets: ['tag']})
} else {
upsertMutedWord([{value: tag, targets: ['tag']}])
}
},
testID: 'tagMenuMute',
icon: {
ios: {
name: 'speaker.slash',
},
android: 'ic_menu_sort_alphabetically',
web: isMuted ? 'eye' : ['far', 'eye-slash'],
},
},
].filter(Boolean)
}, [
_,
authorHandle,
isMuted,
navigation,
preferences,
tag,
truncatedTag,
upsertMutedWord,
removeMutedWord,
])
return (
<EventStopper>
<NativeDropdown
accessibilityLabel={_(msg`Click here to open tag menu for ${tag}`)}
accessibilityHint=""
// @ts-ignore
items={dropdownItems}
triggerStyle={web({
textAlign: 'left',
})}>
{children}
</NativeDropdown>
</EventStopper>
)
}
+389
View File
@@ -0,0 +1,389 @@
import React from 'react'
import {Keyboard, View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api'
import {
usePreferencesQuery,
useUpsertMutedWordsMutation,
useRemoveMutedWordMutation,
} from '#/state/queries/preferences'
import {isNative} from '#/platform/detection'
import {
atoms as a,
useTheme,
useBreakpoints,
ViewStyleProp,
web,
native,
} from '#/alf'
import {Text} from '#/components/Typography'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
import {Divider} from '#/components/Divider'
import {Loader} from '#/components/Loader'
import {logger} from '#/logger'
import * as Dialog from '#/components/Dialog'
import * as Toggle from '#/components/forms/Toggle'
import * as Prompt from '#/components/Prompt'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
export function MutedWordsDialog() {
const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<MutedWordsInner control={control} />
</Dialog.Outer>
)
}
function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
const t = useTheme()
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const {
isLoading: isPreferencesLoading,
data: preferences,
error: preferencesError,
} = usePreferencesQuery()
const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
const [field, setField] = React.useState('')
const [options, setOptions] = React.useState(['content'])
const [error, setError] = React.useState('')
const submit = React.useCallback(async () => {
const sanitizedValue = sanitizeMutedWordValue(field)
const targets = ['tag', options.includes('content') && 'content'].filter(
Boolean,
) as AppBskyActorDefs.MutedWord['targets']
if (!sanitizedValue || !targets.length) {
setField('')
setError(_(msg`Please enter a valid word, tag, or phrase to mute`))
return
}
try {
// send raw value and rely on SDK as sanitization source of truth
await addMutedWord([{value: field, targets}])
setField('')
} catch (e: any) {
logger.error(`Failed to save muted word`, {message: e.message})
setError(e.message)
}
}, [_, field, options, addMutedWord, setField])
return (
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
<View onTouchStart={Keyboard.dismiss}>
<Text
style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
<Trans>Add muted words and tags</Trans>
</Text>
<Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>
Posts can be muted based on their text, their tags, or both.
</Trans>
</Text>
<View style={[a.pb_lg]}>
<Dialog.Input
autoCorrect={false}
autoCapitalize="none"
autoComplete="off"
label={_(msg`Enter a word or tag`)}
placeholder={_(msg`Enter a word or tag`)}
value={field}
onChangeText={value => {
if (error) {
setError('')
}
setField(value)
}}
onSubmitEditing={submit}
/>
<Toggle.Group
label={_(msg`Toggle between muted word options.`)}
type="radio"
values={options}
onChange={setOptions}>
<View
style={[
a.pt_sm,
a.py_sm,
a.flex_row,
a.align_center,
a.gap_sm,
a.flex_wrap,
]}>
<Toggle.Item
label={_(msg`Mute this word in post text and tags`)}
name="content"
style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
<TargetToggle>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.Label>
<Trans>Mute in text & tags</Trans>
</Toggle.Label>
</View>
<PageText size="sm" />
</TargetToggle>
</Toggle.Item>
<Toggle.Item
label={_(msg`Mute this word in tags only`)}
name="tag"
style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
<TargetToggle>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.Label>
<Trans>Mute in tags only</Trans>
</Toggle.Label>
</View>
<Hashtag size="sm" />
</TargetToggle>
</Toggle.Item>
<Button
disabled={isPending || !field}
label={_(msg`Add mute word for configured settings`)}
size="small"
color="primary"
variant="solid"
style={[!gtMobile && [a.w_full, a.flex_0]]}
onPress={submit}>
<ButtonText>
<Trans>Add</Trans>
</ButtonText>
<ButtonIcon icon={isPending ? Loader : Plus} />
</Button>
</View>
</Toggle.Group>
{error && (
<View
style={[
a.mb_lg,
a.flex_row,
a.rounded_sm,
a.p_md,
a.mb_xs,
t.atoms.bg_contrast_25,
{
backgroundColor: t.palette.negative_400,
},
]}>
<Text
style={[
a.italic,
{color: t.palette.white},
native({marginTop: 2}),
]}>
{error}
</Text>
</View>
)}
<Text
style={[
a.pt_xs,
a.text_sm,
a.italic,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>
We recommend avoiding common words that appear in many posts,
since it can result in no posts being shown.
</Trans>
</Text>
</View>
<Divider />
<View style={[a.pt_2xl]}>
<Text
style={[
a.text_md,
a.font_bold,
a.pb_md,
t.atoms.text_contrast_high,
]}>
<Trans>Your muted words</Trans>
</Text>
{isPreferencesLoading ? (
<Loader />
) : preferencesError || !preferences ? (
<View
style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
<Text style={[a.italic, t.atoms.text_contrast_high]}>
<Trans>
We're sorry, but we weren't able to load your muted words at
this time. Please try again.
</Trans>
</Text>
</View>
) : preferences.mutedWords.length ? (
[...preferences.mutedWords]
.reverse()
.map((word, i) => (
<MutedWordRow
key={word.value + i}
word={word}
style={[i % 2 === 0 && t.atoms.bg_contrast_25]}
/>
))
) : (
<View
style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
<Text style={[a.italic, t.atoms.text_contrast_high]}>
<Trans>You haven't muted any words or tags yet</Trans>
</Text>
</View>
)}
</View>
{isNative && <View style={{height: 20}} />}
<Dialog.Close />
</View>
</Dialog.ScrollableInner>
)
}
function MutedWordRow({
style,
word,
}: ViewStyleProp & {word: AppBskyActorDefs.MutedWord}) {
const t = useTheme()
const {_} = useLingui()
const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
const control = Prompt.usePromptControl()
const remove = React.useCallback(async () => {
control.close()
removeMutedWord(word)
}, [removeMutedWord, word, control])
return (
<>
<Prompt.Outer control={control}>
<Prompt.Title>
<Trans>Are you sure?</Trans>
</Prompt.Title>
<Prompt.Description>
<Trans>
This will delete {word.value} from your muted words. You can always
add it back later.
</Trans>
</Prompt.Description>
<Prompt.Actions>
<Prompt.Cancel>
<ButtonText>
<Trans>Nevermind</Trans>
</ButtonText>
</Prompt.Cancel>
<Prompt.Action onPress={remove}>
<ButtonText>
<Trans>Remove</Trans>
</ButtonText>
</Prompt.Action>
</Prompt.Actions>
</Prompt.Outer>
<View
style={[
a.py_md,
a.px_lg,
a.flex_row,
a.align_center,
a.justify_between,
a.rounded_md,
a.gap_md,
style,
]}>
<Text
style={[
a.flex_1,
a.leading_snug,
a.w_full,
a.font_bold,
t.atoms.text_contrast_high,
web({
overflowWrap: 'break-word',
wordBreak: 'break-word',
}),
]}>
{word.value}
</Text>
<View style={[a.flex_row, a.align_center, a.justify_end, a.gap_sm]}>
{word.targets.map(target => (
<View
key={target}
style={[a.py_xs, a.px_sm, a.rounded_sm, t.atoms.bg_contrast_100]}>
<Text
style={[a.text_xs, a.font_bold, t.atoms.text_contrast_medium]}>
{target === 'content' ? _(msg`text`) : _(msg`tag`)}
</Text>
</View>
))}
<Button
label={_(msg`Remove mute word from your list`)}
size="tiny"
shape="round"
variant="ghost"
color="secondary"
onPress={() => control.open()}
style={[a.ml_sm]}>
<ButtonIcon icon={isPending ? Loader : X} />
</Button>
</View>
</View>
</>
)
}
function TargetToggle({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
const ctx = Toggle.useItemContext()
const {gtMobile} = useBreakpoints()
return (
<View
style={[
a.flex_row,
a.align_center,
a.justify_between,
a.gap_xs,
a.flex_1,
a.py_sm,
a.px_sm,
gtMobile && a.px_md,
a.rounded_sm,
t.atoms.bg_contrast_50,
(ctx.hovered || ctx.focused) && t.atoms.bg_contrast_100,
ctx.selected && [
{
backgroundColor:
t.name === 'light' ? t.palette.primary_50 : t.palette.primary_975,
},
],
ctx.disabled && {
opacity: 0.8,
},
]}>
{children}
</View>
)
}
@@ -1,8 +1,5 @@
import React from 'react'
import {View, Pressable} from 'react-native'
import DateTimePicker, {
BaseProps as DateTimePickerProps,
} from '@react-native-community/datetimepicker'
import {useTheme, atoms} from '#/alf'
import {Text} from '#/components/Typography'
@@ -15,6 +12,8 @@ import {
localizeDate,
toSimpleDateString,
} from '#/components/forms/DateField/utils'
import DatePicker from 'react-native-date-picker'
import {isAndroid} from 'platform/detection'
export * as utils from '#/components/forms/DateField/utils'
export const Label = TextField.Label
@@ -38,20 +37,20 @@ export function DateField({
const {chromeFocus, chromeError, chromeErrorHover} =
TextField.useSharedInputStyles()
const onChangeInternal = React.useCallback<
Required<DateTimePickerProps>['onChange']
>(
(_event, date) => {
const onChangeInternal = React.useCallback(
(date: Date) => {
setOpen(false)
if (date) {
const formatted = toSimpleDateString(date)
onChangeDate(formatted)
}
const formatted = toSimpleDateString(date)
onChangeDate(formatted)
},
[onChangeDate, setOpen],
)
const onCancel = React.useCallback(() => {
setOpen(false)
}, [])
return (
<View style={[atoms.relative, atoms.w_full]}>
<Pressable
@@ -89,18 +88,18 @@ export function DateField({
</Pressable>
{open && (
<DateTimePicker
<DatePicker
modal={isAndroid}
open={isAndroid}
theme={t.name === 'light' ? 'light' : 'dark'}
date={new Date(value)}
onConfirm={onChangeInternal}
onCancel={onCancel}
mode="date"
testID={`${testID}-datepicker`}
aria-label={label}
accessibilityLabel={label}
accessibilityHint={undefined}
testID={`${testID}-datepicker`}
mode="date"
timeZoneName={'Etc/UTC'}
display="spinner"
// @ts-ignore applies in iOS only -prf
themeVariant={t.name === 'light' ? 'light' : 'dark'}
value={new Date(value)}
onChange={onChangeInternal}
/>
)}
</View>
+8 -12
View File
@@ -1,13 +1,11 @@
import React from 'react'
import {View} from 'react-native'
import DateTimePicker, {
DateTimePickerEvent,
} from '@react-native-community/datetimepicker'
import {useTheme, atoms} from '#/alf'
import * as TextField from '#/components/forms/TextField'
import {toSimpleDateString} from '#/components/forms/DateField/utils'
import {DateFieldProps} from '#/components/forms/DateField/types'
import DatePicker from 'react-native-date-picker'
export * as utils from '#/components/forms/DateField/utils'
export const Label = TextField.Label
@@ -28,7 +26,7 @@ export function DateField({
const t = useTheme()
const onChangeInternal = React.useCallback(
(event: DateTimePickerEvent, date: Date | undefined) => {
(date: Date | undefined) => {
if (date) {
const formatted = toSimpleDateString(date)
onChangeDate(formatted)
@@ -39,17 +37,15 @@ export function DateField({
return (
<View style={[atoms.relative, atoms.w_full]}>
<DateTimePicker
<DatePicker
theme={t.name === 'light' ? 'light' : 'dark'}
date={new Date(value)}
onDateChange={onChangeInternal}
mode="date"
testID={`${testID}-datepicker`}
aria-label={label}
accessibilityLabel={label}
accessibilityHint={undefined}
testID={`${testID}-datepicker`}
mode="date"
timeZoneName={'Etc/UTC'}
display="spinner"
themeVariant={t.name === 'light' ? 'light' : 'dark'}
value={new Date(value)}
onChange={onChangeInternal}
/>
</View>
)
+3 -3
View File
@@ -10,7 +10,7 @@ import {
} from 'react-native'
import {HITSLOP_20} from 'lib/constants'
import {useTheme, atoms as a, web, tokens, android} from '#/alf'
import {useTheme, atoms as a, web, android} from '#/alf'
import {Text} from '#/components/Typography'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Props as SVGIconProps} from '#/components/icons/common'
@@ -72,7 +72,7 @@ export function Root({children, isInvalid = false}: RootProps) {
return (
<Context.Provider value={context}>
<View
style={[a.flex_row, a.align_center, a.relative, a.w_full, a.px_md]}
style={[a.flex_row, a.align_center, a.relative, a.flex_1, a.px_md]}
{...web({
onClick: () => inputRef.current?.focus(),
onMouseOver: onHoverIn,
@@ -110,7 +110,7 @@ export function useSharedInputStyles() {
{
backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
borderColor: tokens.color.red_500,
borderColor: t.palette.negative_500,
},
]
+12 -26
View File
@@ -13,6 +13,7 @@ import {
} from '#/alf'
import {Text} from '#/components/Typography'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check'
export type ItemState = {
name: string
@@ -314,7 +315,7 @@ export function createSharedToggleStyles({
if (isInvalid) {
base.push({
backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_975,
borderColor:
t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
})
@@ -323,7 +324,7 @@ export function createSharedToggleStyles({
baseHover.push({
backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
borderColor: t.palette.negative_500,
borderColor: t.palette.negative_600,
})
}
}
@@ -345,15 +346,14 @@ export function createSharedToggleStyles({
export function Checkbox() {
const t = useTheme()
const {selected, hovered, focused, disabled, isInvalid} = useItemContext()
const {baseStyles, baseHoverStyles, indicatorStyles} =
createSharedToggleStyles({
theme: t,
hovered,
focused,
selected,
disabled,
isInvalid,
})
const {baseStyles, baseHoverStyles} = createSharedToggleStyles({
theme: t,
hovered,
focused,
selected,
disabled,
isInvalid,
})
return (
<View
style={[
@@ -369,21 +369,7 @@ export function Checkbox() {
baseStyles,
hovered || focused ? baseHoverStyles : {},
]}>
{selected ? (
<View
style={[
a.absolute,
a.rounded_2xs,
{height: 12, width: 12},
selected
? {
backgroundColor: t.palette.primary_500,
}
: {},
indicatorStyles,
]}
/>
) : null}
{selected ? <Checkmark size="xs" fill={t.palette.primary_500} /> : null}
</View>
)
}
+4
View File
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
export const Check_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M21.59 3.193a1 1 0 0 1 .217 1.397l-11.706 16a1 1 0 0 1-1.429.193l-6.294-5a1 1 0 1 1 1.244-1.566l5.48 4.353 11.09-15.16a1 1 0 0 1 1.398-.217Z',
})
export const CheckThick_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M21.474 2.98a2.5 2.5 0 0 1 .545 3.494l-10.222 14a2.5 2.5 0 0 1-3.528.52L2.49 16.617a2.5 2.5 0 0 1 3.018-3.986l3.75 2.84L17.98 3.525a2.5 2.5 0 0 1 3.493-.545Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Clipboard_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M8.17 4A3.001 3.001 0 0 1 11 2h2c1.306 0 2.418.835 2.83 2H17a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3h1.17ZM8 6H7a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1h-1v1a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V6Zm6 0V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v1h4Z',
})
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const MagnifyingGlass2_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M11 5a6 6 0 1 0 0 12 6 6 0 0 0 0-12Zm-8 6a8 8 0 1 1 14.32 4.906l3.387 3.387a1 1 0 0 1-1.414 1.414l-3.387-3.387A8 8 0 0 1 3 11Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Mute_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M20.707 3.293a1 1 0 0 1 0 1.414l-16 16a1 1 0 0 1-1.414-1.414l2.616-2.616A1.998 1.998 0 0 1 5 15V9a2 2 0 0 1 2-2h2.697l5.748-3.832A1 1 0 0 1 17 4v1.586l2.293-2.293a1 1 0 0 1 1.414 0ZM15 7.586 7.586 15H7V9h2.697a2 2 0 0 0 1.11-.336L15 5.87v1.717Zm2 3.657-2 2v4.888l-2.933-1.955-1.442 1.442 4.82 3.214A1 1 0 0 0 17 20v-8.757Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const PageText_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M5 2a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H5Zm1 18V4h12v16H6Zm3-6a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2H9Zm-1-3a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2H9a1 1 0 0 1-1-1Zm1-5a1 1 0 0 0 0 2h6a1 1 0 1 0 0-2H9Z',
})
@@ -94,6 +94,15 @@ function ModerationDetailsDialogInner({
name = _(msg`Account Muted`)
description = _(msg`You have muted this user.`)
}
// TODO
// } else if (cause.type === 'muted-word') {
// return {
// icon: EyeSlash,
// name: _(msg`Post hidden by muted word`),
// description: _(
// msg`You've chosen to hide a word or tag within this post.`,
// ),
// }
} else if (modcause.type === 'label') {
name = desc.name
description = desc.description