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,166 +55,208 @@ 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`)}>
<Text <View onTouchStart={Keyboard.dismiss}>
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>
<Text <Text
style={[ style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
a.text_sm, <Trans>Add muted words and tags</Trans>
a.italic, </Text>
a.leading_snug, <Text style={[a.pb_lg, 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 Posts can be muted based on their text, their tags, or both.
it can result in no posts being shown.
</Trans> </Trans>
</Text> </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> </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> </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>
{children} {tabBarAnchor}
</Animated.View> <View style={[pal.view, pal.border, styles.bar, styles.tabBar]}>
{children}
</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,42 +75,60 @@ export function ViewHeader({
return ( return (
<Container hideOnScroll={hideOnScroll || false} showBorder={showBorder}> <Container hideOnScroll={hideOnScroll || false} showBorder={showBorder}>
{showBackButton ? ( <View style={{flex: 1}}>
<TouchableOpacity <View style={{flexDirection: 'row', alignItems: 'center'}}>
testID="viewHeaderDrawerBtn" {showBackButton ? (
onPress={canGoBack ? onPressBack : onPressMenu} <TouchableOpacity
hitSlop={BACK_HITSLOP} testID="viewHeaderDrawerBtn"
style={canGoBack ? styles.backBtn : styles.backBtnWide} onPress={canGoBack ? onPressBack : onPressMenu}
accessibilityRole="button" hitSlop={BACK_HITSLOP}
accessibilityLabel={canGoBack ? _(msg`Back`) : _(msg`Menu`)} style={canGoBack ? styles.backBtn : styles.backBtnWide}
accessibilityHint={ accessibilityRole="button"
canGoBack ? '' : _(msg`Access navigation links and settings`) accessibilityLabel={canGoBack ? _(msg`Back`) : _(msg`Menu`)}
}> accessibilityHint={
{canGoBack ? ( canGoBack ? '' : _(msg`Access navigation links and settings`)
<FontAwesomeIcon }>
size={18} {canGoBack ? (
icon="angle-left" <FontAwesomeIcon
style={[styles.backIcon, pal.text]} size={18}
/> icon="angle-left"
) : !isTablet ? ( style={[styles.backIcon, pal.text]}
<FontAwesomeIcon />
size={18} ) : !isTablet ? (
icon="bars" <FontAwesomeIcon
style={[styles.backIcon, pal.textLight]} size={18}
/> icon="bars"
style={[styles.backIcon, pal.textLight]}
/>
) : null}
</TouchableOpacity>
) : null} ) : null}
</TouchableOpacity> <View style={styles.titleContainer} pointerEvents="none">
) : null} <Text type="title" style={[pal.text, styles.title]}>
<View style={styles.titleContainer} pointerEvents="none"> {title}
<Text type="title" style={[pal.text, styles.title]}> </Text>
{title} </View>
</Text> {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> </View>
{renderButton ? (
renderButton()
) : showBackButton ? (
<View style={canGoBack ? styles.backBtn : styles.backBtnWide} />
) : null}
</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"