diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index f13d568b..6b76acc9 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -180,6 +180,7 @@ func serve(cctx *cli.Context) error { e.GET("/", server.WebHome) // generic routes + e.GET("/hashtag/:tag", server.WebGeneric) e.GET("/search", server.WebGeneric) e.GET("/feeds", server.WebGeneric) e.GET("/notifications", server.WebGeneric) diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html index 50fb9a2f..413d7ff6 100644 --- a/bskyweb/templates/base.html +++ b/bskyweb/templates/base.html @@ -44,6 +44,12 @@ scrollbar-gutter: stable both-edges; } + /* Buttons and inputs have a font set by UA, so we'll have to reset that */ + button, input, textarea { + font: inherit; + line-height: inherit; + } + /* Color theming */ /* Default will always be white */ :root { diff --git a/package.json b/package.json index e9dd9202..d694d26c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsky.app", - "version": "1.70.0", + "version": "1.71.0", "private": true, "engines": { "node": ">=18" @@ -44,7 +44,7 @@ "update-extensions": "scripts/updateExtensions.sh" }, "dependencies": { - "@atproto/api": "^0.10.0", + "@atproto/api": "^0.10.4", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@emoji-mart/react": "^1.1.1", diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 0aeeeb6a..b30f8f98 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() @@ -262,6 +263,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { requireAuth: true, }} /> + 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 06d6ebf0..27738e91 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 da96f6ef..0c95a459 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 f0b8c7c6..b1468f46 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 00000000..929a01d3 --- /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 e401bda2..5361be96 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 6dfc24f3..ef4f4741 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 ( + + + + ) +} + 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 => ( - - )} + 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 ( - + & +export type LinkProps = Omit & Omit /** @@ -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 00000000..12a93580 --- /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 +}) { + const t = useTheme() + + return ( + + {isFetching ? ( + + ) : ( + + )} + + ) +} + +function ListFooterMaybeError({ + isError, + error, + onRetry, +}: { + isError: boolean + error?: string + onRetry?: () => Promise +}) { + const t = useTheme() + + if (!isError) return null + + return ( + + + + {error ? ( + cleanError(error) + ) : ( + Oops, something went wrong! + )} + + + + + ) +} + +export function ListHeaderDesktop({ + title, + subtitle, +}: { + title: string + subtitle?: string +}) { + const {gtTablet} = useBreakpoints() + const t = useTheme() + + if (!gtTablet) return null + + return ( + + {title} + {subtitle ? ( + + {subtitle} + + ) : undefined} + + ) +} + +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 +}) { + const navigation = useNavigation() + 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 ( + + {isLoading ? ( + + + + ) : ( + <> + + + {isError ? ( + Oops! + ) : isEmpty ? ( + <> + {notFoundType === 'results' ? ( + No results found + ) : ( + Page not found + )} + + ) : undefined} + + + {isError ? ( + + {error ? error : Something went wrong!} + + ) : isEmpty ? ( + + {empty ? ( + empty + ) : ( + + We're sorry! We can't find the page you were looking for. + + )} + + ) : undefined} + + + {isError && onRetry && ( + + )} + + + + )} + + ) +} diff --git a/src/components/RichText.tsx b/src/components/RichText.tsx index 3d5f0802..1a14415c 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)} , ) @@ -121,6 +120,7 @@ export function RichText({ - {tag} + {text} diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx index 2fec7a18..c9ced9a5 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({ - + {isPreferencesLoading ? ( @@ -87,18 +91,14 @@ export function TagMenu({ t.atoms.bg_contrast_25, ]}> { 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({ See{' '} - {tag} + {displayTag} {' '} posts @@ -142,21 +142,19 @@ export function TagMenu({ { 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{' '} - {tag} + {displayTag} {' '} posts by this user @@ -207,22 +205,20 @@ export function TagMenu({ - - - + + style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}> + Add muted words and tags + + - We recommend avoiding common words that appear in many posts, since - it can result in no posts being shown. + Posts can be muted based on their text, their tags, or both. + + + { + if (error) { + setError('') + } + setField(value) + }} + onSubmitEditing={submit} + /> + + + + + + + + + Mute in text & tags + + + + + + + + + + + + Mute in tags only + + + + + + + + + + + {error && ( + + + {error} + + + )} + + + + We recommend avoiding common words that appear in many posts, + since it can result in no posts being shown. + + + + + + + + + Your muted words + + + {isPreferencesLoading ? ( + + ) : preferencesError || !preferences ? ( + + + + We're sorry, but we weren't able to load your muted words at + this time. Please try again. + + + + ) : preferences.mutedWords.length ? ( + [...preferences.mutedWords] + .reverse() + .map((word, i) => ( + + )) + ) : ( + + + You haven't muted any words or tags yet + + + )} + + + {isNative && } + + - - - - - - Your muted words - - - {isPreferencesLoading ? ( - - ) : preferencesError || !preferences ? ( - - - - We're sorry, but we weren't able to load your muted words at - this time. Please try again. - - - - ) : preferences.mutedWords.length ? ( - [...preferences.mutedWords] - .reverse() - .map((word, i) => ( - - )) - ) : ( - - - You haven't muted any words or tags yet - - - )} - - - {isNative && } - - ) } diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx index a781bdd1..b37f4bfa 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 140740f7..a83f92a2 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 d1e2de31..8741530b 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 92543b42..9f6fa9c0 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 (AppBskyFeedPost.isRecord(subject.embed.record.value)) { - embedHidden = - embedHidden || - 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, - isOwnPost, - }) - - if (AppBskyEmbedImages.isMain(subject.embed.record.value.embed)) { - for (const image of subject.embed.record.value.embed.images) { - embedHidden = - embedHidden || - hasMutedWord({ - mutedWords, - text: image.alt, - facets: [], - outlineTags: [], - languages: subject.embed.record.value.langs, - isOwnPost, - }) - } - } - } } if ( AppBskyEmbedRecordWithMedia.isView(subject.embed) && AppBskyEmbedRecord.isViewRecord(subject.embed.record.record) ) { - // TODO what embedHidden = hiddenPosts.includes(subject.embed.record.record.uri) } + + if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) { + if (AppBskyFeedPost.isRecord(subject.embed.record.value)) { + const embeddedPost = subject.embed.record.value + + embedMuted = + embedMuted || + hasMutedWord({ + mutedWords, + text: embeddedPost.text, + facets: embeddedPost.facets, + outlineTags: embeddedPost.tags, + languages: embeddedPost.langs, + isOwnPost, + }) + + if (AppBskyEmbedImages.isMain(embeddedPost.embed)) { + for (const image of embeddedPost.embed.images) { + embedMuted = + embedMuted || + hasMutedWord({ + mutedWords, + text: image.alt, + facets: [], + outlineTags: [], + 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) + ) { + 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 0ec09f61..6756a62a 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 ef341154..ba2cdb39 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 135d50ab..bd75aabe 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 d17f1591..3fc908b4 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 00000000..09a1f282 --- /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) => { + return +} + +const keyExtractor = (item: PostView, index: number) => { + return `${item.uri}-${index}` +} + +export default function HashtagScreen({ + route, +}: NativeStackScreenProps) { + 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 ( + + ( + + + + ) + : undefined + } + /> + + {!isLoading && posts.length > 0 && ( + + data={posts} + renderItem={renderItem} + keyExtractor={keyExtractor} + refreshing={isPTR} + onRefresh={onRefresh} + onEndReached={onEndReached} + onEndReachedThreshold={4} + // @ts-ignore web only -prf + desktopFixedHeight + ListHeaderComponent={ + + } + ListFooterComponent={ + + } + /> + )} + + ) +} diff --git a/src/view/com/composer/text-input/web/LinkDecorator.ts b/src/view/com/composer/text-input/web/LinkDecorator.ts index 19945de0..e36ac80e 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]+)|((?[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 d820ec3f..2bf3184a 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 bbd16465..aa3ecb7f 100644 --- a/src/view/com/home/HomeHeader.tsx +++ b/src/view/com/home/HomeHeader.tsx @@ -52,7 +52,7 @@ export function HomeHeader( ) return ( - + {children} + return } else { - return {children} + return } } -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 - { - headerHeight.value = e.nativeEvent.layout.height - }}> - - + + - } - /> + accessibilityHint=""> + + - {children} - + {tabBarAnchor} + + {children} + + ) } 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 f51efb7b..d7b7231c 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 d52d3c0e..e50fb7f0 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 1ccfcf56..872e10ee 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() 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 ( - {showBackButton ? ( - - {canGoBack ? ( - - ) : !isTablet ? ( - + + + {showBackButton ? ( + + {canGoBack ? ( + + ) : !isTablet ? ( + + ) : null} + ) : null} - - ) : null} - - - {title} - + + + {title} + + + {renderButton ? ( + renderButton() + ) : showBackButton ? ( + + ) : null} + + {subtitle ? ( + + + {subtitle} + + + ) : undefined} - {renderButton ? ( - renderButton() - ) : showBackButton ? ( - - ) : null} ) } @@ -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 b3a56311..cd254529 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 ( diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx index b1fa71d4..ede62e98 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 ? ( , ) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 7ad9beb5..99ac8c44 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -123,8 +123,7 @@ function HomeScreenReady({ return ( ( ref, ) { const pal = usePalette('default') + const t = useTheme() const {_} = useLingui() const {isMobile} = useWebMediaQueries() const {currentAccount} = useSession() @@ -792,7 +793,7 @@ const AboutSection = React.forwardRef( paddingBottom: isMobile ? 14 : 18, }, ]}> - + Users {isOwner && ( @@ -817,14 +818,18 @@ const AboutSection = React.forwardRef( ) }, [ - 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 3f180690..f9ecfba5 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() {

Links

- - External + + https://google.com - -

External with custom children

+ + External with custom children (google.com) - External with custom children + Internal (bsky.social) - - https://bsky.social - - - Internal + + Internal (bsky.app) @@ -108,153 +107,159 @@ export function Palette() { - - + + diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index fbc90bfc..def0333c 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 diff --git a/web/index.html b/web/index.html index 78090591..8f2275a7 100644 --- a/web/index.html +++ b/web/index.html @@ -48,6 +48,12 @@ scrollbar-gutter: stable both-edges; } + /* Buttons and inputs have a font set by UA, so we'll have to reset that */ + button, input, textarea { + font: inherit; + line-height: inherit; + } + /* Color theming */ /* Default will always be white */ :root { diff --git a/yarn.lock b/yarn.lock index a62ff2f8..ceb712ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34,15 +34,15 @@ jsonpointer "^5.0.0" leven "^3.1.0" -"@atproto/api@^0.10.0": - version "0.10.0" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.10.0.tgz#ca34dfa8f9b1e6ba021094c40cb0ff3c4c254044" - integrity sha512-TSVCHh3UUZLtNzh141JwLicfYTc7TvVFvQJSWeOZLHr3Sk+9hqEY+9Itaqp1DAW92r4i25ChaMc/50sg4etAWQ== +"@atproto/api@^0.10.4": + version "0.10.4" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.10.4.tgz#b73446f2344783c42c6040082756449443f15750" + integrity sha512-9gwZt4v4pngfD4mgsET9i9Ym0PpMSzftTzqBjCbFpObx15zMkFemYnLUnyT/NEww2u/aRxjAe2TeBnU0dIbbuQ== dependencies: "@atproto/common-web" "^0.2.3" - "@atproto/lexicon" "^0.3.1" - "@atproto/syntax" "^0.1.5" - "@atproto/xrpc" "^0.4.1" + "@atproto/lexicon" "^0.3.2" + "@atproto/syntax" "^0.2.0" + "@atproto/xrpc" "^0.4.2" multiformats "^9.9.0" tlds "^1.234.0" typed-emitter "^2.1.0" @@ -245,6 +245,17 @@ multiformats "^9.9.0" zod "^3.21.4" +"@atproto/lexicon@^0.3.2": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.3.2.tgz#0085a3acd3a77867b8efe188297a1bbacc55ce5c" + integrity sha512-kmGCkrRwpWIqmn/KO4BZwUf8Nmfndk3XvFC06V0ygCWc42g6+t4QP/6ywNW4PgqfZY0Q5aW4EuDfD7KjAFkFtQ== + dependencies: + "@atproto/common-web" "^0.2.3" + "@atproto/syntax" "^0.2.0" + iso-datestring-validator "^2.2.2" + multiformats "^9.9.0" + zod "^3.21.4" + "@atproto/ozone@^0.0.7": version "0.0.7" resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.0.7.tgz#bfad82bc1d0900e79401a82f13581f707415505a" @@ -340,6 +351,13 @@ dependencies: "@atproto/common-web" "^0.2.3" +"@atproto/syntax@^0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.2.0.tgz#4bab724c02e11f8943b8ec101251082cf55067e9" + integrity sha512-K+9jl6mtxC9ytlR7msSiP9jVNqtdxEBSt0kOfsC924lqGwuD8nlUAMi1GSMgAZJGg/Rd+0MKXh789heTdeL3HQ== + dependencies: + "@atproto/common-web" "^0.2.3" + "@atproto/xrpc-server@^0.4.2": version "0.4.2" resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.4.2.tgz#23efd89086b85933f1b0cc00c86e895adcaac315" @@ -365,6 +383,14 @@ "@atproto/lexicon" "^0.3.1" zod "^3.21.4" +"@atproto/xrpc@^0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.4.2.tgz#57812e0624be597b85f21471acf336513f35ccda" + integrity sha512-x4x2QB4nWmLjIpz2Ue9n/QVbVyJkk6tQMhvmDQaVFF89E3FcVI4rxF4uhzSxaLpbNtyVQBNEEmNHOr5EJLeHVA== + dependencies: + "@atproto/lexicon" "^0.3.2" + zod "^3.21.4" + "@aws-crypto/crc32@3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@aws-crypto/crc32/-/crc32-3.0.0.tgz#07300eca214409c33e3ff769cd5697b57fdd38fa"