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) e.GET("/", server.WebHome)
// generic routes // generic routes
e.GET("/hashtag/:tag", server.WebGeneric)
e.GET("/search", server.WebGeneric) e.GET("/search", server.WebGeneric)
e.GET("/feeds", server.WebGeneric) e.GET("/feeds", server.WebGeneric)
e.GET("/notifications", server.WebGeneric) e.GET("/notifications", server.WebGeneric)

View File

@ -44,6 +44,12 @@
scrollbar-gutter: stable both-edges; 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 */ /* Color theming */
/* Default will always be white */ /* Default will always be white */
:root { :root {

View File

@ -1,6 +1,6 @@
{ {
"name": "bsky.app", "name": "bsky.app",
"version": "1.70.0", "version": "1.71.0",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@ -44,7 +44,7 @@
"update-extensions": "scripts/updateExtensions.sh" "update-extensions": "scripts/updateExtensions.sh"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.10.0", "@atproto/api": "^0.10.4",
"@bam.tech/react-native-image-resizer": "^3.0.4", "@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@emoji-mart/react": "^1.1.1", "@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 {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {i18n, MessageDescriptor} from '@lingui/core' import {i18n, MessageDescriptor} from '@lingui/core'
import HashtagScreen from '#/screens/Hashtag'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>() const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@ -262,6 +263,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
requireAuth: true, requireAuth: true,
}} }}
/> />
<Stack.Screen
name="Hashtag"
getComponent={() => HashtagScreen}
options={{title: title(msg`Hashtag`)}}
/>
</> </>
) )
} }
@ -479,12 +485,19 @@ const LINKING = {
}, },
getStateFromPath(path: string) { 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 // 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 handler hook. We should check for the trailing slash, because if there isn't one then it isn't a valid
// intent // 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 (isNative) {
if (name === 'Search') { if (name === 'Search') {
return buildStateObject('SearchTab', 'Search', params) return buildStateObject('SearchTab', 'Search', params)

View File

@ -17,7 +17,7 @@ const breakpoints: {
[key: string]: number [key: string]: number
} = { } = {
gtMobile: 800, gtMobile: 800,
gtTablet: 1200, gtTablet: 1300,
} }
function getActiveBreakpoints({width}: {width: number}) { function getActiveBreakpoints({width}: {width: number}) {
const active: (keyof typeof breakpoints)[] = Object.keys(breakpoints).filter( const active: (keyof typeof breakpoints)[] = Object.keys(breakpoints).filter(

View File

@ -1,6 +1,7 @@
import * as tokens from '#/alf/tokens' import * as tokens from '#/alf/tokens'
import type {Mutable} from '#/alf/types' import type {Mutable} from '#/alf/types'
import {atoms} from '#/alf/atoms' import {atoms} from '#/alf/atoms'
import {BLUE_HUE, GREEN_HUE, RED_HUE} from '#/alf/util/colorGeneration'
export type ThemeName = 'light' | 'dim' | 'dark' export type ThemeName = 'light' | 'dim' | 'dark'
export type ReadonlyTheme = typeof light export type ReadonlyTheme = typeof light
@ -73,19 +74,19 @@ export const darkPalette: Palette = {
white: tokens.color.gray_0, white: tokens.color.gray_0,
black: tokens.color.trueBlack, black: tokens.color.trueBlack,
contrast_25: `hsl(211, 28%, 8%)`, contrast_25: tokens.color.gray_1000,
contrast_50: `hsl(211, 28%, 11%)`, contrast_50: tokens.color.gray_975,
contrast_100: `hsl(211, 28%, 16%)`, contrast_100: tokens.color.gray_950,
contrast_200: `hsl(211, 28%, 24%)`, contrast_200: tokens.color.gray_900,
contrast_300: `hsl(211, 24%, 31%)`, contrast_300: tokens.color.gray_800,
contrast_400: `hsl(211, 24%, 38%)`, contrast_400: tokens.color.gray_700,
contrast_500: `hsl(211, 20%, 44%)`, contrast_500: tokens.color.gray_600,
contrast_600: `hsl(211, 20%, 55%)`, contrast_600: tokens.color.gray_500,
contrast_700: `hsl(211, 20%, 63%)`, contrast_700: tokens.color.gray_400,
contrast_800: `hsl(211, 20%, 71%)`, contrast_800: tokens.color.gray_300,
contrast_900: `hsl(211, 20%, 79%)`, contrast_900: tokens.color.gray_200,
contrast_950: `hsl(211, 20%, 87%)`, contrast_950: tokens.color.gray_100,
contrast_975: `hsl(211, 20%, 95%)`, contrast_975: tokens.color.gray_50,
primary_25: tokens.color.blue_25, primary_25: tokens.color.blue_25,
primary_50: tokens.color.blue_50, primary_50: tokens.color.blue_50,
@ -132,28 +133,63 @@ export const darkPalette: Palette = {
export const dimPalette: Palette = { export const dimPalette: Palette = {
...darkPalette, ...darkPalette,
black: `hsl(211, 28%, 12%)`, black: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[0]}%)`,
contrast_25: `hsl(211, 28%, 15%)`, contrast_25: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[1]}%)`,
contrast_50: `hsl(211, 28%, 18%)`, contrast_50: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[2]}%)`,
contrast_100: `hsl(211, 28%, 24%)`, contrast_100: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[3]}%)`,
contrast_200: `hsl(211, 28%, 27%)`, contrast_200: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[4]}%)`,
contrast_300: `hsl(211, 24%, 34%)`, contrast_300: `hsl(${BLUE_HUE}, 24%, ${tokens.dimScale[5]}%)`,
contrast_400: `hsl(211, 24%, 41%)`, contrast_400: `hsl(${BLUE_HUE}, 24%, ${tokens.dimScale[6]}%)`,
contrast_500: `hsl(211, 20%, 52%)`, contrast_500: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[7]}%)`,
contrast_600: `hsl(211, 20%, 55%)`, contrast_600: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[8]}%)`,
contrast_700: `hsl(211, 20%, 67%)`, contrast_700: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[9]}%)`,
contrast_800: `hsl(211, 20%, 71%)`, contrast_800: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[10]}%)`,
contrast_900: `hsl(211, 20%, 79%)`, contrast_900: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[11]}%)`,
contrast_950: `hsl(211, 20%, 87%)`, contrast_950: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[12]}%)`,
contrast_975: `hsl(211, 20%, 95%)`, contrast_975: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[13]}%)`,
primary_600: `hsl(211, 95%, 39%)`, primary_25: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[13]}%)`,
primary_700: `hsl(211, 90%, 30%)`, primary_50: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[12]}%)`,
primary_800: `hsl(211, 90%, 23%)`, primary_100: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[11]}%)`,
primary_900: `hsl(211, 80%, 16%)`, primary_200: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[10]}%)`,
primary_950: `hsl(211, 80%, 13%)`, primary_300: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[9]}%)`,
primary_975: `hsl(211, 80%, 10%)`, 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 } as const
export const light = { export const light = {
@ -404,17 +440,17 @@ export const dim: Theme = {
shadow_sm: { shadow_sm: {
...atoms.shadow_sm, ...atoms.shadow_sm,
shadowOpacity: 0.7, shadowOpacity: 0.7,
shadowColor: `hsl(211, 28%, 3%)`, shadowColor: `hsl(${BLUE_HUE}, 28%, 6%)`,
}, },
shadow_md: { shadow_md: {
...atoms.shadow_md, ...atoms.shadow_md,
shadowOpacity: 0.7, shadowOpacity: 0.7,
shadowColor: `hsl(211, 28%, 3%)`, shadowColor: `hsl(${BLUE_HUE}, 28%, 6%)`,
}, },
shadow_lg: { shadow_lg: {
...atoms.shadow_lg, ...atoms.shadow_lg,
shadowOpacity: 0.7, shadowOpacity: 0.7,
shadowColor: `hsl(211, 28%, 3%)`, shadowColor: `hsl(${BLUE_HUE}, 28%, 6%)`,
}, },
}, },
} }

View File

@ -1,25 +1,32 @@
const BLUE_HUE = 211 import {
const RED_HUE = 346 BLUE_HUE,
const GREEN_HUE = 152 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 = { export const color = {
trueBlack: '#000000', trueBlack: '#000000',
gray_0: `hsl(${BLUE_HUE}, 20%, 100%)`, gray_0: `hsl(${BLUE_HUE}, 20%, ${scale[14]}%)`,
gray_25: `hsl(${BLUE_HUE}, 20%, 97%)`, gray_25: `hsl(${BLUE_HUE}, 20%, ${scale[13]}%)`,
gray_50: `hsl(${BLUE_HUE}, 20%, 95%)`, gray_50: `hsl(${BLUE_HUE}, 20%, ${scale[12]}%)`,
gray_100: `hsl(${BLUE_HUE}, 20%, 90%)`, gray_100: `hsl(${BLUE_HUE}, 20%, ${scale[11]}%)`,
gray_200: `hsl(${BLUE_HUE}, 20%, 80%)`, gray_200: `hsl(${BLUE_HUE}, 20%, ${scale[10]}%)`,
gray_300: `hsl(${BLUE_HUE}, 20%, 70%)`, gray_300: `hsl(${BLUE_HUE}, 20%, ${scale[9]}%)`,
gray_400: `hsl(${BLUE_HUE}, 20%, 60%)`, gray_400: `hsl(${BLUE_HUE}, 20%, ${scale[8]}%)`,
gray_500: `hsl(${BLUE_HUE}, 20%, 50%)`, gray_500: `hsl(${BLUE_HUE}, 20%, ${scale[7]}%)`,
gray_600: `hsl(${BLUE_HUE}, 24%, 42%)`, gray_600: `hsl(${BLUE_HUE}, 24%, ${scale[6]}%)`,
gray_700: `hsl(${BLUE_HUE}, 24%, 34%)`, gray_700: `hsl(${BLUE_HUE}, 24%, ${scale[5]}%)`,
gray_800: `hsl(${BLUE_HUE}, 28%, 26%)`, gray_800: `hsl(${BLUE_HUE}, 28%, ${scale[4]}%)`,
gray_900: `hsl(${BLUE_HUE}, 28%, 18%)`, gray_900: `hsl(${BLUE_HUE}, 28%, ${scale[3]}%)`,
gray_950: `hsl(${BLUE_HUE}, 28%, 10%)`, gray_950: `hsl(${BLUE_HUE}, 28%, ${scale[2]}%)`,
gray_975: `hsl(${BLUE_HUE}, 28%, 7%)`, gray_975: `hsl(${BLUE_HUE}, 28%, ${scale[1]}%)`,
gray_1000: `hsl(${BLUE_HUE}, 28%, 4%)`, gray_1000: `hsl(${BLUE_HUE}, 28%, ${scale[0]}%)`,
blue_25: `hsl(${BLUE_HUE}, 99%, 97%)`, blue_25: `hsl(${BLUE_HUE}, 99%, 97%)`,
blue_50: `hsl(${BLUE_HUE}, 99%, 95%)`, 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) { if (!disabled) {
baseStyles.push(a.border, { baseStyles.push(a.border, {
borderColor: tokens.color.blue_500, borderColor: t.palette.primary_500,
}) })
hoverStyles.push(a.border, { hoverStyles.push(a.border, {
backgroundColor: light backgroundColor: light
@ -174,7 +174,7 @@ export function Button({
}) })
} else { } else {
baseStyles.push(a.border, { 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') { } else if (variant === 'ghost') {
@ -191,20 +191,14 @@ export function Button({
if (variant === 'solid') { if (variant === 'solid') {
if (!disabled) { if (!disabled) {
baseStyles.push({ baseStyles.push({
backgroundColor: light backgroundColor: t.palette.contrast_50,
? tokens.color.gray_50
: tokens.color.gray_900,
}) })
hoverStyles.push({ hoverStyles.push({
backgroundColor: light backgroundColor: t.palette.contrast_100,
? tokens.color.gray_100
: tokens.color.gray_950,
}) })
} else { } else {
baseStyles.push({ baseStyles.push({
backgroundColor: light backgroundColor: t.palette.contrast_200,
? tokens.color.gray_200
: tokens.color.gray_950,
}) })
} }
} else if (variant === 'outline') { } else if (variant === 'outline') {
@ -214,21 +208,19 @@ export function Button({
if (!disabled) { if (!disabled) {
baseStyles.push(a.border, { 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 { } else {
baseStyles.push(a.border, { baseStyles.push(a.border, {
borderColor: light ? tokens.color.gray_200 : tokens.color.gray_800, borderColor: t.palette.contrast_200,
}) })
} }
} else if (variant === 'ghost') { } else if (variant === 'ghost') {
if (!disabled) { if (!disabled) {
baseStyles.push(t.atoms.bg) baseStyles.push(t.atoms.bg)
hoverStyles.push({ hoverStyles.push({
backgroundColor: light backgroundColor: t.palette.contrast_100,
? tokens.color.gray_100
: tokens.color.gray_900,
}) })
} }
} }
@ -236,14 +228,14 @@ export function Button({
if (variant === 'solid') { if (variant === 'solid') {
if (!disabled) { if (!disabled) {
baseStyles.push({ baseStyles.push({
backgroundColor: t.palette.negative_400, backgroundColor: t.palette.negative_500,
}) })
hoverStyles.push({ hoverStyles.push({
backgroundColor: t.palette.negative_500, backgroundColor: t.palette.negative_600,
}) })
} else { } else {
baseStyles.push({ baseStyles.push({
backgroundColor: t.palette.negative_600, backgroundColor: t.palette.negative_700,
}) })
} }
} else if (variant === 'outline') { } else if (variant === 'outline') {
@ -253,7 +245,7 @@ export function Button({
if (!disabled) { if (!disabled) {
baseStyles.push(a.border, { baseStyles.push(a.border, {
borderColor: t.palette.negative_400, borderColor: t.palette.negative_500,
}) })
hoverStyles.push(a.border, { hoverStyles.push(a.border, {
backgroundColor: light backgroundColor: light
@ -273,7 +265,7 @@ export function Button({
hoverStyles.push({ hoverStyles.push({
backgroundColor: light backgroundColor: light
? t.palette.negative_100 ? 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 (variant === 'solid' || variant === 'gradient') {
if (!disabled) { if (!disabled) {
baseStyles.push({ baseStyles.push({
color: light ? tokens.color.gray_700 : tokens.color.gray_100, color: t.palette.contrast_700,
}) })
} else { } else {
baseStyles.push({ baseStyles.push({
color: light ? tokens.color.gray_400 : tokens.color.gray_700, color: t.palette.contrast_400,
}) })
} }
} else if (variant === 'outline') { } else if (variant === 'outline') {
if (!disabled) { if (!disabled) {
baseStyles.push({ baseStyles.push({
color: light ? tokens.color.gray_600 : tokens.color.gray_300, color: t.palette.contrast_600,
}) })
} else { } else {
baseStyles.push({ baseStyles.push({
color: light ? tokens.color.gray_400 : tokens.color.gray_700, color: t.palette.contrast_300,
}) })
} }
} else if (variant === 'ghost') { } else if (variant === 'ghost') {
if (!disabled) { if (!disabled) {
baseStyles.push({ baseStyles.push({
color: light ? tokens.color.gray_600 : tokens.color.gray_300, color: t.palette.contrast_600,
}) })
} else { } else {
baseStyles.push({ 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 React, {useImperativeHandle} from 'react'
import {View, Dimensions} from 'react-native' import {View, Dimensions, Keyboard, Pressable} from 'react-native'
import BottomSheet, { import BottomSheet, {
BottomSheetBackdrop, BottomSheetBackdropProps,
BottomSheetScrollView, BottomSheetScrollView,
BottomSheetTextInput, BottomSheetTextInput,
BottomSheetView, BottomSheetView,
useBottomSheet,
WINDOW_HEIGHT,
} from '@gorhom/bottom-sheet' } from '@gorhom/bottom-sheet'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import Animated, {useAnimatedStyle} from 'react-native-reanimated'
import {useTheme, atoms as a, flatten} from '#/alf' import {useTheme, atoms as a, flatten} from '#/alf'
import {Portal} from '#/components/Portal' import {Portal} from '#/components/Portal'
@ -26,6 +29,47 @@ export * from '#/components/Dialog/types'
// @ts-ignore // @ts-ignore
export const Input = createInput(BottomSheetTextInput) 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({ export function Outer({
children, children,
control, control,
@ -78,6 +122,7 @@ export function Outer({
const onChange = React.useCallback( const onChange = React.useCallback(
(index: number) => { (index: number) => {
if (index === -1) { if (index === -1) {
Keyboard.dismiss()
try { try {
closeCallback.current?.() closeCallback.current?.()
} catch (e: any) { } catch (e: any) {
@ -113,15 +158,7 @@ export function Outer({
ref={sheet} ref={sheet}
index={openIndex} index={openIndex}
backgroundStyle={{backgroundColor: 'transparent'}} backgroundStyle={{backgroundColor: 'transparent'}}
backdropComponent={props => ( backdropComponent={Backdrop}
<BottomSheetBackdrop
opacity={0.4}
appearsOnIndex={0}
disappearsOnIndex={-1}
{...props}
style={[flatten(props.style), t.atoms.bg_contrast_300]}
/>
)}
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}} handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
handleStyle={{display: 'none'}} handleStyle={{display: 'none'}}
onChange={onChange}> onChange={onChange}>
@ -190,8 +227,15 @@ export function ScrollableInner({children, style}: DialogInnerProps) {
export function Handle() { export function Handle() {
const t = useTheme() const t = useTheme()
const onTouchStart = React.useCallback(() => {
Keyboard.dismiss()
}, [])
return ( 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 <View
style={[ style={[
a.rounded_sm, a.rounded_sm,

View File

@ -49,7 +49,7 @@ type BaseLinkProps = Pick<
* *
* Note: atm this only works for `InlineLink`s with a string child. * 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` * Callback for when the link is pressed. Prevent default and return `false`
@ -69,7 +69,7 @@ export function useLink({
to, to,
displayText, displayText,
action = 'push', action = 'push',
warnOnMismatchingTextChild, disableMismatchWarning,
onPress: outerOnPress, onPress: outerOnPress,
}: BaseLinkProps & { }: BaseLinkProps & {
displayText: string displayText: string
@ -90,7 +90,7 @@ export function useLink({
if (exitEarlyIfFalse === false) return if (exitEarlyIfFalse === false) return
const requiresWarning = Boolean( const requiresWarning = Boolean(
warnOnMismatchingTextChild && !disableMismatchWarning &&
displayText && displayText &&
isExternal && isExternal &&
linkRequiresWarning(href, displayText), linkRequiresWarning(href, displayText),
@ -148,7 +148,7 @@ export function useLink({
}, },
[ [
outerOnPress, outerOnPress,
warnOnMismatchingTextChild, disableMismatchWarning,
displayText, displayText,
isExternal, isExternal,
href, 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'> Omit<ButtonProps, 'onPress' | 'disabled' | 'label'>
/** /**
@ -226,7 +226,7 @@ export function InlineLink({
children, children,
to, to,
action = 'push', action = 'push',
warnOnMismatchingTextChild, disableMismatchWarning,
style, style,
onPress: outerOnPress, onPress: outerOnPress,
download, download,
@ -239,7 +239,7 @@ export function InlineLink({
to, to,
displayText: stringChildren ? children : '', displayText: stringChildren ? children : '',
action, action,
warnOnMismatchingTextChild, disableMismatchWarning,
onPress: outerOnPress, onPress: outerOnPress,
}) })
const { 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} to={link.uri}
style={[...styles, {pointerEvents: 'auto'}]} style={[...styles, {pointerEvents: 'auto'}]}
// @ts-ignore TODO // @ts-ignore TODO
dataSet={WORD_WRAP} dataSet={WORD_WRAP}>
warnOnMismatchingLabel>
{toShortUrl(segment.text)} {toShortUrl(segment.text)}
</InlineLink>, </InlineLink>,
) )
@ -121,6 +120,7 @@ export function RichText({
<RichTextTag <RichTextTag
key={key} key={key}
text={segment.text} text={segment.text}
tag={tag.tag}
style={styles} style={styles}
selectable={selectable} selectable={selectable}
authorHandle={authorHandle} authorHandle={authorHandle}
@ -146,12 +146,14 @@ export function RichText({
} }
function RichTextTag({ function RichTextTag({
text: tag, text,
tag,
style, style,
selectable, selectable,
authorHandle, authorHandle,
}: { }: {
text: string text: string
tag: string
selectable?: boolean selectable?: boolean
authorHandle?: string authorHandle?: string
} & TextStyleProp) { } & TextStyleProp) {
@ -185,8 +187,8 @@ function RichTextTag({
<Text <Text
selectable={selectable} selectable={selectable}
{...native({ {...native({
accessibilityLabel: _(msg`Hashtag: ${tag}`), accessibilityLabel: _(msg`Hashtag: #${tag}`),
accessibilityHint: _(msg`Click here to open tag menu for ${tag}`), accessibilityHint: _(msg`Click here to open tag menu for #${tag}`),
accessibilityRole: isNative ? 'button' : undefined, accessibilityRole: isNative ? 'button' : undefined,
onPress: open, onPress: open,
onPressIn: onPressIn, onPressIn: onPressIn,
@ -214,7 +216,7 @@ function RichTextTag({
textDecorationColor: t.palette.primary_500, textDecorationColor: t.palette.primary_500,
}, },
]}> ]}>
{tag} {text}
</Text> </Text>
</TagMenu> </TagMenu>
</React.Fragment> </React.Fragment>

View File

@ -34,6 +34,10 @@ export function TagMenu({
authorHandle, authorHandle,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
control: Dialog.DialogOuterProps['control'] control: Dialog.DialogOuterProps['control']
/**
* This should be the sanitized tag value from the facet itself, not the
* "display" value with a leading `#`.
*/
tag: string tag: string
authorHandle?: string authorHandle?: string
}>) { }>) {
@ -52,16 +56,16 @@ export function TagMenu({
variables: optimisticRemove, variables: optimisticRemove,
reset: resetRemove, reset: resetRemove,
} = useRemoveMutedWordMutation() } = useRemoveMutedWordMutation()
const displayTag = '#' + tag
const sanitizedTag = tag.replace(/^#/, '')
const isMuted = Boolean( const isMuted = Boolean(
(preferences?.mutedWords?.find( (preferences?.mutedWords?.find(
m => m.value === sanitizedTag && m.targets.includes('tag'), m => m.value === tag && m.targets.includes('tag'),
) ?? ) ??
optimisticUpsert?.find( 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 ( return (
@ -71,7 +75,7 @@ export function TagMenu({
<Dialog.Outer control={control}> <Dialog.Outer control={control}>
<Dialog.Handle /> <Dialog.Handle />
<Dialog.Inner label={_(msg`Tag menu: ${tag}`)}> <Dialog.Inner label={_(msg`Tag menu: ${displayTag}`)}>
{isPreferencesLoading ? ( {isPreferencesLoading ? (
<View style={[a.w_full, a.align_center]}> <View style={[a.w_full, a.align_center]}>
<Loader size="lg" /> <Loader size="lg" />
@ -87,18 +91,14 @@ export function TagMenu({
t.atoms.bg_contrast_25, t.atoms.bg_contrast_25,
]}> ]}>
<Link <Link
label={_(msg`Search for all posts with tag ${tag}`)} label={_(msg`Search for all posts with tag ${displayTag}`)}
to={makeSearchLink({query: tag})} to={makeSearchLink({query: displayTag})}
onPress={e => { onPress={e => {
e.preventDefault() e.preventDefault()
control.close(() => { control.close(() => {
// @ts-ignore :ron_swanson: "I know more than you" navigation.push('Hashtag', {
navigation.navigate('SearchTab', { tag: tag.replaceAll('#', '%23'),
screen: 'Search',
params: {
q: tag,
},
}) })
}) })
@ -128,7 +128,7 @@ export function TagMenu({
<Trans> <Trans>
See{' '} See{' '}
<Text style={[a.text_md, a.font_bold, t.atoms.text]}> <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
{tag} {displayTag}
</Text>{' '} </Text>{' '}
posts posts
</Trans> </Trans>
@ -142,21 +142,19 @@ export function TagMenu({
<Link <Link
label={_( 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 => { onPress={e => {
e.preventDefault() e.preventDefault()
control.close(() => { control.close(() => {
// @ts-ignore :ron_swanson: "I know more than you" navigation.push('Hashtag', {
navigation.navigate('SearchTab', { tag: tag.replaceAll('#', '%23'),
screen: 'Search', author: authorHandle,
params: {
q:
tag +
(authorHandle ? ` from:${authorHandle}` : ''),
},
}) })
}) })
@ -190,7 +188,7 @@ export function TagMenu({
See{' '} See{' '}
<Text <Text
style={[a.text_md, a.font_bold, t.atoms.text]}> style={[a.text_md, a.font_bold, t.atoms.text]}>
{tag} {displayTag}
</Text>{' '} </Text>{' '}
posts by this user posts by this user
</Trans> </Trans>
@ -207,22 +205,20 @@ export function TagMenu({
<Button <Button
label={ label={
isMuted isMuted
? _(msg`Unmute all ${tag} posts`) ? _(msg`Unmute all ${displayTag} posts`)
: _(msg`Mute all ${tag} posts`) : _(msg`Mute all ${displayTag} posts`)
} }
onPress={() => { onPress={() => {
control.close(() => { control.close(() => {
if (isMuted) { if (isMuted) {
resetUpsert() resetUpsert()
removeMutedWord({ removeMutedWord({
value: sanitizedTag, value: tag,
targets: ['tag'], targets: ['tag'],
}) })
} else { } else {
resetRemove() resetRemove()
upsertMutedWord([ upsertMutedWord([{value: tag, targets: ['tag']}])
{value: sanitizedTag, targets: ['tag']},
])
} }
}) })
}}> }}>
@ -252,7 +248,7 @@ export function TagMenu({
]}> ]}>
{isMuted ? _(msg`Unmute`) : _(msg`Mute`)}{' '} {isMuted ? _(msg`Unmute`) : _(msg`Mute`)}{' '}
<Text style={[a.text_md, a.font_bold, t.atoms.text]}> <Text style={[a.text_md, a.font_bold, t.atoms.text]}>
{tag} {displayTag}
</Text>{' '} </Text>{' '}
<Trans>posts</Trans> <Trans>posts</Trans>
</Text> </Text>

View File

@ -14,18 +14,34 @@ import {
} from '#/state/queries/preferences' } from '#/state/queries/preferences'
import {enforceLen} from '#/lib/strings/helpers' import {enforceLen} from '#/lib/strings/helpers'
import {web} from '#/alf' 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({ export function TagMenu({
children, children,
tag, tag,
authorHandle, authorHandle,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
/**
* This should be the sanitized tag value from the facet itself, not the
* "display" value with a leading `#`.
*/
tag: string tag: string
authorHandle?: string authorHandle?: string
}>) { }>) {
const sanitizedTag = tag.replace(/^#/, '')
const {_} = useLingui() const {_} = useLingui()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {data: preferences} = usePreferencesQuery() const {data: preferences} = usePreferencesQuery()
@ -35,22 +51,22 @@ export function TagMenu({
useRemoveMutedWordMutation() useRemoveMutedWordMutation()
const isMuted = Boolean( const isMuted = Boolean(
(preferences?.mutedWords?.find( (preferences?.mutedWords?.find(
m => m.value === sanitizedTag && m.targets.includes('tag'), m => m.value === tag && m.targets.includes('tag'),
) ?? ) ??
optimisticUpsert?.find( 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(() => { const dropdownItems = React.useMemo(() => {
return [ return [
{ {
label: _(msg`See ${truncatedTag} posts`), label: _(msg`See ${truncatedTag} posts`),
onPress() { onPress() {
navigation.navigate('Search', { navigation.push('Hashtag', {
q: tag, tag: tag.replaceAll('#', '%23'),
}) })
}, },
testID: 'tagMenuSearch', testID: 'tagMenuSearch',
@ -66,11 +82,9 @@ export function TagMenu({
!isInvalidHandle(authorHandle) && { !isInvalidHandle(authorHandle) && {
label: _(msg`See ${truncatedTag} posts by user`), label: _(msg`See ${truncatedTag} posts by user`),
onPress() { onPress() {
navigation.navigate({ navigation.push('Hashtag', {
name: 'Search', tag: tag.replaceAll('#', '%23'),
params: { author: authorHandle,
q: tag + (authorHandle ? ` from:${authorHandle}` : ''),
},
}) })
}, },
testID: 'tagMenuSeachByUser', testID: 'tagMenuSeachByUser',
@ -91,9 +105,9 @@ export function TagMenu({
: _(msg`Mute ${truncatedTag}`), : _(msg`Mute ${truncatedTag}`),
onPress() { onPress() {
if (isMuted) { if (isMuted) {
removeMutedWord({value: sanitizedTag, targets: ['tag']}) removeMutedWord({value: tag, targets: ['tag']})
} else { } else {
upsertMutedWord([{value: sanitizedTag, targets: ['tag']}]) upsertMutedWord([{value: tag, targets: ['tag']}])
} }
}, },
testID: 'tagMenuMute', testID: 'tagMenuMute',
@ -114,7 +128,6 @@ export function TagMenu({
preferences, preferences,
tag, tag,
truncatedTag, truncatedTag,
sanitizedTag,
upsertMutedWord, upsertMutedWord,
removeMutedWord, removeMutedWord,
]) ])

View File

@ -1,8 +1,8 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {Keyboard, View} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {AppBskyActorDefs} from '@atproto/api' import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api'
import { import {
usePreferencesQuery, usePreferencesQuery,
@ -10,7 +10,14 @@ import {
useRemoveMutedWordMutation, useRemoveMutedWordMutation,
} from '#/state/queries/preferences' } from '#/state/queries/preferences'
import {isNative} from '#/platform/detection' 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 {Text} from '#/components/Typography'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
@ -48,27 +55,33 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation() const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
const [field, setField] = React.useState('') const [field, setField] = React.useState('')
const [options, setOptions] = React.useState(['content']) const [options, setOptions] = React.useState(['content'])
const [_error, setError] = React.useState('') const [error, setError] = React.useState('')
const submit = React.useCallback(async () => { const submit = React.useCallback(async () => {
const value = field.trim() const sanitizedValue = sanitizeMutedWordValue(field)
const targets = ['tag', options.includes('content') && 'content'].filter( const targets = ['tag', options.includes('content') && 'content'].filter(
Boolean, Boolean,
) as AppBskyActorDefs.MutedWord['targets'] ) 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 { try {
await addMutedWord([{value, targets}]) // send raw value and rely on SDK as sanitization source of truth
await addMutedWord([{value: field, targets}])
setField('') setField('')
} catch (e: any) { } catch (e: any) {
logger.error(`Failed to save muted word`, {message: e.message}) logger.error(`Failed to save muted word`, {message: e.message})
setError(e.message) setError(e.message)
} }
}, [field, options, addMutedWord, setField]) }, [_, field, options, addMutedWord, setField])
return ( return (
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}> <Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
<View onTouchStart={Keyboard.dismiss}>
<Text <Text
style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}> style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
<Trans>Add muted words and tags</Trans> <Trans>Add muted words and tags</Trans>
@ -87,7 +100,12 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
label={_(msg`Enter a word or tag`)} label={_(msg`Enter a word or tag`)}
placeholder={_(msg`Enter a word or tag`)} placeholder={_(msg`Enter a word or tag`)}
value={field} value={field}
onChangeText={setField} onChangeText={value => {
if (error) {
setError('')
}
setField(value)
}}
onSubmitEditing={submit} onSubmitEditing={submit}
/> />
@ -99,7 +117,7 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
<View <View
style={[ style={[
a.pt_sm, a.pt_sm,
a.pb_md, a.py_sm,
a.flex_row, a.flex_row,
a.align_center, a.align_center,
a.gap_sm, a.gap_sm,
@ -151,16 +169,41 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
</View> </View>
</Toggle.Group> </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 <Text
style={[ style={[
a.italic,
{color: t.palette.white},
native({marginTop: 2}),
]}>
{error}
</Text>
</View>
)}
<Text
style={[
a.pt_xs,
a.text_sm, a.text_sm,
a.italic, a.italic,
a.leading_snug, a.leading_snug,
t.atoms.text_contrast_medium, t.atoms.text_contrast_medium,
]}> ]}>
<Trans> <Trans>
We recommend avoiding common words that appear in many posts, since We recommend avoiding common words that appear in many posts,
it can result in no posts being shown. since it can result in no posts being shown.
</Trans> </Trans>
</Text> </Text>
</View> </View>
@ -169,7 +212,12 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
<View style={[a.pt_2xl]}> <View style={[a.pt_2xl]}>
<Text <Text
style={[a.text_md, a.font_bold, a.pb_md, t.atoms.text_contrast_high]}> style={[
a.text_md,
a.font_bold,
a.pb_md,
t.atoms.text_contrast_high,
]}>
<Trans>Your muted words</Trans> <Trans>Your muted words</Trans>
</Text> </Text>
@ -208,6 +256,7 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
{isNative && <View style={{height: 20}} />} {isNative && <View style={{height: 20}} />}
<Dialog.Close /> <Dialog.Close />
</View>
</Dialog.ScrollableInner> </Dialog.ScrollableInner>
) )
} }

View File

@ -10,7 +10,7 @@ import {
} from 'react-native' } from 'react-native'
import {HITSLOP_20} from 'lib/constants' 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 {Text} from '#/components/Typography'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Props as SVGIconProps} from '#/components/icons/common' import {Props as SVGIconProps} from '#/components/icons/common'
@ -110,7 +110,7 @@ export function useSharedInputStyles() {
{ {
backgroundColor: backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900, 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) { if (isInvalid) {
base.push({ base.push({
backgroundColor: backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900, t.name === 'light' ? t.palette.negative_25 : t.palette.negative_975,
borderColor: borderColor:
t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800, t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
}) })
@ -310,7 +310,7 @@ export function createSharedToggleStyles({
baseHover.push({ baseHover.push({
backgroundColor: backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900, 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(() => { React.useEffect(() => {
const handleIncomingURL = (url: string) => { 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 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 // 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 // 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 = intent === 'intent'
const isIntent = isNative
? urlp.hostname === 'intent'
: intentTypeNative === 'intent'
const params = urlp.searchParams const params = urlp.searchParams
if (!isIntent) return if (!isIntent) return
@ -69,10 +74,7 @@ function useComposeIntent() {
return false return false
} }
// We also should just filter out cases that don't have all the info we need // We also should just filter out cases that don't have all the info we need
if (!VALID_IMAGE_REGEX.test(part)) { return VALID_IMAGE_REGEX.test(part)
return false
}
return true
}) })
.map(part => { .map(part => {
const [uri, width, height] = part.split('|') const [uri, width, height] = part.split('|')

View File

@ -6,6 +6,7 @@ import {
AppBskyFeedPost, AppBskyFeedPost,
AppBskyRichtextFacet, AppBskyRichtextFacet,
AppBskyEmbedImages, AppBskyEmbedImages,
AppBskyEmbedExternal,
} from '@atproto/api' } from '@atproto/api'
type ModeratePost = typeof moderatePost type ModeratePost = typeof moderatePost
@ -205,44 +206,151 @@ export function moderatePost_wrapped(
if (subject.embed) { if (subject.embed) {
let embedHidden = false let embedHidden = false
let embedMuted = false
let externalMuted = false
if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) { if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
embedHidden = hiddenPosts.includes(subject.embed.record.uri) 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 ( if (
AppBskyEmbedRecordWithMedia.isView(subject.embed) && AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
AppBskyEmbedRecord.isViewRecord(subject.embed.record.record) AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
) { ) {
// TODO what
embedHidden = hiddenPosts.includes(subject.embed.record.record.uri) 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) { if (embedHidden) {
moderations.embed.filter = true moderations.embed.filter = true
moderations.embed.blur = true moderations.embed.blur = true
@ -254,6 +362,17 @@ export function moderatePost_wrapped(
priority: 1, 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 PreferencesThreads: undefined
PreferencesExternalEmbeds: undefined PreferencesExternalEmbeds: undefined
Search: {q?: string} Search: {q?: string}
Hashtag: {tag: string; author?: string}
} }
export type BottomTabNavigatorParams = CommonNavigatorParams & { export type BottomTabNavigatorParams = CommonNavigatorParams & {
@ -69,6 +70,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
Search: {q?: string} Search: {q?: string}
Feeds: undefined Feeds: undefined
Notifications: undefined Notifications: undefined
Hashtag: {tag: string; author?: string}
} }
export type AllNavigatorParams = CommonNavigatorParams & { export type AllNavigatorParams = CommonNavigatorParams & {
@ -81,6 +83,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
NotificationsTab: undefined NotificationsTab: undefined
Notifications: undefined Notifications: undefined
MyProfileTab: undefined MyProfileTab: undefined
Hashtag: {tag: string; author?: string}
} }
// NOTE // NOTE

View File

@ -157,17 +157,11 @@ export function linkRequiresWarning(uri: string, label: string) {
const host = urip.hostname.toLowerCase() 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, // if this is a link to internal content,
// warn if it represents itself as a URL to another app // warn if it represents itself as a URL to another app
if ( return !!labelDomain && labelDomain !== host && isPossiblyAUrl(labelDomain)
labelDomain &&
labelDomain !== 'bsky.app' &&
isPossiblyAUrl(labelDomain)
) {
return true
}
return false
} else { } else {
// if this is a link to external content, // if this is a link to external content,
// warn if the label doesnt match the target // warn if the label doesnt match the target

View File

@ -357,8 +357,8 @@ export const dimTheme: Theme = {
textVeryLight: dimPalette.contrast_400, textVeryLight: dimPalette.contrast_400,
replyLine: dimPalette.contrast_200, replyLine: dimPalette.contrast_200,
replyLineDot: dimPalette.contrast_200, replyLineDot: dimPalette.contrast_200,
unreadNotifBg: `hsl(211, 48%, 17%)`, unreadNotifBg: dimPalette.primary_975,
unreadNotifBorder: `hsl(211, 48%, 30%)`, unreadNotifBorder: dimPalette.primary_900,
postCtrl: dimPalette.contrast_500, postCtrl: dimPalette.contrast_500,
brandText: dimPalette.primary_500, brandText: dimPalette.primary_500,
emptyStateIcon: dimPalette.contrast_300, emptyStateIcon: dimPalette.contrast_300,

View File

@ -33,4 +33,5 @@ export const router = new Router({
TermsOfService: '/support/tos', TermsOfService: '/support/tos',
CommunityGuidelines: '/support/community-guidelines', CommunityGuidelines: '/support/community-guidelines',
CopyrightPolicy: '/support/copyright', 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 {Plugin, PluginKey} from '@tiptap/pm/state'
import {Node as ProsemirrorNode} from '@tiptap/pm/model' import {Node as ProsemirrorNode} from '@tiptap/pm/model'
import {Decoration, DecorationSet} from '@tiptap/pm/view' import {Decoration, DecorationSet} from '@tiptap/pm/view'
import {URL_REGEX} from '@atproto/api'
import {isValidDomain} from 'lib/strings/url-helpers' import {isValidDomain} from 'lib/strings/url-helpers'
export const LinkDecorator = Mark.create({ export const LinkDecorator = Mark.create({
@ -78,8 +80,7 @@ function linkDecorator() {
function iterateUris(str: string, cb: (from: number, to: number) => void) { function iterateUris(str: string, cb: (from: number, to: number) => void) {
let match let match
const re = const re = URL_REGEX
/(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim
while ((match = re.exec(str))) { while ((match = re.exec(str))) {
let uri = match[2] let uri = match[2]
if (!uri.startsWith('http')) { if (!uri.startsWith('http')) {

View File

@ -18,28 +18,36 @@ import {Mark} from '@tiptap/core'
import {Plugin, PluginKey} from '@tiptap/pm/state' import {Plugin, PluginKey} from '@tiptap/pm/state'
import {Node as ProsemirrorNode} from '@tiptap/pm/model' import {Node as ProsemirrorNode} from '@tiptap/pm/model'
import {Decoration, DecorationSet} from '@tiptap/pm/view' import {Decoration, DecorationSet} from '@tiptap/pm/view'
import {TAG_REGEX, TRAILING_PUNCTUATION_REGEX} from '@atproto/api'
function getDecorations(doc: ProsemirrorNode) { function getDecorations(doc: ProsemirrorNode) {
const decorations: Decoration[] = [] const decorations: Decoration[] = []
doc.descendants((node, pos) => { doc.descendants((node, pos) => {
if (node.isText && node.text) { if (node.isText && node.text) {
const regex = /(?:^|\s)(#[^\d\s]\S*)(?=\s)?/g const regex = TAG_REGEX
const textContent = node.textContent const textContent = node.textContent
let match let match
while ((match = regex.exec(textContent))) { 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( decorations.push(
Decoration.inline(pos + from, pos + to, { Decoration.inline(start, end, {
class: 'autolink', class: 'autolink',
}), }),
) )

View File

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

View File

@ -1,13 +1,10 @@
import React from 'react' import React from 'react'
import {StyleSheet, View} from 'react-native' import {StyleSheet, View} from 'react-native'
import Animated from 'react-native-reanimated'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {HomeHeaderLayoutMobile} from './HomeHeaderLayoutMobile' import {HomeHeaderLayoutMobile} from './HomeHeaderLayoutMobile'
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
import {useShellLayout} from '#/state/shell/shell-layout'
import {Logo} from '#/view/icons/Logo' import {Logo} from '#/view/icons/Logo'
import {Link, TextLink} from '../util/Link' import {Link} from '../util/Link'
import { import {
FontAwesomeIcon, FontAwesomeIcon,
FontAwesomeIconStyle, FontAwesomeIconStyle,
@ -16,41 +13,42 @@ import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {CogIcon} from '#/lib/icons' 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() const {isMobile} = useWebMediaQueries()
if (isMobile) { if (isMobile) {
return <HomeHeaderLayoutMobile>{children}</HomeHeaderLayoutMobile> return <HomeHeaderLayoutMobile {...props} />
} else { } 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 pal = usePalette('default')
const {headerMinimalShellTransform} = useMinimalShellMode()
const {headerHeight} = useShellLayout()
const {_} = useLingui() const {_} = useLingui()
return ( return (
// @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf <>
<Animated.View <View style={[pal.view, pal.border, styles.bar, styles.topBar]}>
style={[pal.view, pal.border, styles.tabBar, headerMinimalShellTransform]} <Link
onLayout={e => {
headerHeight.value = e.nativeEvent.layout.height
}}>
<View style={[pal.view, styles.topBar]}>
<TextLink
type="title-lg"
href="/settings/following-feed" href="/settings/following-feed"
hitSlop={10}
accessibilityRole="button"
accessibilityLabel={_(msg`Following Feed Preferences`)} accessibilityLabel={_(msg`Following Feed Preferences`)}
accessibilityHint="" accessibilityHint="">
text={
<FontAwesomeIcon <FontAwesomeIcon
icon="sliders" icon="sliders"
style={pal.textLight as FontAwesomeIconStyle} style={pal.textLight as FontAwesomeIconStyle}
/> />
} </Link>
/>
<Logo width={28} /> <Logo width={28} />
<Link <Link
href="/settings/saved-feeds" href="/settings/saved-feeds"
@ -61,32 +59,38 @@ function HomeHeaderLayoutTablet({children}: {children: React.ReactNode}) {
<CogIcon size={22} strokeWidth={2} style={pal.textLight} /> <CogIcon size={22} strokeWidth={2} style={pal.textLight} />
</Link> </Link>
</View> </View>
{tabBarAnchor}
<View style={[pal.view, pal.border, styles.bar, styles.tabBar]}>
{children} {children}
</Animated.View> </View>
</>
) )
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
bar: {
// @ts-ignore Web only
left: 'calc(50% - 300px)',
width: 600,
borderLeftWidth: 1,
borderRightWidth: 1,
},
topBar: { topBar: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 18, paddingHorizontal: 18,
paddingVertical: 8, paddingTop: 16,
marginTop: 8, paddingBottom: 8,
width: '100%',
}, },
tabBar: { tabBar: {
// @ts-ignore Web only // @ts-ignore Web only
position: 'sticky', position: 'sticky',
zIndex: 1,
// @ts-ignore Web only -prf
left: 'calc(50% - 300px)',
width: 600,
top: 0, top: 0,
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
borderLeftWidth: 1, borderLeftWidth: 1,
borderRightWidth: 1, borderRightWidth: 1,
zIndex: 1,
}, },
}) })

View File

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

View File

@ -159,7 +159,7 @@ export const TextLink = memo(function TextLink({
dataSet, dataSet,
title, title,
onPress, onPress,
warnOnMismatchingLabel, disableMismatchWarning,
navigationAction, navigationAction,
...orgProps ...orgProps
}: { }: {
@ -172,7 +172,7 @@ export const TextLink = memo(function TextLink({
lineHeight?: number lineHeight?: number
dataSet?: any dataSet?: any
title?: string title?: string
warnOnMismatchingLabel?: boolean disableMismatchWarning?: boolean
navigationAction?: 'push' | 'replace' | 'navigate' navigationAction?: 'push' | 'replace' | 'navigate'
} & TextProps) { } & TextProps) {
const {...props} = useLinkProps({to: sanitizeUrl(href)}) const {...props} = useLinkProps({to: sanitizeUrl(href)})
@ -180,14 +180,14 @@ export const TextLink = memo(function TextLink({
const {openModal, closeModal} = useModalControls() const {openModal, closeModal} = useModalControls()
const openLink = useOpenLink() const openLink = useOpenLink()
if (warnOnMismatchingLabel && typeof text !== 'string') { if (!disableMismatchWarning && typeof text !== 'string') {
console.error('Unable to detect mismatching label') console.error('Unable to detect mismatching label')
} }
props.onPress = React.useCallback( props.onPress = React.useCallback(
(e?: Event) => { (e?: Event) => {
const requiresWarning = const requiresWarning =
warnOnMismatchingLabel && !disableMismatchWarning &&
linkRequiresWarning(href, typeof text === 'string' ? text : '') linkRequiresWarning(href, typeof text === 'string' ? text : '')
if (requiresWarning) { if (requiresWarning) {
e?.preventDefault?.() e?.preventDefault?.()
@ -227,7 +227,7 @@ export const TextLink = memo(function TextLink({
navigation, navigation,
href, href,
text, text,
warnOnMismatchingLabel, disableMismatchWarning,
navigationAction, navigationAction,
openLink, openLink,
], ],

View File

@ -13,11 +13,13 @@ import Animated from 'react-native-reanimated'
import {useSetDrawerOpen} from '#/state/shell' import {useSetDrawerOpen} from '#/state/shell'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useTheme} from '#/alf'
const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20} const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
export function ViewHeader({ export function ViewHeader({
title, title,
subtitle,
canGoBack, canGoBack,
showBackButton = true, showBackButton = true,
hideOnScroll, hideOnScroll,
@ -26,6 +28,7 @@ export function ViewHeader({
renderButton, renderButton,
}: { }: {
title: string title: string
subtitle?: string
canGoBack?: boolean canGoBack?: boolean
showBackButton?: boolean showBackButton?: boolean
hideOnScroll?: boolean hideOnScroll?: boolean
@ -39,6 +42,7 @@ export function ViewHeader({
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {track} = useAnalytics() const {track} = useAnalytics()
const {isDesktop, isTablet} = useWebMediaQueries() const {isDesktop, isTablet} = useWebMediaQueries()
const t = useTheme()
const onPressBack = React.useCallback(() => { const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) { if (navigation.canGoBack()) {
@ -71,6 +75,8 @@ export function ViewHeader({
return ( return (
<Container hideOnScroll={hideOnScroll || false} showBorder={showBorder}> <Container hideOnScroll={hideOnScroll || false} showBorder={showBorder}>
<View style={{flex: 1}}>
<View style={{flexDirection: 'row', alignItems: 'center'}}>
{showBackButton ? ( {showBackButton ? (
<TouchableOpacity <TouchableOpacity
testID="viewHeaderDrawerBtn" testID="viewHeaderDrawerBtn"
@ -107,6 +113,22 @@ export function ViewHeader({
) : showBackButton ? ( ) : showBackButton ? (
<View style={canGoBack ? styles.backBtn : styles.backBtnWide} /> <View style={canGoBack ? styles.backBtn : styles.backBtnWide} />
) : null} ) : 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>
</Container> </Container>
) )
} }
@ -185,7 +207,6 @@ function Container({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
header: { header: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 6, paddingVertical: 6,
width: '100%', width: '100%',
@ -207,12 +228,14 @@ const styles = StyleSheet.create({
titleContainer: { titleContainer: {
marginLeft: 'auto', marginLeft: 'auto',
marginRight: 'auto', marginRight: 'auto',
paddingRight: 10, alignItems: 'center',
}, },
title: { title: {
fontWeight: 'bold', fontWeight: 'bold',
}, },
subtitle: {
fontSize: 13,
},
backBtn: { backBtn: {
width: 30, width: 30,
height: 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') const desc = describeModerationCause(moderation.cause, 'content')
return ( return (
<View testID={testID} style={[styles.outer, style]}> <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') const desc = describeModerationCause(moderation.cause, 'content')
return !override ? ( return !override ? (
<Pressable <Pressable

View File

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

View File

@ -123,8 +123,7 @@ function HomeScreenReady({
return ( return (
<HomeHeader <HomeHeader
key="FEEDS_TAB_BAR" key="FEEDS_TAB_BAR"
selectedPage={props.selectedPage} {...props}
onSelect={props.onSelect}
testID="homeScreenFeedTabs" testID="homeScreenFeedTabs"
onPressSelected={onPressSelected} onPressSelected={onPressSelected}
feeds={pinnedFeedInfos} feeds={pinnedFeedInfos}

View File

@ -491,6 +491,8 @@ const styles = StyleSheet.create({
container: { container: {
flexDirection: 'column', flexDirection: 'column',
height: '100%', height: '100%',
// @ts-ignore Web-only.
overflowAnchor: 'none', // Fixes jumps when switching tabs while scrolled down.
}, },
loading: { loading: {
paddingVertical: 10, paddingVertical: 10,

View File

@ -60,7 +60,7 @@ import {
import {logger} from '#/logger' import {logger} from '#/logger'
import {useAnalytics} from '#/lib/analytics/analytics' import {useAnalytics} from '#/lib/analytics/analytics'
import {listenSoftReset} from '#/state/events' 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_CURATE = ['Posts', 'About']
const SECTION_TITLES_MOD = ['About'] const SECTION_TITLES_MOD = ['About']
@ -699,6 +699,7 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
ref, ref,
) { ) {
const pal = usePalette('default') const pal = usePalette('default')
const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
const {currentAccount} = useSession() const {currentAccount} = useSession()
@ -792,7 +793,7 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
paddingBottom: isMobile ? 14 : 18, paddingBottom: isMobile ? 14 : 18,
}, },
]}> ]}>
<Text type="lg-bold"> <Text type="lg-bold" style={t.atoms.text}>
<Trans>Users</Trans> <Trans>Users</Trans>
</Text> </Text>
{isOwner && ( {isOwner && (
@ -817,14 +818,18 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
</View> </View>
) )
}, [ }, [
pal,
list,
isMobile, isMobile,
pal.border,
pal.textLight,
pal.colors.link,
pal.link,
descriptionRT, descriptionRT,
isCurateList, isCurateList,
isOwner, isOwner,
onPressAddUser, list.creator,
t.atoms.text,
_, _,
onPressAddUser,
]) ])
const renderEmptyState = useCallback(() => { const renderEmptyState = useCallback(() => {

View File

@ -4,7 +4,7 @@ import {View} from 'react-native'
import {useTheme, atoms as a} from '#/alf' import {useTheme, atoms as a} from '#/alf'
import {ButtonText} from '#/components/Button' import {ButtonText} from '#/components/Button'
import {InlineLink, Link} from '#/components/Link' import {InlineLink, Link} from '#/components/Link'
import {H1, H3, Text} from '#/components/Typography' import {H1, Text} from '#/components/Typography'
export function Links() { export function Links() {
const t = useTheme() const t = useTheme()
@ -13,31 +13,19 @@ export function Links() {
<H1>Links</H1> <H1>Links</H1>
<View style={[a.gap_md, a.align_start]}> <View style={[a.gap_md, a.align_start]}>
<InlineLink <InlineLink to="https://google.com" style={[a.text_lg]}>
to="https://bsky.social" https://google.com
warnOnMismatchingTextChild
style={[a.text_md]}>
External
</InlineLink> </InlineLink>
<InlineLink to="https://bsky.social" style={[a.text_md, t.atoms.text]}> <InlineLink to="https://google.com" style={[a.text_lg]}>
<H3>External with custom children</H3> External with custom children (google.com)
</InlineLink> </InlineLink>
<InlineLink <InlineLink
to="https://bsky.social" to="https://bsky.social"
style={[a.text_md, t.atoms.text_contrast_low]}> style={[a.text_md, t.atoms.text_contrast_low]}>
External with custom children Internal (bsky.social)
</InlineLink> </InlineLink>
<InlineLink <InlineLink to="https://bsky.app/profile/bsky.app" style={[a.text_md]}>
to="https://bsky.social" Internal (bsky.app)
warnOnMismatchingTextChild
style={[a.text_lg]}>
https://bsky.social
</InlineLink>
<InlineLink
to="https://bsky.app/profile/bsky.app"
warnOnMismatchingTextChild
style={[a.text_md]}>
Internal
</InlineLink> </InlineLink>
<Link <Link

View File

@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import * as tokens from '#/alf/tokens'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
export function Palette() { export function Palette() {
@ -28,79 +27,79 @@ export function Palette() {
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.blue_25}, {height: 60, backgroundColor: t.palette.primary_25},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.blue_50}, {height: 60, backgroundColor: t.palette.primary_50},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.blue_100}, {height: 60, backgroundColor: t.palette.primary_100},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.blue_200}, {height: 60, backgroundColor: t.palette.primary_200},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.blue_300}, {height: 60, backgroundColor: t.palette.primary_300},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.blue_400}, {height: 60, backgroundColor: t.palette.primary_400},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.blue_500}, {height: 60, backgroundColor: t.palette.primary_500},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.blue_600}, {height: 60, backgroundColor: t.palette.primary_600},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.blue_700}, {height: 60, backgroundColor: t.palette.primary_700},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.blue_800}, {height: 60, backgroundColor: t.palette.primary_800},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.blue_900}, {height: 60, backgroundColor: t.palette.primary_900},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.blue_950}, {height: 60, backgroundColor: t.palette.primary_950},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.blue_975}, {height: 60, backgroundColor: t.palette.primary_975},
]} ]}
/> />
</View> </View>
@ -108,153 +107,159 @@ export function Palette() {
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.green_25}, {height: 60, backgroundColor: t.palette.positive_25},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.green_50}, {height: 60, backgroundColor: t.palette.positive_50},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.green_100}, {height: 60, backgroundColor: t.palette.positive_100},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.green_200}, {height: 60, backgroundColor: t.palette.positive_200},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.green_300}, {height: 60, backgroundColor: t.palette.positive_300},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.green_400}, {height: 60, backgroundColor: t.palette.positive_400},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.green_500}, {height: 60, backgroundColor: t.palette.positive_500},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.green_600}, {height: 60, backgroundColor: t.palette.positive_600},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.green_700}, {height: 60, backgroundColor: t.palette.positive_700},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.green_800}, {height: 60, backgroundColor: t.palette.positive_800},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.green_900}, {height: 60, backgroundColor: t.palette.positive_900},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.green_950}, {height: 60, backgroundColor: t.palette.positive_950},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.green_975}, {height: 60, backgroundColor: t.palette.positive_975},
]} ]}
/> />
</View> </View>
<View style={[a.flex_row, a.gap_md]}> <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 <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.red_100}, {height: 60, backgroundColor: t.palette.negative_25},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.red_200}, {height: 60, backgroundColor: t.palette.negative_50},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.red_300}, {height: 60, backgroundColor: t.palette.negative_100},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.red_400}, {height: 60, backgroundColor: t.palette.negative_200},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.red_500}, {height: 60, backgroundColor: t.palette.negative_300},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.red_600}, {height: 60, backgroundColor: t.palette.negative_400},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.red_700}, {height: 60, backgroundColor: t.palette.negative_500},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.red_800}, {height: 60, backgroundColor: t.palette.negative_600},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.red_900}, {height: 60, backgroundColor: t.palette.negative_700},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, a.flex_1,
{height: 60, backgroundColor: tokens.color.red_950}, {height: 60, backgroundColor: t.palette.negative_800},
]} ]}
/> />
<View <View
style={[ style={[
a.flex_1, 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>

View File

@ -200,10 +200,10 @@ function ComposeBtn() {
const fetchHandle = useFetchHandle() const fetchHandle = useFetchHandle()
const getProfileHandle = async () => { const getProfileHandle = async () => {
const {routes} = getState() const routes = getState()?.routes
const currentRoute = routes[routes.length - 1] const currentRoute = routes?.[routes?.length - 1]
if (currentRoute.name === 'Profile') { if (currentRoute?.name === 'Profile') {
let handle: string | undefined = ( let handle: string | undefined = (
currentRoute.params as CommonNavigatorParams['Profile'] currentRoute.params as CommonNavigatorParams['Profile']
).name ).name

View File

@ -48,6 +48,12 @@
scrollbar-gutter: stable both-edges; 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 */ /* Color theming */
/* Default will always be white */ /* Default will always be white */
:root { :root {

View File

@ -34,15 +34,15 @@
jsonpointer "^5.0.0" jsonpointer "^5.0.0"
leven "^3.1.0" leven "^3.1.0"
"@atproto/api@^0.10.0": "@atproto/api@^0.10.4":
version "0.10.0" version "0.10.4"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.10.0.tgz#ca34dfa8f9b1e6ba021094c40cb0ff3c4c254044" resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.10.4.tgz#b73446f2344783c42c6040082756449443f15750"
integrity sha512-TSVCHh3UUZLtNzh141JwLicfYTc7TvVFvQJSWeOZLHr3Sk+9hqEY+9Itaqp1DAW92r4i25ChaMc/50sg4etAWQ== integrity sha512-9gwZt4v4pngfD4mgsET9i9Ym0PpMSzftTzqBjCbFpObx15zMkFemYnLUnyT/NEww2u/aRxjAe2TeBnU0dIbbuQ==
dependencies: dependencies:
"@atproto/common-web" "^0.2.3" "@atproto/common-web" "^0.2.3"
"@atproto/lexicon" "^0.3.1" "@atproto/lexicon" "^0.3.2"
"@atproto/syntax" "^0.1.5" "@atproto/syntax" "^0.2.0"
"@atproto/xrpc" "^0.4.1" "@atproto/xrpc" "^0.4.2"
multiformats "^9.9.0" multiformats "^9.9.0"
tlds "^1.234.0" tlds "^1.234.0"
typed-emitter "^2.1.0" typed-emitter "^2.1.0"
@ -245,6 +245,17 @@
multiformats "^9.9.0" multiformats "^9.9.0"
zod "^3.21.4" 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": "@atproto/ozone@^0.0.7":
version "0.0.7" version "0.0.7"
resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.0.7.tgz#bfad82bc1d0900e79401a82f13581f707415505a" resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.0.7.tgz#bfad82bc1d0900e79401a82f13581f707415505a"
@ -340,6 +351,13 @@
dependencies: dependencies:
"@atproto/common-web" "^0.2.3" "@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": "@atproto/xrpc-server@^0.4.2":
version "0.4.2" version "0.4.2"
resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.4.2.tgz#23efd89086b85933f1b0cc00c86e895adcaac315" resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.4.2.tgz#23efd89086b85933f1b0cc00c86e895adcaac315"
@ -365,6 +383,14 @@
"@atproto/lexicon" "^0.3.1" "@atproto/lexicon" "^0.3.1"
zod "^3.21.4" 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": "@aws-crypto/crc32@3.0.0":
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/@aws-crypto/crc32/-/crc32-3.0.0.tgz#07300eca214409c33e3ff769cd5697b57fdd38fa" resolved "https://registry.yarnpkg.com/@aws-crypto/crc32/-/crc32-3.0.0.tgz#07300eca214409c33e3ff769cd5697b57fdd38fa"