Merge branch 'bluesky-social:main' into patch-3

zio/stable
Minseo Lee 2024-03-02 13:04:51 +09:00 committed by GitHub
commit ab2b454be8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 1299 additions and 527 deletions

View File

@ -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)

View File

@ -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 {

View File

@ -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",

View File

@ -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)

View File

@ -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(

View File

@ -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%)`,
},
},
}

View File

@ -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%)`,

View File

@ -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
})
}

View File

@ -165,7 +165,7 @@ export function Button({
if (!disabled) {
baseStyles.push(a.border, {
borderColor: tokens.color.blue_500,
borderColor: t.palette.primary_500,
})
hoverStyles.push(a.border, {
backgroundColor: light
@ -174,7 +174,7 @@ export function Button({
})
} else {
baseStyles.push(a.border, {
borderColor: light ? tokens.color.blue_200 : tokens.color.blue_900,
borderColor: light ? t.palette.primary_200 : t.palette.primary_900,
})
}
} else if (variant === 'ghost') {
@ -191,20 +191,14 @@ export function Button({
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: light
? tokens.color.gray_50
: tokens.color.gray_900,
backgroundColor: t.palette.contrast_50,
})
hoverStyles.push({
backgroundColor: light
? tokens.color.gray_100
: tokens.color.gray_950,
backgroundColor: t.palette.contrast_100,
})
} else {
baseStyles.push({
backgroundColor: light
? tokens.color.gray_200
: tokens.color.gray_950,
backgroundColor: t.palette.contrast_200,
})
}
} else if (variant === 'outline') {
@ -214,21 +208,19 @@ export function Button({
if (!disabled) {
baseStyles.push(a.border, {
borderColor: light ? tokens.color.gray_300 : tokens.color.gray_700,
borderColor: t.palette.contrast_300,
})
hoverStyles.push(a.border, t.atoms.bg_contrast_50)
hoverStyles.push(t.atoms.bg_contrast_50)
} else {
baseStyles.push(a.border, {
borderColor: light ? tokens.color.gray_200 : tokens.color.gray_800,
borderColor: t.palette.contrast_200,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push(t.atoms.bg)
hoverStyles.push({
backgroundColor: light
? tokens.color.gray_100
: tokens.color.gray_900,
backgroundColor: t.palette.contrast_100,
})
}
}
@ -236,14 +228,14 @@ export function Button({
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: t.palette.negative_400,
backgroundColor: t.palette.negative_500,
})
hoverStyles.push({
backgroundColor: t.palette.negative_500,
backgroundColor: t.palette.negative_600,
})
} else {
baseStyles.push({
backgroundColor: t.palette.negative_600,
backgroundColor: t.palette.negative_700,
})
}
} else if (variant === 'outline') {
@ -253,7 +245,7 @@ export function Button({
if (!disabled) {
baseStyles.push(a.border, {
borderColor: t.palette.negative_400,
borderColor: t.palette.negative_500,
})
hoverStyles.push(a.border, {
backgroundColor: light
@ -273,7 +265,7 @@ export function Button({
hoverStyles.push({
backgroundColor: light
? t.palette.negative_100
: t.palette.negative_950,
: t.palette.negative_975,
})
}
}
@ -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,
})
}
}

View File

@ -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,

View File

@ -49,7 +49,7 @@ type BaseLinkProps = Pick<
*
* Note: atm this only works for `InlineLink`s with a string child.
*/
warnOnMismatchingTextChild?: boolean
disableMismatchWarning?: boolean
/**
* Callback for when the link is pressed. Prevent default and return `false`
@ -69,7 +69,7 @@ export function useLink({
to,
displayText,
action = 'push',
warnOnMismatchingTextChild,
disableMismatchWarning,
onPress: outerOnPress,
}: BaseLinkProps & {
displayText: string
@ -90,7 +90,7 @@ export function useLink({
if (exitEarlyIfFalse === false) return
const requiresWarning = Boolean(
warnOnMismatchingTextChild &&
!disableMismatchWarning &&
displayText &&
isExternal &&
linkRequiresWarning(href, displayText),
@ -148,7 +148,7 @@ export function useLink({
},
[
outerOnPress,
warnOnMismatchingTextChild,
disableMismatchWarning,
displayText,
isExternal,
href,
@ -167,7 +167,7 @@ export function useLink({
}
}
export type LinkProps = Omit<BaseLinkProps, 'warnOnMismatchingTextChild'> &
export type LinkProps = Omit<BaseLinkProps, 'disableMismatchWarning'> &
Omit<ButtonProps, 'onPress' | 'disabled' | 'label'>
/**
@ -226,7 +226,7 @@ export function InlineLink({
children,
to,
action = 'push',
warnOnMismatchingTextChild,
disableMismatchWarning,
style,
onPress: outerOnPress,
download,
@ -239,7 +239,7 @@ export function InlineLink({
to,
displayText: stringChildren ? children : '',
action,
warnOnMismatchingTextChild,
disableMismatchWarning,
onPress: outerOnPress,
})
const {

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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>

View File

@ -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,
])

View File

@ -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 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}
/>
<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.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>
<View onTouchStart={Keyboard.dismiss}>
<Text
style={[
a.text_sm,
a.italic,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
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>
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.
</Trans>
</Text>
<View style={[a.pb_lg]}>
<Dialog.Input
autoCorrect={false}
autoCapitalize="none"
autoComplete="off"
label={_(msg`Enter a word or tag`)}
placeholder={_(msg`Enter a word or tag`)}
value={field}
onChangeText={value => {
if (error) {
setError('')
}
setField(value)
}}
onSubmitEditing={submit}
/>
<Toggle.Group
label={_(msg`Toggle between muted word options.`)}
type="radio"
values={options}
onChange={setOptions}>
<View
style={[
a.pt_sm,
a.py_sm,
a.flex_row,
a.align_center,
a.gap_sm,
a.flex_wrap,
]}>
<Toggle.Item
label={_(msg`Mute this word in post text and tags`)}
name="content"
style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
<TargetToggle>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.Label>
<Trans>Mute in text & tags</Trans>
</Toggle.Label>
</View>
<PageText size="sm" />
</TargetToggle>
</Toggle.Item>
<Toggle.Item
label={_(msg`Mute this word in tags only`)}
name="tag"
style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
<TargetToggle>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Toggle.Radio />
<Toggle.Label>
<Trans>Mute in tags only</Trans>
</Toggle.Label>
</View>
<Hashtag size="sm" />
</TargetToggle>
</Toggle.Item>
<Button
disabled={isPending || !field}
label={_(msg`Add mute word for configured settings`)}
size="small"
color="primary"
variant="solid"
style={[!gtMobile && [a.w_full, a.flex_0]]}
onPress={submit}>
<ButtonText>
<Trans>Add</Trans>
</ButtonText>
<ButtonIcon icon={isPending ? Loader : Plus} />
</Button>
</View>
</Toggle.Group>
{error && (
<View
style={[
a.mb_lg,
a.flex_row,
a.rounded_sm,
a.p_md,
a.mb_xs,
t.atoms.bg_contrast_25,
{
backgroundColor: t.palette.negative_400,
},
]}>
<Text
style={[
a.italic,
{color: t.palette.white},
native({marginTop: 2}),
]}>
{error}
</Text>
</View>
)}
<Text
style={[
a.pt_xs,
a.text_sm,
a.italic,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>
We recommend avoiding common words that appear in many posts,
since it can result in no posts being shown.
</Trans>
</Text>
</View>
<Divider />
<View style={[a.pt_2xl]}>
<Text
style={[
a.text_md,
a.font_bold,
a.pb_md,
t.atoms.text_contrast_high,
]}>
<Trans>Your muted words</Trans>
</Text>
{isPreferencesLoading ? (
<Loader />
) : preferencesError || !preferences ? (
<View
style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
<Text style={[a.italic, t.atoms.text_contrast_high]}>
<Trans>
We're sorry, but we weren't able to load your muted words at
this time. Please try again.
</Trans>
</Text>
</View>
) : preferences.mutedWords.length ? (
[...preferences.mutedWords]
.reverse()
.map((word, i) => (
<MutedWordRow
key={word.value + i}
word={word}
style={[i % 2 === 0 && t.atoms.bg_contrast_25]}
/>
))
) : (
<View
style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
<Text style={[a.italic, t.atoms.text_contrast_high]}>
<Trans>You haven't muted any words or tags yet</Trans>
</Text>
</View>
)}
</View>
{isNative && <View style={{height: 20}} />}
<Dialog.Close />
</View>
<Divider />
<View style={[a.pt_2xl]}>
<Text
style={[a.text_md, a.font_bold, a.pb_md, t.atoms.text_contrast_high]}>
<Trans>Your muted words</Trans>
</Text>
{isPreferencesLoading ? (
<Loader />
) : preferencesError || !preferences ? (
<View
style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
<Text style={[a.italic, t.atoms.text_contrast_high]}>
<Trans>
We're sorry, but we weren't able to load your muted words at
this time. Please try again.
</Trans>
</Text>
</View>
) : preferences.mutedWords.length ? (
[...preferences.mutedWords]
.reverse()
.map((word, i) => (
<MutedWordRow
key={word.value + i}
word={word}
style={[i % 2 === 0 && t.atoms.bg_contrast_25]}
/>
))
) : (
<View
style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
<Text style={[a.italic, t.atoms.text_contrast_high]}>
<Trans>You haven't muted any words or tags yet</Trans>
</Text>
</View>
)}
</View>
{isNative && <View style={{height: 20}} />}
<Dialog.Close />
</Dialog.ScrollableInner>
)
}

View File

@ -10,7 +10,7 @@ import {
} from 'react-native'
import {HITSLOP_20} from 'lib/constants'
import {useTheme, atoms as a, web, tokens, android} from '#/alf'
import {useTheme, atoms as a, web, android} from '#/alf'
import {Text} from '#/components/Typography'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Props as SVGIconProps} from '#/components/icons/common'
@ -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,
},
]

View File

@ -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,
})
}
}

View File

@ -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('|')

View File

@ -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,
}
}
}
}

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -33,4 +33,5 @@ export const router = new Router({
TermsOfService: '/support/tos',
CommunityGuidelines: '/support/community-guidelines',
CopyrightPolicy: '/support/copyright',
Hashtag: '/hashtag/:tag',
})

View File

@ -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>
)
}

View File

@ -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')) {

View File

@ -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',
}),
)

View File

@ -52,7 +52,7 @@ export function HomeHeader(
)
return (
<HomeHeaderLayout>
<HomeHeaderLayout tabBarAnchor={props.tabBarAnchor}>
<TabBar
key={items.join(',')}
onPressSelected={props.onPressSelected}

View File

@ -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,
},
})

View File

@ -23,6 +23,7 @@ export function HomeHeaderLayoutMobile({
children,
}: {
children: React.ReactNode
tabBarAnchor: JSX.Element | null | undefined
}) {
const pal = usePalette('default')
const {_} = useLingui()

View File

@ -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,
],

View File

@ -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,

View File

@ -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]}>

View File

@ -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

View File

@ -114,7 +114,6 @@ export function RichText({
href={link.uri}
style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]}
dataSet={WORD_WRAP}
warnOnMismatchingLabel
selectable={selectable}
/>,
)

View File

@ -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}

View File

@ -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,

View File

@ -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(() => {

View File

@ -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
Internal (bsky.social)
</InlineLink>
<InlineLink
to="https://bsky.social"
warnOnMismatchingTextChild
style={[a.text_lg]}>
https://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

View File

@ -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}]}
/>
<View
style={[a.flex_1, {height: 60, backgroundColor: tokens.color.red_50}]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.red_100},
{height: 60, backgroundColor: t.palette.negative_25},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.red_200},
{height: 60, backgroundColor: t.palette.negative_50},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.red_300},
{height: 60, backgroundColor: t.palette.negative_100},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.red_400},
{height: 60, backgroundColor: t.palette.negative_200},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.red_500},
{height: 60, backgroundColor: t.palette.negative_300},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.red_600},
{height: 60, backgroundColor: t.palette.negative_400},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.red_700},
{height: 60, backgroundColor: t.palette.negative_500},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.red_800},
{height: 60, backgroundColor: t.palette.negative_600},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.red_900},
{height: 60, backgroundColor: t.palette.negative_700},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.red_950},
{height: 60, backgroundColor: t.palette.negative_800},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: tokens.color.red_975},
{height: 60, backgroundColor: t.palette.negative_900},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: t.palette.negative_950},
]}
/>
<View
style={[
a.flex_1,
{height: 60, backgroundColor: t.palette.negative_975},
]}
/>
</View>

View File

@ -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

View File

@ -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 {

View File

@ -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"