diff options
Diffstat (limited to 'src')
38 files changed, 1226 insertions, 493 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 0aeeeb6ad..b30f8f982 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -77,6 +77,7 @@ import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbed import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth' import {msg} from '@lingui/macro' import {i18n, MessageDescriptor} from '@lingui/core' +import HashtagScreen from '#/screens/Hashtag' const navigationRef = createNavigationContainerRef<AllNavigatorParams>() @@ -262,6 +263,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { requireAuth: true, }} /> + <Stack.Screen + name="Hashtag" + getComponent={() => HashtagScreen} + options={{title: title(msg`Hashtag`)}} + /> </> ) } @@ -479,12 +485,19 @@ const LINKING = { }, getStateFromPath(path: string) { + const [name, params] = router.matchPath(path) + // Any time we receive a url that starts with `intent/` we want to ignore it here. It will be handled in the // intent handler hook. We should check for the trailing slash, because if there isn't one then it isn't a valid // intent - if (path.includes('intent/')) return + // On web, there is no route state that's created by default, so we should initialize it as the home route. On + // native, since the home tab and the home screen are defined as initial routes, we don't need to return a state + // since it will be created by react-navigation. + if (path.includes('intent/')) { + if (isNative) return + return buildStateObject('Flat', 'Home', params) + } - const [name, params] = router.matchPath(path) if (isNative) { if (name === 'Search') { return buildStateObject('SearchTab', 'Search', params) diff --git a/src/alf/index.tsx b/src/alf/index.tsx index 06d6ebf01..27738e91d 100644 --- a/src/alf/index.tsx +++ b/src/alf/index.tsx @@ -17,7 +17,7 @@ const breakpoints: { [key: string]: number } = { gtMobile: 800, - gtTablet: 1200, + gtTablet: 1300, } function getActiveBreakpoints({width}: {width: number}) { const active: (keyof typeof breakpoints)[] = Object.keys(breakpoints).filter( diff --git a/src/alf/themes.ts b/src/alf/themes.ts index da96f6eff..0c95a459e 100644 --- a/src/alf/themes.ts +++ b/src/alf/themes.ts @@ -1,6 +1,7 @@ import * as tokens from '#/alf/tokens' import type {Mutable} from '#/alf/types' import {atoms} from '#/alf/atoms' +import {BLUE_HUE, GREEN_HUE, RED_HUE} from '#/alf/util/colorGeneration' export type ThemeName = 'light' | 'dim' | 'dark' export type ReadonlyTheme = typeof light @@ -73,19 +74,19 @@ export const darkPalette: Palette = { white: tokens.color.gray_0, black: tokens.color.trueBlack, - contrast_25: `hsl(211, 28%, 8%)`, - contrast_50: `hsl(211, 28%, 11%)`, - contrast_100: `hsl(211, 28%, 16%)`, - contrast_200: `hsl(211, 28%, 24%)`, - contrast_300: `hsl(211, 24%, 31%)`, - contrast_400: `hsl(211, 24%, 38%)`, - contrast_500: `hsl(211, 20%, 44%)`, - contrast_600: `hsl(211, 20%, 55%)`, - contrast_700: `hsl(211, 20%, 63%)`, - contrast_800: `hsl(211, 20%, 71%)`, - contrast_900: `hsl(211, 20%, 79%)`, - contrast_950: `hsl(211, 20%, 87%)`, - contrast_975: `hsl(211, 20%, 95%)`, + contrast_25: tokens.color.gray_1000, + contrast_50: tokens.color.gray_975, + contrast_100: tokens.color.gray_950, + contrast_200: tokens.color.gray_900, + contrast_300: tokens.color.gray_800, + contrast_400: tokens.color.gray_700, + contrast_500: tokens.color.gray_600, + contrast_600: tokens.color.gray_500, + contrast_700: tokens.color.gray_400, + contrast_800: tokens.color.gray_300, + contrast_900: tokens.color.gray_200, + contrast_950: tokens.color.gray_100, + contrast_975: tokens.color.gray_50, primary_25: tokens.color.blue_25, primary_50: tokens.color.blue_50, @@ -132,28 +133,63 @@ export const darkPalette: Palette = { export const dimPalette: Palette = { ...darkPalette, - black: `hsl(211, 28%, 12%)`, + black: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[0]}%)`, - contrast_25: `hsl(211, 28%, 15%)`, - contrast_50: `hsl(211, 28%, 18%)`, - contrast_100: `hsl(211, 28%, 24%)`, - contrast_200: `hsl(211, 28%, 27%)`, - contrast_300: `hsl(211, 24%, 34%)`, - contrast_400: `hsl(211, 24%, 41%)`, - contrast_500: `hsl(211, 20%, 52%)`, - contrast_600: `hsl(211, 20%, 55%)`, - contrast_700: `hsl(211, 20%, 67%)`, - contrast_800: `hsl(211, 20%, 71%)`, - contrast_900: `hsl(211, 20%, 79%)`, - contrast_950: `hsl(211, 20%, 87%)`, - contrast_975: `hsl(211, 20%, 95%)`, + contrast_25: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[1]}%)`, + contrast_50: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[2]}%)`, + contrast_100: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[3]}%)`, + contrast_200: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[4]}%)`, + contrast_300: `hsl(${BLUE_HUE}, 24%, ${tokens.dimScale[5]}%)`, + contrast_400: `hsl(${BLUE_HUE}, 24%, ${tokens.dimScale[6]}%)`, + contrast_500: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[7]}%)`, + contrast_600: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[8]}%)`, + contrast_700: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[9]}%)`, + contrast_800: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[10]}%)`, + contrast_900: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[11]}%)`, + contrast_950: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[12]}%)`, + contrast_975: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[13]}%)`, - primary_600: `hsl(211, 95%, 39%)`, - primary_700: `hsl(211, 90%, 30%)`, - primary_800: `hsl(211, 90%, 23%)`, - primary_900: `hsl(211, 80%, 16%)`, - primary_950: `hsl(211, 80%, 13%)`, - primary_975: `hsl(211, 80%, 10%)`, + primary_25: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[13]}%)`, + primary_50: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[12]}%)`, + primary_100: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[11]}%)`, + primary_200: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[10]}%)`, + primary_300: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[9]}%)`, + primary_400: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[8]}%)`, + primary_500: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[7]}%)`, + primary_600: `hsl(${BLUE_HUE}, 95%, ${tokens.dimScale[6]}%)`, + primary_700: `hsl(${BLUE_HUE}, 90%, ${tokens.dimScale[5]}%)`, + primary_800: `hsl(${BLUE_HUE}, 82%, ${tokens.dimScale[4]}%)`, + primary_900: `hsl(${BLUE_HUE}, 70%, ${tokens.dimScale[3]}%)`, + primary_950: `hsl(${BLUE_HUE}, 60%, ${tokens.dimScale[2]}%)`, + primary_975: `hsl(${BLUE_HUE}, 50%, ${tokens.dimScale[1]}%)`, + + positive_25: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[13]}%)`, + positive_50: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[12]}%)`, + positive_100: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[11]}%)`, + positive_200: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[10]}%)`, + positive_300: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[9]}%)`, + positive_400: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[8]}%)`, + positive_500: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[7]}%)`, + positive_600: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[6]}%)`, + positive_700: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[5]}%)`, + positive_800: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[4]}%)`, + positive_900: `hsl(${GREEN_HUE}, 70%, ${tokens.dimScale[3]}%)`, + positive_950: `hsl(${GREEN_HUE}, 60%, ${tokens.dimScale[2]}%)`, + positive_975: `hsl(${GREEN_HUE}, 50%, ${tokens.dimScale[1]}%)`, + + negative_25: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[13]}%)`, + negative_50: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[12]}%)`, + negative_100: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[11]}%)`, + negative_200: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[10]}%)`, + negative_300: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[9]}%)`, + negative_400: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[8]}%)`, + negative_500: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[7]}%)`, + negative_600: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[6]}%)`, + negative_700: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[5]}%)`, + negative_800: `hsl(${RED_HUE}, 88%, ${tokens.dimScale[4]}%)`, + negative_900: `hsl(${RED_HUE}, 84%, ${tokens.dimScale[3]}%)`, + negative_950: `hsl(${RED_HUE}, 80%, ${tokens.dimScale[2]}%)`, + negative_975: `hsl(${RED_HUE}, 70%, ${tokens.dimScale[1]}%)`, } as const export const light = { @@ -404,17 +440,17 @@ export const dim: Theme = { shadow_sm: { ...atoms.shadow_sm, shadowOpacity: 0.7, - shadowColor: `hsl(211, 28%, 3%)`, + shadowColor: `hsl(${BLUE_HUE}, 28%, 6%)`, }, shadow_md: { ...atoms.shadow_md, shadowOpacity: 0.7, - shadowColor: `hsl(211, 28%, 3%)`, + shadowColor: `hsl(${BLUE_HUE}, 28%, 6%)`, }, shadow_lg: { ...atoms.shadow_lg, shadowOpacity: 0.7, - shadowColor: `hsl(211, 28%, 3%)`, + shadowColor: `hsl(${BLUE_HUE}, 28%, 6%)`, }, }, } diff --git a/src/alf/tokens.ts b/src/alf/tokens.ts index f0b8c7c69..b1468f461 100644 --- a/src/alf/tokens.ts +++ b/src/alf/tokens.ts @@ -1,25 +1,32 @@ -const BLUE_HUE = 211 -const RED_HUE = 346 -const GREEN_HUE = 152 +import { + BLUE_HUE, + RED_HUE, + GREEN_HUE, + generateScale, +} from '#/alf/util/colorGeneration' + +export const scale = generateScale(6, 100) +// dim shifted 6% lighter +export const dimScale = generateScale(12, 100) export const color = { trueBlack: '#000000', - gray_0: `hsl(${BLUE_HUE}, 20%, 100%)`, - gray_25: `hsl(${BLUE_HUE}, 20%, 97%)`, - gray_50: `hsl(${BLUE_HUE}, 20%, 95%)`, - gray_100: `hsl(${BLUE_HUE}, 20%, 90%)`, - gray_200: `hsl(${BLUE_HUE}, 20%, 80%)`, - gray_300: `hsl(${BLUE_HUE}, 20%, 70%)`, - gray_400: `hsl(${BLUE_HUE}, 20%, 60%)`, - gray_500: `hsl(${BLUE_HUE}, 20%, 50%)`, - gray_600: `hsl(${BLUE_HUE}, 24%, 42%)`, - gray_700: `hsl(${BLUE_HUE}, 24%, 34%)`, - gray_800: `hsl(${BLUE_HUE}, 28%, 26%)`, - gray_900: `hsl(${BLUE_HUE}, 28%, 18%)`, - gray_950: `hsl(${BLUE_HUE}, 28%, 10%)`, - gray_975: `hsl(${BLUE_HUE}, 28%, 7%)`, - gray_1000: `hsl(${BLUE_HUE}, 28%, 4%)`, + gray_0: `hsl(${BLUE_HUE}, 20%, ${scale[14]}%)`, + gray_25: `hsl(${BLUE_HUE}, 20%, ${scale[13]}%)`, + gray_50: `hsl(${BLUE_HUE}, 20%, ${scale[12]}%)`, + gray_100: `hsl(${BLUE_HUE}, 20%, ${scale[11]}%)`, + gray_200: `hsl(${BLUE_HUE}, 20%, ${scale[10]}%)`, + gray_300: `hsl(${BLUE_HUE}, 20%, ${scale[9]}%)`, + gray_400: `hsl(${BLUE_HUE}, 20%, ${scale[8]}%)`, + gray_500: `hsl(${BLUE_HUE}, 20%, ${scale[7]}%)`, + gray_600: `hsl(${BLUE_HUE}, 24%, ${scale[6]}%)`, + gray_700: `hsl(${BLUE_HUE}, 24%, ${scale[5]}%)`, + gray_800: `hsl(${BLUE_HUE}, 28%, ${scale[4]}%)`, + gray_900: `hsl(${BLUE_HUE}, 28%, ${scale[3]}%)`, + gray_950: `hsl(${BLUE_HUE}, 28%, ${scale[2]}%)`, + gray_975: `hsl(${BLUE_HUE}, 28%, ${scale[1]}%)`, + gray_1000: `hsl(${BLUE_HUE}, 28%, ${scale[0]}%)`, blue_25: `hsl(${BLUE_HUE}, 99%, 97%)`, blue_50: `hsl(${BLUE_HUE}, 99%, 95%)`, diff --git a/src/alf/util/colorGeneration.ts b/src/alf/util/colorGeneration.ts new file mode 100644 index 000000000..929a01d3a --- /dev/null +++ b/src/alf/util/colorGeneration.ts @@ -0,0 +1,17 @@ +export const BLUE_HUE = 211 +export const RED_HUE = 346 +export const GREEN_HUE = 152 + +/** + * Smooth progression of lightness "stops" for generating HSL colors. + */ +export const COLOR_STOPS = [ + 0, 0.05, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.85, 0.9, 0.95, 1, +] + +export function generateScale(start: number, end: number) { + const range = end - start + return COLOR_STOPS.map(stop => { + return start + range * stop + }) +} diff --git a/src/components/Button.tsx b/src/components/Button.tsx index e401bda2a..5361be963 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -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, }) } } @@ -461,31 +453,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, }) } } diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index 6dfc24f3b..ef4f4741b 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -1,12 +1,15 @@ import React, {useImperativeHandle} from 'react' -import {View, Dimensions} from 'react-native' +import {View, Dimensions, Keyboard, Pressable} from 'react-native' import BottomSheet, { - BottomSheetBackdrop, + 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' @@ -26,6 +29,47 @@ export * from '#/components/Dialog/types' // @ts-ignore export const Input = createInput(BottomSheetTextInput) +function Backdrop(props: BottomSheetBackdropProps) { + const t = useTheme() + const bottomSheet = useBottomSheet() + + const animatedStyle = useAnimatedStyle(() => { + const opacity = + (Math.abs(WINDOW_HEIGHT - props.animatedPosition.value) - 50) / 1000 + + return { + opacity: Math.min(Math.max(opacity, 0), 0.55), + } + }) + + const onPress = React.useCallback(() => { + bottomSheet.close() + }, [bottomSheet]) + + return ( + <Animated.View + style={[ + t.atoms.bg_contrast_300, + { + top: 0, + left: 0, + right: 0, + bottom: 0, + position: 'absolute', + }, + animatedStyle, + ]}> + <Pressable + accessibilityRole="button" + accessibilityLabel="Dialog backdrop" + accessibilityHint="Press the backdrop to close the dialog" + style={{flex: 1}} + onPress={onPress} + /> + </Animated.View> + ) +} + export function Outer({ children, control, @@ -78,6 +122,7 @@ export function Outer({ const onChange = React.useCallback( (index: number) => { if (index === -1) { + Keyboard.dismiss() try { closeCallback.current?.() } catch (e: any) { @@ -113,15 +158,7 @@ export function Outer({ 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]} - /> - )} + backdropComponent={Backdrop} handleIndicatorStyle={{backgroundColor: t.palette.primary_500}} handleStyle={{display: 'none'}} onChange={onChange}> @@ -190,8 +227,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, diff --git a/src/components/Link.tsx b/src/components/Link.tsx index 0a654fed2..8c963909b 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -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 { diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx new file mode 100644 index 000000000..12a935807 --- /dev/null +++ b/src/components/Lists.tsx @@ -0,0 +1,246 @@ +import React from 'react' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {View} from 'react-native' +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} = 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 ( + <View + style={[ + a.flex_1, + a.align_center, + !gtMobile ? [a.justify_between, a.border_t] : a.gap_5xl, + t.atoms.border_contrast_low, + {paddingTop: 175, paddingBottom: 110}, + ]}> + {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> + </> + )} + </View> + ) +} diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx index 3d5f08026..1a14415cf 100644 --- a/src/components/RichText.tsx +++ b/src/components/RichText.tsx @@ -105,8 +105,7 @@ export function RichText({ to={link.uri} style={[...styles, {pointerEvents: 'auto'}]} // @ts-ignore TODO - dataSet={WORD_WRAP} - warnOnMismatchingLabel> + dataSet={WORD_WRAP}> {toShortUrl(segment.text)} </InlineLink>, ) @@ -121,6 +120,7 @@ export function RichText({ <RichTextTag key={key} text={segment.text} + tag={tag.tag} style={styles} selectable={selectable} authorHandle={authorHandle} @@ -146,12 +146,14 @@ export function RichText({ } function RichTextTag({ - text: tag, + text, + tag, style, selectable, authorHandle, }: { text: string + tag: string selectable?: boolean authorHandle?: string } & TextStyleProp) { @@ -185,8 +187,8 @@ function RichTextTag({ <Text selectable={selectable} {...native({ - accessibilityLabel: _(msg`Hashtag: ${tag}`), - accessibilityHint: _(msg`Click here to open tag menu for ${tag}`), + accessibilityLabel: _(msg`Hashtag: #${tag}`), + accessibilityHint: _(msg`Click here to open tag menu for #${tag}`), accessibilityRole: isNative ? 'button' : undefined, onPress: open, onPressIn: onPressIn, @@ -214,7 +216,7 @@ function RichTextTag({ textDecorationColor: t.palette.primary_500, }, ]}> - {tag} + {text} </Text> </TagMenu> </React.Fragment> diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx index 2fec7a188..c9ced9a54 100644 --- a/src/components/TagMenu/index.tsx +++ b/src/components/TagMenu/index.tsx @@ -34,6 +34,10 @@ export function TagMenu({ 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 }>) { @@ -52,16 +56,16 @@ export function TagMenu({ variables: optimisticRemove, reset: resetRemove, } = useRemoveMutedWordMutation() + const displayTag = '#' + tag - const sanitizedTag = tag.replace(/^#/, '') const isMuted = Boolean( (preferences?.mutedWords?.find( - m => m.value === sanitizedTag && m.targets.includes('tag'), + m => m.value === tag && m.targets.includes('tag'), ) ?? optimisticUpsert?.find( - m => m.value === sanitizedTag && m.targets.includes('tag'), + m => m.value === tag && m.targets.includes('tag'), )) && - !(optimisticRemove?.value === sanitizedTag), + !(optimisticRemove?.value === tag), ) return ( @@ -71,7 +75,7 @@ export function TagMenu({ <Dialog.Outer control={control}> <Dialog.Handle /> - <Dialog.Inner label={_(msg`Tag menu: ${tag}`)}> + <Dialog.Inner label={_(msg`Tag menu: ${displayTag}`)}> {isPreferencesLoading ? ( <View style={[a.w_full, a.align_center]}> <Loader size="lg" /> @@ -87,18 +91,14 @@ export function TagMenu({ t.atoms.bg_contrast_25, ]}> <Link - label={_(msg`Search for all posts with tag ${tag}`)} - to={makeSearchLink({query: tag})} + label={_(msg`Search for all posts with tag ${displayTag}`)} + to={makeSearchLink({query: displayTag})} onPress={e => { e.preventDefault() control.close(() => { - // @ts-ignore :ron_swanson: "I know more than you" - navigation.navigate('SearchTab', { - screen: 'Search', - params: { - q: tag, - }, + navigation.push('Hashtag', { + tag: tag.replaceAll('#', '%23'), }) }) @@ -128,7 +128,7 @@ export function TagMenu({ <Trans> See{' '} <Text style={[a.text_md, a.font_bold, t.atoms.text]}> - {tag} + {displayTag} </Text>{' '} posts </Trans> @@ -142,21 +142,19 @@ export function TagMenu({ <Link label={_( - msg`Search for all posts by @${authorHandle} with tag ${tag}`, + msg`Search for all posts by @${authorHandle} with tag ${displayTag}`, )} - to={makeSearchLink({query: tag, from: authorHandle})} + to={makeSearchLink({ + query: displayTag, + from: authorHandle, + })} onPress={e => { e.preventDefault() control.close(() => { - // @ts-ignore :ron_swanson: "I know more than you" - navigation.navigate('SearchTab', { - screen: 'Search', - params: { - q: - tag + - (authorHandle ? ` from:${authorHandle}` : ''), - }, + navigation.push('Hashtag', { + tag: tag.replaceAll('#', '%23'), + author: authorHandle, }) }) @@ -190,7 +188,7 @@ export function TagMenu({ See{' '} <Text style={[a.text_md, a.font_bold, t.atoms.text]}> - {tag} + {displayTag} </Text>{' '} posts by this user </Trans> @@ -207,22 +205,20 @@ export function TagMenu({ <Button label={ isMuted - ? _(msg`Unmute all ${tag} posts`) - : _(msg`Mute all ${tag} posts`) + ? _(msg`Unmute all ${displayTag} posts`) + : _(msg`Mute all ${displayTag} posts`) } onPress={() => { control.close(() => { if (isMuted) { resetUpsert() removeMutedWord({ - value: sanitizedTag, + value: tag, targets: ['tag'], }) } else { resetRemove() - upsertMutedWord([ - {value: sanitizedTag, targets: ['tag']}, - ]) + upsertMutedWord([{value: tag, targets: ['tag']}]) } }) }}> @@ -252,7 +248,7 @@ export function TagMenu({ ]}> {isMuted ? _(msg`Unmute`) : _(msg`Mute`)}{' '} <Text style={[a.text_md, a.font_bold, t.atoms.text]}> - {tag} + {displayTag} </Text>{' '} <Trans>posts</Trans> </Text> diff --git a/src/components/TagMenu/index.web.tsx b/src/components/TagMenu/index.web.tsx index 31187112f..a0dc2bce6 100644 --- a/src/components/TagMenu/index.web.tsx +++ b/src/components/TagMenu/index.web.tsx @@ -14,18 +14,34 @@ import { } from '#/state/queries/preferences' import {enforceLen} from '#/lib/strings/helpers' import {web} from '#/alf' +import * as Dialog from '#/components/Dialog' -export function useTagMenuControl() {} +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 sanitizedTag = tag.replace(/^#/, '') const {_} = useLingui() const navigation = useNavigation<NavigationProp>() const {data: preferences} = usePreferencesQuery() @@ -35,22 +51,22 @@ export function TagMenu({ useRemoveMutedWordMutation() const isMuted = Boolean( (preferences?.mutedWords?.find( - m => m.value === sanitizedTag && m.targets.includes('tag'), + m => m.value === tag && m.targets.includes('tag'), ) ?? optimisticUpsert?.find( - m => m.value === sanitizedTag && m.targets.includes('tag'), + m => m.value === tag && m.targets.includes('tag'), )) && - !(optimisticRemove?.value === sanitizedTag), + !(optimisticRemove?.value === tag), ) - const truncatedTag = enforceLen(tag, 15, true, 'middle') + const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle') const dropdownItems = React.useMemo(() => { return [ { label: _(msg`See ${truncatedTag} posts`), onPress() { - navigation.navigate('Search', { - q: tag, + navigation.push('Hashtag', { + tag: tag.replaceAll('#', '%23'), }) }, testID: 'tagMenuSearch', @@ -66,11 +82,9 @@ export function TagMenu({ !isInvalidHandle(authorHandle) && { label: _(msg`See ${truncatedTag} posts by user`), onPress() { - navigation.navigate({ - name: 'Search', - params: { - q: tag + (authorHandle ? ` from:${authorHandle}` : ''), - }, + navigation.push('Hashtag', { + tag: tag.replaceAll('#', '%23'), + author: authorHandle, }) }, testID: 'tagMenuSeachByUser', @@ -91,9 +105,9 @@ export function TagMenu({ : _(msg`Mute ${truncatedTag}`), onPress() { if (isMuted) { - removeMutedWord({value: sanitizedTag, targets: ['tag']}) + removeMutedWord({value: tag, targets: ['tag']}) } else { - upsertMutedWord([{value: sanitizedTag, targets: ['tag']}]) + upsertMutedWord([{value: tag, targets: ['tag']}]) } }, testID: 'tagMenuMute', @@ -114,7 +128,6 @@ export function TagMenu({ preferences, tag, truncatedTag, - sanitizedTag, upsertMutedWord, removeMutedWord, ]) diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx index 7c0d4fbca..658ba2aae 100644 --- a/src/components/dialogs/MutedWords.tsx +++ b/src/components/dialogs/MutedWords.tsx @@ -1,8 +1,8 @@ import React from 'react' -import {View} from 'react-native' +import {Keyboard, View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {AppBskyActorDefs} from '@atproto/api' +import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api' import { usePreferencesQuery, @@ -10,7 +10,14 @@ import { useRemoveMutedWordMutation, } from '#/state/queries/preferences' import {isNative} from '#/platform/detection' -import {atoms as a, useTheme, useBreakpoints, ViewStyleProp, web} from '#/alf' +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' @@ -48,166 +55,208 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) { const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation() const [field, setField] = React.useState('') const [options, setOptions] = React.useState(['content']) - const [_error, setError] = React.useState('') + const [error, setError] = React.useState('') const submit = React.useCallback(async () => { - const value = field.trim() + const sanitizedValue = sanitizeMutedWordValue(field) const targets = ['tag', options.includes('content') && 'content'].filter( Boolean, ) as AppBskyActorDefs.MutedWord['targets'] - if (!value || !targets.length) return + if (!sanitizedValue || !targets.length) { + setField('') + setError(_(msg`Please enter a valid word, tag, or phrase to mute`)) + return + } try { - await addMutedWord([{value, targets}]) + // 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]) + }, [_, field, options, addMutedWord, setField]) return ( <Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}> - <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 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={setField} - onSubmitEditing={submit} - /> + <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.pb_md, - 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.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> + <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> + <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> - <Text - style={[ - 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> + {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> + )} - <Divider /> + <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> - <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> + <Divider /> - {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> + <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}} />} + {isNative && <View style={{height: 20}} />} - <Dialog.Close /> + <Dialog.Close /> + </View> </Dialog.ScrollableInner> ) } diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index a781bdd18..b37f4bfae 100644 --- a/src/components/forms/TextField.tsx +++ b/src/components/forms/TextField.tsx @@ -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' @@ -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, }, ] diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle.tsx index 140740f70..a83f92a2a 100644 --- a/src/components/forms/Toggle.tsx +++ b/src/components/forms/Toggle.tsx @@ -301,7 +301,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, }) @@ -310,7 +310,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, }) } } diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts index d1e2de31d..8741530b5 100644 --- a/src/lib/hooks/useIntentHandler.ts +++ b/src/lib/hooks/useIntentHandler.ts @@ -15,15 +15,20 @@ export function useIntentHandler() { React.useEffect(() => { const handleIncomingURL = (url: string) => { + // We want to be able to support bluesky:// deeplinks. It's unnatural for someone to use a deeplink with three + // slashes, like bluesky:///intent/follow. However, supporting just two slashes causes us to have to take care + // of two cases when parsing the url. If we ensure there is a third slash, we can always ensure the first + // path parameter is in pathname rather than in hostname. + if (url.startsWith('bluesky://') && !url.startsWith('bluesky:///')) { + url = url.replace('bluesky://', 'bluesky:///') + } + const urlp = new URL(url) - const [_, intentTypeNative, intentTypeWeb] = urlp.pathname.split('/') + const [_, intent, intentType] = urlp.pathname.split('/') // On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the // intent check. On web, we have to check the first part of the path since we have an actual hostname - const intentType = isNative ? intentTypeNative : intentTypeWeb - const isIntent = isNative - ? urlp.hostname === 'intent' - : intentTypeNative === 'intent' + const isIntent = intent === 'intent' const params = urlp.searchParams if (!isIntent) return @@ -69,10 +74,7 @@ function useComposeIntent() { return false } // We also should just filter out cases that don't have all the info we need - if (!VALID_IMAGE_REGEX.test(part)) { - return false - } - return true + return VALID_IMAGE_REGEX.test(part) }) .map(part => { const [uri, width, height] = part.split('|') diff --git a/src/lib/moderatePost_wrapped.ts b/src/lib/moderatePost_wrapped.ts index 92543b42c..9f6fa9c07 100644 --- a/src/lib/moderatePost_wrapped.ts +++ b/src/lib/moderatePost_wrapped.ts @@ -6,6 +6,7 @@ import { AppBskyFeedPost, AppBskyRichtextFacet, AppBskyEmbedImages, + AppBskyEmbedExternal, } from '@atproto/api' type ModeratePost = typeof moderatePost @@ -205,44 +206,151 @@ export function moderatePost_wrapped( if (subject.embed) { let embedHidden = false + let embedMuted = false + let externalMuted = false + if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) { embedHidden = hiddenPosts.includes(subject.embed.record.uri) + } + if ( + AppBskyEmbedRecordWithMedia.isView(subject.embed) && + AppBskyEmbedRecord.isViewRecord(subject.embed.record.record) + ) { + embedHidden = hiddenPosts.includes(subject.embed.record.record.uri) + } + if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) { if (AppBskyFeedPost.isRecord(subject.embed.record.value)) { - embedHidden = - embedHidden || + const embeddedPost = subject.embed.record.value + + embedMuted = + embedMuted || hasMutedWord({ mutedWords, - text: subject.embed.record.value.text, - facets: subject.embed.record.value.facets, - outlineTags: subject.embed.record.value.tags, - languages: subject.embed.record.value.langs, + text: embeddedPost.text, + facets: embeddedPost.facets, + outlineTags: embeddedPost.tags, + languages: embeddedPost.langs, isOwnPost, }) - if (AppBskyEmbedImages.isMain(subject.embed.record.value.embed)) { - for (const image of subject.embed.record.value.embed.images) { - embedHidden = - embedHidden || + if (AppBskyEmbedImages.isMain(embeddedPost.embed)) { + for (const image of embeddedPost.embed.images) { + embedMuted = + embedMuted || hasMutedWord({ mutedWords, text: image.alt, facets: [], outlineTags: [], - languages: subject.embed.record.value.langs, + languages: embeddedPost.langs, isOwnPost, }) } } + + if (AppBskyEmbedExternal.isMain(embeddedPost.embed)) { + const {external} = embeddedPost.embed + + embedMuted = + embedMuted || + hasMutedWord({ + mutedWords, + text: external.title + ' ' + external.description, + facets: [], + outlineTags: [], + languages: [], + isOwnPost, + }) + } + + if (AppBskyEmbedRecordWithMedia.isMain(embeddedPost.embed)) { + if (AppBskyEmbedExternal.isMain(embeddedPost.embed.media)) { + const {external} = embeddedPost.embed.media + + embedMuted = + embedMuted || + hasMutedWord({ + mutedWords, + text: external.title + ' ' + external.description, + facets: [], + outlineTags: [], + languages: [], + isOwnPost, + }) + } + + if (AppBskyEmbedImages.isMain(embeddedPost.embed.media)) { + for (const image of embeddedPost.embed.media.images) { + embedMuted = + embedMuted || + hasMutedWord({ + mutedWords, + text: image.alt, + facets: [], + outlineTags: [], + languages: AppBskyFeedPost.isRecord(embeddedPost.record) + ? embeddedPost.langs + : [], + isOwnPost, + }) + } + } + } } } + + if (AppBskyEmbedExternal.isView(subject.embed)) { + const {external} = subject.embed + + externalMuted = + externalMuted || + hasMutedWord({ + mutedWords, + text: external.title + ' ' + external.description, + facets: [], + outlineTags: [], + languages: [], + isOwnPost, + }) + } + if ( AppBskyEmbedRecordWithMedia.isView(subject.embed) && AppBskyEmbedRecord.isViewRecord(subject.embed.record.record) ) { - // TODO what - embedHidden = hiddenPosts.includes(subject.embed.record.record.uri) + if (AppBskyFeedPost.isRecord(subject.embed.record.record.value)) { + const post = subject.embed.record.record.value + embedMuted = + embedMuted || + hasMutedWord({ + mutedWords, + text: post.text, + facets: post.facets, + outlineTags: post.tags, + languages: post.langs, + isOwnPost, + }) + } + + if (AppBskyEmbedImages.isView(subject.embed.media)) { + for (const image of subject.embed.media.images) { + embedMuted = + embedMuted || + hasMutedWord({ + mutedWords, + text: image.alt, + facets: [], + outlineTags: [], + languages: AppBskyFeedPost.isRecord(subject.record) + ? subject.record.langs + : [], + isOwnPost, + }) + } + } } + if (embedHidden) { moderations.embed.filter = true moderations.embed.blur = true @@ -254,6 +362,17 @@ export function moderatePost_wrapped( priority: 1, } } + } else if (externalMuted || embedMuted) { + moderations.content.filter = true + moderations.content.blur = true + if (!moderations.content.cause) { + moderations.content.cause = { + // @ts-ignore Temporary extension to the moderation system -prf + type: 'muted-word', + source: {type: 'user'}, + priority: 1, + } + } } } diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 0ec09f610..6756a62a6 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -34,6 +34,7 @@ export type CommonNavigatorParams = { PreferencesThreads: undefined PreferencesExternalEmbeds: undefined Search: {q?: string} + Hashtag: {tag: string; author?: string} } export type BottomTabNavigatorParams = CommonNavigatorParams & { @@ -69,6 +70,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & { Search: {q?: string} Feeds: undefined Notifications: undefined + Hashtag: {tag: string; author?: string} } export type AllNavigatorParams = CommonNavigatorParams & { @@ -81,6 +83,7 @@ export type AllNavigatorParams = CommonNavigatorParams & { NotificationsTab: undefined Notifications: undefined MyProfileTab: undefined + Hashtag: {tag: string; author?: string} } // NOTE diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index ef341154d..ba2cdb39b 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -157,17 +157,11 @@ export function linkRequiresWarning(uri: string, label: string) { const host = urip.hostname.toLowerCase() - if (host === 'bsky.app') { + // Hosts that end with bsky.app or bsky.social should be trusted by default. + if (host.endsWith('bsky.app') || host.endsWith('bsky.social')) { // if this is a link to internal content, // warn if it represents itself as a URL to another app - if ( - labelDomain && - labelDomain !== 'bsky.app' && - isPossiblyAUrl(labelDomain) - ) { - return true - } - return false + return !!labelDomain && labelDomain !== host && isPossiblyAUrl(labelDomain) } else { // if this is a link to external content, // warn if the label doesnt match the target diff --git a/src/lib/themes.ts b/src/lib/themes.ts index 135d50ab6..bd75aabea 100644 --- a/src/lib/themes.ts +++ b/src/lib/themes.ts @@ -357,8 +357,8 @@ export const dimTheme: Theme = { textVeryLight: dimPalette.contrast_400, replyLine: dimPalette.contrast_200, replyLineDot: dimPalette.contrast_200, - unreadNotifBg: `hsl(211, 48%, 17%)`, - unreadNotifBorder: `hsl(211, 48%, 30%)`, + unreadNotifBg: dimPalette.primary_975, + unreadNotifBorder: dimPalette.primary_900, postCtrl: dimPalette.contrast_500, brandText: dimPalette.primary_500, emptyStateIcon: dimPalette.contrast_300, diff --git a/src/routes.ts b/src/routes.ts index d17f15912..3fc908b48 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -33,4 +33,5 @@ export const router = new Router({ TermsOfService: '/support/tos', CommunityGuidelines: '/support/community-guidelines', CopyrightPolicy: '/support/copyright', + Hashtag: '/hashtag/:tag', }) diff --git a/src/screens/Hashtag.tsx b/src/screens/Hashtag.tsx new file mode 100644 index 000000000..09a1f2824 --- /dev/null +++ b/src/screens/Hashtag.tsx @@ -0,0 +1,164 @@ +import React from 'react' +import {ListRenderItemInfo, Pressable} from 'react-native' +import {atoms as a, useBreakpoints} from '#/alf' +import {useFocusEffect} from '@react-navigation/native' +import {useSetMinimalShellMode} from 'state/shell' +import {ViewHeader} from 'view/com/util/ViewHeader' +import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {CommonNavigatorParams} from 'lib/routes/types' +import {useSearchPostsQuery} from 'state/queries/search-posts' +import {Post} from 'view/com/post/Post' +import {PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' +import {enforceLen} from 'lib/strings/helpers' +import { + ListFooter, + ListHeaderDesktop, + ListMaybePlaceholder, +} from '#/components/Lists' +import {List} from 'view/com/util/List' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {sanitizeHandle} from 'lib/strings/handles' +import {CenteredView} from 'view/com/util/Views' +import {ArrowOutOfBox_Stroke2_Corner0_Rounded} from '#/components/icons/ArrowOutOfBox' +import {shareUrl} from 'lib/sharing' +import {HITSLOP_10} from 'lib/constants' +import {isNative} from 'platform/detection' + +const renderItem = ({item}: ListRenderItemInfo<PostView>) => { + return <Post post={item} /> +} + +const keyExtractor = (item: PostView, index: number) => { + return `${item.uri}-${index}` +} + +export default function HashtagScreen({ + route, +}: NativeStackScreenProps<CommonNavigatorParams, 'Hashtag'>) { + const {tag, author} = route.params + const setMinimalShellMode = useSetMinimalShellMode() + const {gtMobile} = useBreakpoints() + const {_} = useLingui() + const [isPTR, setIsPTR] = React.useState(false) + + const fullTag = React.useMemo(() => { + return `#${tag.replaceAll('%23', '#')}` + }, [tag]) + + const queryParam = React.useMemo(() => { + if (!author) return fullTag + return `${fullTag} from:${sanitizeHandle(author)}` + }, [fullTag, author]) + + const headerTitle = React.useMemo(() => { + return enforceLen(fullTag.toLowerCase(), 24, true, 'middle') + }, [fullTag]) + + const sanitizedAuthor = React.useMemo(() => { + if (!author) return + return sanitizeHandle(author) + }, [author]) + + const { + data, + isFetching, + isLoading, + isRefetching, + isError, + error, + refetch, + fetchNextPage, + hasNextPage, + } = useSearchPostsQuery({query: queryParam}) + + const posts = React.useMemo(() => { + return data?.pages.flatMap(page => page.posts) || [] + }, [data]) + + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) + + const onShare = React.useCallback(() => { + const url = new URL('https://bsky.app') + url.pathname = `/hashtag/${tag}` + if (author) { + url.searchParams.set('author', author) + } + shareUrl(url.toString()) + }, [tag, author]) + + const onRefresh = React.useCallback(async () => { + setIsPTR(true) + await refetch() + setIsPTR(false) + }, [refetch]) + + const onEndReached = React.useCallback(() => { + if (isFetching || !hasNextPage || error) return + fetchNextPage() + }, [isFetching, hasNextPage, error, fetchNextPage]) + + return ( + <CenteredView style={a.flex_1} sideBorders={gtMobile}> + <ViewHeader + title={headerTitle} + subtitle={author ? _(msg`From @${sanitizedAuthor}`) : undefined} + canGoBack + renderButton={ + isNative + ? () => ( + <Pressable + accessibilityRole="button" + onPress={onShare} + hitSlop={HITSLOP_10}> + <ArrowOutOfBox_Stroke2_Corner0_Rounded + size="lg" + onPress={onShare} + /> + </Pressable> + ) + : undefined + } + /> + <ListMaybePlaceholder + isLoading={isLoading || isRefetching} + isError={isError} + isEmpty={posts.length < 1} + onRetry={refetch} + notFoundType="results" + empty={_(msg`We couldn't find any results for that hashtag.`)} + /> + {!isLoading && posts.length > 0 && ( + <List<PostView> + data={posts} + renderItem={renderItem} + keyExtractor={keyExtractor} + refreshing={isPTR} + onRefresh={onRefresh} + onEndReached={onEndReached} + onEndReachedThreshold={4} + // @ts-ignore web only -prf + desktopFixedHeight + ListHeaderComponent={ + <ListHeaderDesktop + title={headerTitle} + subtitle={author ? _(msg`From @${sanitizedAuthor}`) : undefined} + /> + } + ListFooterComponent={ + <ListFooter + isFetching={isFetching && !isRefetching} + isError={isError} + error={error?.name} + onRetry={fetchNextPage} + /> + } + /> + )} + </CenteredView> + ) +} diff --git a/src/view/com/composer/text-input/web/LinkDecorator.ts b/src/view/com/composer/text-input/web/LinkDecorator.ts index 19945de08..e36ac80e4 100644 --- a/src/view/com/composer/text-input/web/LinkDecorator.ts +++ b/src/view/com/composer/text-input/web/LinkDecorator.ts @@ -18,6 +18,8 @@ import {Mark} from '@tiptap/core' import {Plugin, PluginKey} from '@tiptap/pm/state' import {Node as ProsemirrorNode} from '@tiptap/pm/model' import {Decoration, DecorationSet} from '@tiptap/pm/view' +import {URL_REGEX} from '@atproto/api' + import {isValidDomain} from 'lib/strings/url-helpers' export const LinkDecorator = Mark.create({ @@ -78,8 +80,7 @@ function linkDecorator() { function iterateUris(str: string, cb: (from: number, to: number) => void) { let match - const re = - /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim + const re = URL_REGEX while ((match = re.exec(str))) { let uri = match[2] if (!uri.startsWith('http')) { diff --git a/src/view/com/composer/text-input/web/TagDecorator.ts b/src/view/com/composer/text-input/web/TagDecorator.ts index d820ec3f0..2bf3184a8 100644 --- a/src/view/com/composer/text-input/web/TagDecorator.ts +++ b/src/view/com/composer/text-input/web/TagDecorator.ts @@ -18,28 +18,36 @@ import {Mark} from '@tiptap/core' import {Plugin, PluginKey} from '@tiptap/pm/state' import {Node as ProsemirrorNode} from '@tiptap/pm/model' import {Decoration, DecorationSet} from '@tiptap/pm/view' +import {TAG_REGEX, TRAILING_PUNCTUATION_REGEX} from '@atproto/api' function getDecorations(doc: ProsemirrorNode) { const decorations: Decoration[] = [] doc.descendants((node, pos) => { if (node.isText && node.text) { - const regex = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/g + const regex = TAG_REGEX const textContent = node.textContent let match while ((match = regex.exec(textContent))) { - const [matchedString, tag] = match + const [matchedString, _, tag] = match - if (tag.length > 66) continue + if (!tag || tag.replace(TRAILING_PUNCTUATION_REGEX, '').length > 64) + continue - const [trailingPunc = ''] = tag.match(/\p{P}+$/u) || [] + const [trailingPunc = ''] = tag.match(TRAILING_PUNCTUATION_REGEX) || [] + const matchedFrom = match.index + matchedString.indexOf(tag) + const matchedTo = matchedFrom + (tag.length - trailingPunc.length) - const from = match.index + matchedString.indexOf(tag) - const to = from + (tag.length - trailingPunc.length) + /* + * The match is exclusive of `#` so we need to adjust the start of the + * highlight by -1 to include the `#` + */ + const start = pos + matchedFrom - 1 + const end = pos + matchedTo decorations.push( - Decoration.inline(pos + from, pos + to, { + Decoration.inline(start, end, { class: 'autolink', }), ) diff --git a/src/view/com/home/HomeHeader.tsx b/src/view/com/home/HomeHeader.tsx index bbd16465a..aa3ecb7fc 100644 --- a/src/view/com/home/HomeHeader.tsx +++ b/src/view/com/home/HomeHeader.tsx @@ -52,7 +52,7 @@ export function HomeHeader( ) return ( - <HomeHeaderLayout> + <HomeHeaderLayout tabBarAnchor={props.tabBarAnchor}> <TabBar key={items.join(',')} onPressSelected={props.onPressSelected} diff --git a/src/view/com/home/HomeHeaderLayout.web.tsx b/src/view/com/home/HomeHeaderLayout.web.tsx index fbb55e6bc..6145081af 100644 --- a/src/view/com/home/HomeHeaderLayout.web.tsx +++ b/src/view/com/home/HomeHeaderLayout.web.tsx @@ -1,13 +1,10 @@ import React from 'react' import {StyleSheet, View} from 'react-native' -import Animated from 'react-native-reanimated' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {HomeHeaderLayoutMobile} from './HomeHeaderLayoutMobile' -import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' -import {useShellLayout} from '#/state/shell/shell-layout' import {Logo} from '#/view/icons/Logo' -import {Link, TextLink} from '../util/Link' +import {Link} from '../util/Link' import { FontAwesomeIcon, FontAwesomeIconStyle, @@ -16,41 +13,42 @@ import {useLingui} from '@lingui/react' import {msg} from '@lingui/macro' import {CogIcon} from '#/lib/icons' -export function HomeHeaderLayout({children}: {children: React.ReactNode}) { +export function HomeHeaderLayout(props: { + children: React.ReactNode + tabBarAnchor: JSX.Element | null | undefined +}) { const {isMobile} = useWebMediaQueries() if (isMobile) { - return <HomeHeaderLayoutMobile>{children}</HomeHeaderLayoutMobile> + return <HomeHeaderLayoutMobile {...props} /> } else { - return <HomeHeaderLayoutTablet>{children}</HomeHeaderLayoutTablet> + return <HomeHeaderLayoutDesktopAndTablet {...props} /> } } -function HomeHeaderLayoutTablet({children}: {children: React.ReactNode}) { +function HomeHeaderLayoutDesktopAndTablet({ + children, + tabBarAnchor, +}: { + children: React.ReactNode + tabBarAnchor: JSX.Element | null | undefined +}) { const pal = usePalette('default') - const {headerMinimalShellTransform} = useMinimalShellMode() - const {headerHeight} = useShellLayout() const {_} = useLingui() return ( - // @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf - <Animated.View - style={[pal.view, pal.border, styles.tabBar, headerMinimalShellTransform]} - onLayout={e => { - headerHeight.value = e.nativeEvent.layout.height - }}> - <View style={[pal.view, styles.topBar]}> - <TextLink - type="title-lg" + <> + <View style={[pal.view, pal.border, styles.bar, styles.topBar]}> + <Link href="/settings/following-feed" + hitSlop={10} + accessibilityRole="button" accessibilityLabel={_(msg`Following Feed Preferences`)} - accessibilityHint="" - text={ - <FontAwesomeIcon - icon="sliders" - style={pal.textLight as FontAwesomeIconStyle} - /> - } - /> + accessibilityHint=""> + <FontAwesomeIcon + icon="sliders" + style={pal.textLight as FontAwesomeIconStyle} + /> + </Link> <Logo width={28} /> <Link href="/settings/saved-feeds" @@ -61,32 +59,38 @@ function HomeHeaderLayoutTablet({children}: {children: React.ReactNode}) { <CogIcon size={22} strokeWidth={2} style={pal.textLight} /> </Link> </View> - {children} - </Animated.View> + {tabBarAnchor} + <View style={[pal.view, pal.border, styles.bar, styles.tabBar]}> + {children} + </View> + </> ) } const styles = StyleSheet.create({ + bar: { + // @ts-ignore Web only + left: 'calc(50% - 300px)', + width: 600, + borderLeftWidth: 1, + borderRightWidth: 1, + }, topBar: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 18, - paddingVertical: 8, - marginTop: 8, - width: '100%', + paddingTop: 16, + paddingBottom: 8, }, tabBar: { // @ts-ignore Web only position: 'sticky', - zIndex: 1, - // @ts-ignore Web only -prf - left: 'calc(50% - 300px)', - width: 600, top: 0, flexDirection: 'column', alignItems: 'center', borderLeftWidth: 1, borderRightWidth: 1, + zIndex: 1, }, }) diff --git a/src/view/com/home/HomeHeaderLayoutMobile.tsx b/src/view/com/home/HomeHeaderLayoutMobile.tsx index f51efb7b4..d7b7231c6 100644 --- a/src/view/com/home/HomeHeaderLayoutMobile.tsx +++ b/src/view/com/home/HomeHeaderLayoutMobile.tsx @@ -23,6 +23,7 @@ export function HomeHeaderLayoutMobile({ children, }: { children: React.ReactNode + tabBarAnchor: JSX.Element | null | undefined }) { const pal = usePalette('default') const {_} = useLingui() diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index d52d3c0e6..e50fb7f09 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -159,7 +159,7 @@ export const TextLink = memo(function TextLink({ dataSet, title, onPress, - warnOnMismatchingLabel, + disableMismatchWarning, navigationAction, ...orgProps }: { @@ -172,7 +172,7 @@ export const TextLink = memo(function TextLink({ lineHeight?: number dataSet?: any title?: string - warnOnMismatchingLabel?: boolean + disableMismatchWarning?: boolean navigationAction?: 'push' | 'replace' | 'navigate' } & TextProps) { const {...props} = useLinkProps({to: sanitizeUrl(href)}) @@ -180,14 +180,14 @@ export const TextLink = memo(function TextLink({ const {openModal, closeModal} = useModalControls() const openLink = useOpenLink() - if (warnOnMismatchingLabel && typeof text !== 'string') { + if (!disableMismatchWarning && typeof text !== 'string') { console.error('Unable to detect mismatching label') } props.onPress = React.useCallback( (e?: Event) => { const requiresWarning = - warnOnMismatchingLabel && + !disableMismatchWarning && linkRequiresWarning(href, typeof text === 'string' ? text : '') if (requiresWarning) { e?.preventDefault?.() @@ -227,7 +227,7 @@ export const TextLink = memo(function TextLink({ navigation, href, text, - warnOnMismatchingLabel, + disableMismatchWarning, navigationAction, openLink, ], diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx index 1ccfcf56c..872e10eef 100644 --- a/src/view/com/util/ViewHeader.tsx +++ b/src/view/com/util/ViewHeader.tsx @@ -13,11 +13,13 @@ import Animated from 'react-native-reanimated' import {useSetDrawerOpen} from '#/state/shell' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useTheme} from '#/alf' const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20} export function ViewHeader({ title, + subtitle, canGoBack, showBackButton = true, hideOnScroll, @@ -26,6 +28,7 @@ export function ViewHeader({ renderButton, }: { title: string + subtitle?: string canGoBack?: boolean showBackButton?: boolean hideOnScroll?: boolean @@ -39,6 +42,7 @@ export function ViewHeader({ const navigation = useNavigation<NavigationProp>() const {track} = useAnalytics() const {isDesktop, isTablet} = useWebMediaQueries() + const t = useTheme() const onPressBack = React.useCallback(() => { if (navigation.canGoBack()) { @@ -71,42 +75,60 @@ export function ViewHeader({ return ( <Container hideOnScroll={hideOnScroll || false} showBorder={showBorder}> - {showBackButton ? ( - <TouchableOpacity - testID="viewHeaderDrawerBtn" - onPress={canGoBack ? onPressBack : onPressMenu} - hitSlop={BACK_HITSLOP} - style={canGoBack ? styles.backBtn : styles.backBtnWide} - accessibilityRole="button" - accessibilityLabel={canGoBack ? _(msg`Back`) : _(msg`Menu`)} - accessibilityHint={ - canGoBack ? '' : _(msg`Access navigation links and settings`) - }> - {canGoBack ? ( - <FontAwesomeIcon - size={18} - icon="angle-left" - style={[styles.backIcon, pal.text]} - /> - ) : !isTablet ? ( - <FontAwesomeIcon - size={18} - icon="bars" - style={[styles.backIcon, pal.textLight]} - /> + <View style={{flex: 1}}> + <View style={{flexDirection: 'row', alignItems: 'center'}}> + {showBackButton ? ( + <TouchableOpacity + testID="viewHeaderDrawerBtn" + onPress={canGoBack ? onPressBack : onPressMenu} + hitSlop={BACK_HITSLOP} + style={canGoBack ? styles.backBtn : styles.backBtnWide} + accessibilityRole="button" + accessibilityLabel={canGoBack ? _(msg`Back`) : _(msg`Menu`)} + accessibilityHint={ + canGoBack ? '' : _(msg`Access navigation links and settings`) + }> + {canGoBack ? ( + <FontAwesomeIcon + size={18} + icon="angle-left" + style={[styles.backIcon, pal.text]} + /> + ) : !isTablet ? ( + <FontAwesomeIcon + size={18} + icon="bars" + style={[styles.backIcon, pal.textLight]} + /> + ) : null} + </TouchableOpacity> ) : null} - </TouchableOpacity> - ) : null} - <View style={styles.titleContainer} pointerEvents="none"> - <Text type="title" style={[pal.text, styles.title]}> - {title} - </Text> + <View style={styles.titleContainer} pointerEvents="none"> + <Text type="title" style={[pal.text, styles.title]}> + {title} + </Text> + </View> + {renderButton ? ( + renderButton() + ) : showBackButton ? ( + <View style={canGoBack ? styles.backBtn : styles.backBtnWide} /> + ) : null} + </View> + {subtitle ? ( + <View + style={[styles.titleContainer, {marginTop: -3}]} + pointerEvents="none"> + <Text + style={[ + pal.text, + styles.subtitle, + t.atoms.text_contrast_medium, + ]}> + {subtitle} + </Text> + </View> + ) : undefined} </View> - {renderButton ? ( - renderButton() - ) : showBackButton ? ( - <View style={canGoBack ? styles.backBtn : styles.backBtnWide} /> - ) : null} </Container> ) } @@ -185,7 +207,6 @@ function Container({ const styles = StyleSheet.create({ header: { flexDirection: 'row', - alignItems: 'center', paddingHorizontal: 12, paddingVertical: 6, width: '100%', @@ -207,12 +228,14 @@ const styles = StyleSheet.create({ titleContainer: { marginLeft: 'auto', marginRight: 'auto', - paddingRight: 10, + alignItems: 'center', }, title: { fontWeight: 'bold', }, - + subtitle: { + fontSize: 13, + }, backBtn: { width: 30, height: 30, diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx index b3a563116..cd2545290 100644 --- a/src/view/com/util/moderation/ContentHider.tsx +++ b/src/view/com/util/moderation/ContentHider.tsx @@ -46,7 +46,7 @@ export function ContentHider({ ) } - const isMute = moderation.cause?.type === 'muted' + const isMute = ['muted', 'muted-word'].includes(moderation.cause?.type || '') const desc = describeModerationCause(moderation.cause, 'content') return ( <View testID={testID} style={[styles.outer, style]}> diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx index b1fa71d4a..ede62e988 100644 --- a/src/view/com/util/moderation/PostHider.tsx +++ b/src/view/com/util/moderation/PostHider.tsx @@ -47,7 +47,7 @@ export function PostHider({ ) } - const isMute = moderation.cause?.type === 'muted' + const isMute = ['muted', 'muted-word'].includes(moderation.cause?.type || '') const desc = describeModerationCause(moderation.cause, 'content') return !override ? ( <Pressable diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx index 0ec3f3181..f4ade30e5 100644 --- a/src/view/com/util/text/RichText.tsx +++ b/src/view/com/util/text/RichText.tsx @@ -114,7 +114,6 @@ export function RichText({ href={link.uri} style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]} dataSet={WORD_WRAP} - warnOnMismatchingLabel selectable={selectable} />, ) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 7ad9beb56..99ac8c44a 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -123,8 +123,7 @@ function HomeScreenReady({ return ( <HomeHeader key="FEEDS_TAB_BAR" - selectedPage={props.selectedPage} - onSelect={props.onSelect} + {...props} testID="homeScreenFeedTabs" onPressSelected={onPressSelected} feeds={pinnedFeedInfos} diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 64e067593..b30b4491b 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -491,6 +491,8 @@ const styles = StyleSheet.create({ container: { flexDirection: 'column', height: '100%', + // @ts-ignore Web-only. + overflowAnchor: 'none', // Fixes jumps when switching tabs while scrolled down. }, loading: { paddingVertical: 10, diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index b3072c397..3c675ee0a 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -60,7 +60,7 @@ import { import {logger} from '#/logger' import {useAnalytics} from '#/lib/analytics/analytics' import {listenSoftReset} from '#/state/events' -import {atoms as a} from '#/alf' +import {atoms as a, useTheme} from '#/alf' const SECTION_TITLES_CURATE = ['Posts', 'About'] const SECTION_TITLES_MOD = ['About'] @@ -699,6 +699,7 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( ref, ) { const pal = usePalette('default') + const t = useTheme() const {_} = useLingui() const {isMobile} = useWebMediaQueries() const {currentAccount} = useSession() @@ -792,7 +793,7 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( paddingBottom: isMobile ? 14 : 18, }, ]}> - <Text type="lg-bold"> + <Text type="lg-bold" style={t.atoms.text}> <Trans>Users</Trans> </Text> {isOwner && ( @@ -817,14 +818,18 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( </View> ) }, [ - pal, - list, isMobile, + pal.border, + pal.textLight, + pal.colors.link, + pal.link, descriptionRT, isCurateList, isOwner, - onPressAddUser, + list.creator, + t.atoms.text, _, + onPressAddUser, ]) const renderEmptyState = useCallback(() => { diff --git a/src/view/screens/Storybook/Links.tsx b/src/view/screens/Storybook/Links.tsx index 3f1806906..f9ecfba55 100644 --- a/src/view/screens/Storybook/Links.tsx +++ b/src/view/screens/Storybook/Links.tsx @@ -4,7 +4,7 @@ import {View} from 'react-native' import {useTheme, atoms as a} from '#/alf' import {ButtonText} from '#/components/Button' import {InlineLink, Link} from '#/components/Link' -import {H1, H3, Text} from '#/components/Typography' +import {H1, Text} from '#/components/Typography' export function Links() { const t = useTheme() @@ -13,31 +13,19 @@ export function Links() { <H1>Links</H1> <View style={[a.gap_md, a.align_start]}> - <InlineLink - to="https://bsky.social" - warnOnMismatchingTextChild - style={[a.text_md]}> - External + <InlineLink to="https://google.com" style={[a.text_lg]}> + https://google.com </InlineLink> - <InlineLink to="https://bsky.social" style={[a.text_md, t.atoms.text]}> - <H3>External with custom children</H3> + <InlineLink to="https://google.com" style={[a.text_lg]}> + External with custom children (google.com) </InlineLink> <InlineLink to="https://bsky.social" style={[a.text_md, t.atoms.text_contrast_low]}> - External with custom children - </InlineLink> - <InlineLink - to="https://bsky.social" - warnOnMismatchingTextChild - style={[a.text_lg]}> - https://bsky.social + Internal (bsky.social) </InlineLink> - <InlineLink - to="https://bsky.app/profile/bsky.app" - warnOnMismatchingTextChild - style={[a.text_md]}> - Internal + <InlineLink to="https://bsky.app/profile/bsky.app" style={[a.text_md]}> + Internal (bsky.app) </InlineLink> <Link diff --git a/src/view/screens/Storybook/Palette.tsx b/src/view/screens/Storybook/Palette.tsx index 900754681..42000aa81 100644 --- a/src/view/screens/Storybook/Palette.tsx +++ b/src/view/screens/Storybook/Palette.tsx @@ -1,7 +1,6 @@ import React from 'react' import {View} from 'react-native' -import * as tokens from '#/alf/tokens' import {atoms as a, useTheme} from '#/alf' export function Palette() { @@ -28,79 +27,79 @@ export function Palette() { <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_25}, + {height: 60, backgroundColor: t.palette.primary_25}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_50}, + {height: 60, backgroundColor: t.palette.primary_50}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_100}, + {height: 60, backgroundColor: t.palette.primary_100}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_200}, + {height: 60, backgroundColor: t.palette.primary_200}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_300}, + {height: 60, backgroundColor: t.palette.primary_300}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_400}, + {height: 60, backgroundColor: t.palette.primary_400}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_500}, + {height: 60, backgroundColor: t.palette.primary_500}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_600}, + {height: 60, backgroundColor: t.palette.primary_600}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_700}, + {height: 60, backgroundColor: t.palette.primary_700}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_800}, + {height: 60, backgroundColor: t.palette.primary_800}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_900}, + {height: 60, backgroundColor: t.palette.primary_900}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_950}, + {height: 60, backgroundColor: t.palette.primary_950}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_975}, + {height: 60, backgroundColor: t.palette.primary_975}, ]} /> </View> @@ -108,153 +107,159 @@ export function Palette() { <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.green_25}, + {height: 60, backgroundColor: t.palette.positive_25}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.green_50}, + {height: 60, backgroundColor: t.palette.positive_50}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.green_100}, + {height: 60, backgroundColor: t.palette.positive_100}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.green_200}, + {height: 60, backgroundColor: t.palette.positive_200}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.green_300}, + {height: 60, backgroundColor: t.palette.positive_300}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.green_400}, + {height: 60, backgroundColor: t.palette.positive_400}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.green_500}, + {height: 60, backgroundColor: t.palette.positive_500}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.green_600}, + {height: 60, backgroundColor: t.palette.positive_600}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.green_700}, + {height: 60, backgroundColor: t.palette.positive_700}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.green_800}, + {height: 60, backgroundColor: t.palette.positive_800}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.green_900}, + {height: 60, backgroundColor: t.palette.positive_900}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.green_950}, + {height: 60, backgroundColor: t.palette.positive_950}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.green_975}, + {height: 60, backgroundColor: t.palette.positive_975}, ]} /> </View> <View style={[a.flex_row, a.gap_md]}> <View - style={[a.flex_1, {height: 60, backgroundColor: tokens.color.red_25}]} + style={[ + a.flex_1, + {height: 60, backgroundColor: t.palette.negative_25}, + ]} /> <View - style={[a.flex_1, {height: 60, backgroundColor: tokens.color.red_50}]} + style={[ + a.flex_1, + {height: 60, backgroundColor: t.palette.negative_50}, + ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.red_100}, + {height: 60, backgroundColor: t.palette.negative_100}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.red_200}, + {height: 60, backgroundColor: t.palette.negative_200}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.red_300}, + {height: 60, backgroundColor: t.palette.negative_300}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.red_400}, + {height: 60, backgroundColor: t.palette.negative_400}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.red_500}, + {height: 60, backgroundColor: t.palette.negative_500}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.red_600}, + {height: 60, backgroundColor: t.palette.negative_600}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.red_700}, + {height: 60, backgroundColor: t.palette.negative_700}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.red_800}, + {height: 60, backgroundColor: t.palette.negative_800}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.red_900}, + {height: 60, backgroundColor: t.palette.negative_900}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.red_950}, + {height: 60, backgroundColor: t.palette.negative_950}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.red_975}, + {height: 60, backgroundColor: t.palette.negative_975}, ]} /> </View> diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index fbc90bfc6..def0333c7 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -200,10 +200,10 @@ function ComposeBtn() { const fetchHandle = useFetchHandle() const getProfileHandle = async () => { - const {routes} = getState() - const currentRoute = routes[routes.length - 1] + const routes = getState()?.routes + const currentRoute = routes?.[routes?.length - 1] - if (currentRoute.name === 'Profile') { + if (currentRoute?.name === 'Profile') { let handle: string | undefined = ( currentRoute.params as CommonNavigatorParams['Profile'] ).name |