Merge branch 'main' into hailey/onboarding-profile

# Conflicts:
#	package.json
#	src/screens/Onboarding/StepTopicalFeeds.tsx
#	yarn.lock
This commit is contained in:
Hailey
2024-03-07 21:09:50 -08:00
227 changed files with 16572 additions and 8323 deletions
+23 -15
View File
@@ -43,8 +43,12 @@ import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unre
import * as persisted from '#/state/persisted'
import {Splash} from '#/Splash'
import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
import {StatusBar} from 'expo-status-bar'
import {isAndroid} from 'platform/detection'
SplashScreen.preventAutoHideAsync()
@@ -53,6 +57,7 @@ function InnerApp() {
const {resumeSession} = useSessionApi()
const theme = useColorModeTheme()
const {_} = useLingui()
useIntentHandler()
// init
useEffect(() => {
@@ -67,26 +72,29 @@ function InnerApp() {
return (
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
{isAndroid && <StatusBar />}
<Alf theme={theme}>
<Splash isReady={!isInitialLoad}>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={theme}>
{/* All components should be within this provider */}
<RootSiblingParent>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootSiblingParent>
</ThemeProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
<StatsigProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={theme}>
{/* All components should be within this provider */}
<RootSiblingParent>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootSiblingParent>
</ThemeProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</StatsigProvider>
</React.Fragment>
</Splash>
</Alf>
+20 -15
View File
@@ -32,11 +32,14 @@ import {
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import * as persisted from '#/state/persisted'
import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
function InnerApp() {
const {isInitialLoad, currentAccount} = useSession()
const {resumeSession} = useSessionApi()
const theme = useColorModeTheme()
useIntentHandler()
// init
useEffect(() => {
@@ -52,21 +55,23 @@ function InnerApp() {
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={theme}>
{/* All components should be within this provider */}
<RootSiblingParent>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</RootSiblingParent>
<ToastContainer />
</ThemeProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
<StatsigProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={theme}>
{/* All components should be within this provider */}
<RootSiblingParent>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</RootSiblingParent>
<ToastContainer />
</ThemeProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</StatsigProvider>
</React.Fragment>
</Alf>
)
+33 -6
View File
@@ -71,12 +71,14 @@ import {AppPasswords} from 'view/screens/AppPasswords'
import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
import {SavedFeeds} from 'view/screens/SavedFeeds'
import {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed'
import {PreferencesFollowingFeed} from 'view/screens/PreferencesFollowingFeed'
import {PreferencesThreads} from 'view/screens/PreferencesThreads'
import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds'
import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
import {msg} from '@lingui/macro'
import {i18n, MessageDescriptor} from '@lingui/core'
import HashtagScreen from '#/screens/Hashtag'
import {logEvent} from './lib/statsig/statsig'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@@ -242,9 +244,12 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
options={{title: title(msg`Edit My Feeds`), requireAuth: true}}
/>
<Stack.Screen
name="PreferencesHomeFeed"
getComponent={() => PreferencesHomeFeed}
options={{title: title(msg`Home Feed Preferences`), requireAuth: true}}
name="PreferencesFollowingFeed"
getComponent={() => PreferencesFollowingFeed}
options={{
title: title(msg`Following Feed Preferences`),
requireAuth: true,
}}
/>
<Stack.Screen
name="PreferencesThreads"
@@ -259,6 +264,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
requireAuth: true,
}}
/>
<Stack.Screen
name="Hashtag"
getComponent={() => HashtagScreen}
options={{title: title(msg`Hashtag`)}}
/>
</>
)
}
@@ -457,7 +467,8 @@ const FlatNavigator = () => {
*/
const LINKING = {
prefixes: ['bsky://', 'https://bsky.app'],
// TODO figure out what we are going to use
prefixes: ['bsky://', 'bluesky://', 'https://bsky.app'],
getPathFromState(state: State) {
// find the current node in the navigation tree
@@ -476,6 +487,18 @@ const LINKING = {
getStateFromPath(path: string) {
const [name, params] = router.matchPath(path)
// Any time we receive a url that starts with `intent/` we want to ignore it here. It will be handled in the
// intent handler hook. We should check for the trailing slash, because if there isn't one then it isn't a valid
// intent
// 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)
}
if (isNative) {
if (name === 'Search') {
return buildStateObject('SearchTab', 'Search', params)
@@ -494,7 +517,8 @@ const LINKING = {
},
])
} else {
return buildStateObject('Flat', name, params)
const res = buildStateObject('Flat', name, params)
return res
}
},
}
@@ -626,11 +650,14 @@ function logModuleInitTime() {
return
}
didInit = true
const initMs = Math.round(
// @ts-ignore Emitted by Metro in the bundle prelude
performance.now() - global.__BUNDLE_START_TIME__,
)
console.log(`Time to first paint: ${initMs} ms`)
logEvent('init', initMs)
if (__DEV__) {
// This log is noisy, so keep false committed
const shouldLog = false
+8 -7
View File
@@ -21,7 +21,6 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
import Svg, {Path, SvgProps} from 'react-native-svg'
import {isAndroid} from '#/platform/detection'
import {useThemePrefs} from 'state/shell'
import {Logotype} from '#/view/icons/Logotype'
// @ts-ignore
@@ -75,10 +74,8 @@ export function Splash(props: React.PropsWithChildren<Props>) {
isLayoutReady &&
reduceMotion !== undefined
const {colorMode} = useThemePrefs()
const colorScheme = useColorScheme()
const themeName = colorMode === 'system' ? colorScheme : colorMode
const isDarkMode = themeName === 'dark'
const isDarkMode = colorScheme === 'dark'
const logoAnimation = useAnimatedStyle(() => {
return {
@@ -184,6 +181,8 @@ export function Splash(props: React.PropsWithChildren<Props>) {
const logoAnimations =
reduceMotion === true ? reducedLogoAnimation : logoAnimation
// special off-spec color for dark mode
const logoBg = isDarkMode ? '#0F1824' : '#fff'
return (
<View style={{flex: 1}} onLayout={onLayout}>
@@ -235,7 +234,7 @@ export function Splash(props: React.PropsWithChildren<Props>) {
},
]}>
<AnimatedLogo
fill="#fff"
fill={logoBg}
style={[{opacity: 0}, logoAnimations]}
/>
</Animated.View>
@@ -256,14 +255,16 @@ export function Splash(props: React.PropsWithChildren<Props>) {
transform: [{translateY: -(insets.top / 2)}, {scale: 0.1}], // scale from 1000px to 100px
},
]}>
<AnimatedLogo fill="#fff" style={[logoAnimations]} />
<AnimatedLogo fill={logoBg} style={[logoAnimations]} />
</Animated.View>
}>
{!isAnimationComplete && (
<View
style={[
StyleSheet.absoluteFillObject,
{backgroundColor: '#fff'},
{
backgroundColor: logoBg,
},
]}
/>
)}
+57 -1
View File
@@ -1,3 +1,4 @@
import {web, native} from '#/alf/util/platform'
import * as tokens from '#/alf/tokens'
export const atoms = {
@@ -113,6 +114,9 @@ export const atoms = {
flex_wrap: {
flexWrap: 'wrap',
},
flex_0: {
flex: web('0 0 auto') || (native(0) as number),
},
flex_1: {
flex: 1,
},
@@ -122,6 +126,9 @@ export const atoms = {
flex_shrink: {
flexShrink: 1,
},
justify_start: {
justifyContent: 'flex-start',
},
justify_center: {
justifyContent: 'center',
},
@@ -140,10 +147,31 @@ export const atoms = {
align_end: {
alignItems: 'flex-end',
},
self_auto: {
alignSelf: 'auto',
},
self_start: {
alignSelf: 'flex-start',
},
self_end: {
alignSelf: 'flex-end',
},
self_center: {
alignSelf: 'center',
},
self_stretch: {
alignSelf: 'stretch',
},
self_baseline: {
alignSelf: 'baseline',
},
/*
* Text
*/
text_left: {
textAlign: 'left',
},
text_center: {
textAlign: 'center',
},
@@ -152,53 +180,75 @@ export const atoms = {
},
text_2xs: {
fontSize: tokens.fontSize._2xs,
letterSpacing: 0.25,
},
text_xs: {
fontSize: tokens.fontSize.xs,
letterSpacing: 0.25,
},
text_sm: {
fontSize: tokens.fontSize.sm,
letterSpacing: 0.25,
},
text_md: {
fontSize: tokens.fontSize.md,
letterSpacing: 0.25,
},
text_lg: {
fontSize: tokens.fontSize.lg,
letterSpacing: 0.25,
},
text_xl: {
fontSize: tokens.fontSize.xl,
letterSpacing: 0.25,
},
text_2xl: {
fontSize: tokens.fontSize._2xl,
letterSpacing: 0.25,
},
text_3xl: {
fontSize: tokens.fontSize._3xl,
letterSpacing: 0.25,
},
text_4xl: {
fontSize: tokens.fontSize._4xl,
letterSpacing: 0.25,
},
text_5xl: {
fontSize: tokens.fontSize._5xl,
letterSpacing: 0.25,
},
leading_tight: {
lineHeight: 1.15,
},
leading_snug: {
lineHeight: 1.25,
lineHeight: 1.3,
},
leading_normal: {
lineHeight: 1.5,
},
tracking_normal: {
letterSpacing: 0,
},
tracking_wide: {
letterSpacing: 0.25,
},
font_normal: {
fontWeight: tokens.fontWeight.normal,
},
font_bold: {
fontWeight: tokens.fontWeight.semibold,
},
italic: {
fontStyle: 'italic',
},
/*
* Border
*/
border_0: {
borderWidth: 0,
},
border: {
borderWidth: 1,
},
@@ -208,6 +258,12 @@ export const atoms = {
border_b: {
borderBottomWidth: 1,
},
border_l: {
borderLeftWidth: 1,
},
border_r: {
borderRightWidth: 1,
},
/*
* Shadow
+1 -1
View File
@@ -17,7 +17,7 @@ const breakpoints: {
[key: string]: number
} = {
gtMobile: 800,
gtTablet: 1200,
gtTablet: 1300,
}
function getActiveBreakpoints({width}: {width: number}) {
const active: (keyof typeof breakpoints)[] = Object.keys(breakpoints).filter(
+73 -14
View File
@@ -1,6 +1,7 @@
import * as tokens from '#/alf/tokens'
import type {Mutable} from '#/alf/types'
import {atoms} from '#/alf/atoms'
import {BLUE_HUE, GREEN_HUE, RED_HUE} from '#/alf/util/colorGeneration'
export type ThemeName = 'light' | 'dim' | 'dark'
export type ReadonlyTheme = typeof light
@@ -132,21 +133,63 @@ export const darkPalette: Palette = {
export const dimPalette: Palette = {
...darkPalette,
black: tokens.color.gray_1000,
black: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[0]}%)`,
contrast_25: tokens.color.gray_975,
contrast_50: tokens.color.gray_950,
contrast_100: tokens.color.gray_900,
contrast_200: tokens.color.gray_800,
contrast_300: tokens.color.gray_700,
contrast_400: tokens.color.gray_600,
contrast_500: tokens.color.gray_500,
contrast_600: tokens.color.gray_400,
contrast_700: tokens.color.gray_300,
contrast_800: tokens.color.gray_200,
contrast_900: tokens.color.gray_100,
contrast_950: tokens.color.gray_50,
contrast_975: tokens.color.gray_25,
contrast_25: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[1]}%)`,
contrast_50: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[2]}%)`,
contrast_100: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[3]}%)`,
contrast_200: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[4]}%)`,
contrast_300: `hsl(${BLUE_HUE}, 24%, ${tokens.dimScale[5]}%)`,
contrast_400: `hsl(${BLUE_HUE}, 24%, ${tokens.dimScale[6]}%)`,
contrast_500: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[7]}%)`,
contrast_600: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[8]}%)`,
contrast_700: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[9]}%)`,
contrast_800: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[10]}%)`,
contrast_900: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[11]}%)`,
contrast_950: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[12]}%)`,
contrast_975: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[13]}%)`,
primary_25: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[13]}%)`,
primary_50: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[12]}%)`,
primary_100: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[11]}%)`,
primary_200: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[10]}%)`,
primary_300: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[9]}%)`,
primary_400: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[8]}%)`,
primary_500: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[7]}%)`,
primary_600: `hsl(${BLUE_HUE}, 95%, ${tokens.dimScale[6]}%)`,
primary_700: `hsl(${BLUE_HUE}, 90%, ${tokens.dimScale[5]}%)`,
primary_800: `hsl(${BLUE_HUE}, 82%, ${tokens.dimScale[4]}%)`,
primary_900: `hsl(${BLUE_HUE}, 70%, ${tokens.dimScale[3]}%)`,
primary_950: `hsl(${BLUE_HUE}, 60%, ${tokens.dimScale[2]}%)`,
primary_975: `hsl(${BLUE_HUE}, 50%, ${tokens.dimScale[1]}%)`,
positive_25: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[13]}%)`,
positive_50: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[12]}%)`,
positive_100: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[11]}%)`,
positive_200: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[10]}%)`,
positive_300: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[9]}%)`,
positive_400: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[8]}%)`,
positive_500: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[7]}%)`,
positive_600: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[6]}%)`,
positive_700: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[5]}%)`,
positive_800: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[4]}%)`,
positive_900: `hsl(${GREEN_HUE}, 70%, ${tokens.dimScale[3]}%)`,
positive_950: `hsl(${GREEN_HUE}, 60%, ${tokens.dimScale[2]}%)`,
positive_975: `hsl(${GREEN_HUE}, 50%, ${tokens.dimScale[1]}%)`,
negative_25: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[13]}%)`,
negative_50: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[12]}%)`,
negative_100: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[11]}%)`,
negative_200: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[10]}%)`,
negative_300: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[9]}%)`,
negative_400: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[8]}%)`,
negative_500: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[7]}%)`,
negative_600: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[6]}%)`,
negative_700: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[5]}%)`,
negative_800: `hsl(${RED_HUE}, 88%, ${tokens.dimScale[4]}%)`,
negative_900: `hsl(${RED_HUE}, 84%, ${tokens.dimScale[3]}%)`,
negative_950: `hsl(${RED_HUE}, 80%, ${tokens.dimScale[2]}%)`,
negative_975: `hsl(${RED_HUE}, 70%, ${tokens.dimScale[1]}%)`,
} as const
export const light = {
@@ -325,6 +368,7 @@ export const dark: Theme = {
export const dim: Theme = {
...dark,
name: 'dim',
palette: dimPalette,
atoms: {
...dark.atoms,
text: {
@@ -393,5 +437,20 @@ export const dim: Theme = {
border_contrast_high: {
borderColor: dimPalette.contrast_300,
},
shadow_sm: {
...atoms.shadow_sm,
shadowOpacity: 0.7,
shadowColor: `hsl(${BLUE_HUE}, 28%, 6%)`,
},
shadow_md: {
...atoms.shadow_md,
shadowOpacity: 0.7,
shadowColor: `hsl(${BLUE_HUE}, 28%, 6%)`,
},
shadow_lg: {
...atoms.shadow_lg,
shadowOpacity: 0.7,
shadowColor: `hsl(${BLUE_HUE}, 28%, 6%)`,
},
},
}
+25 -18
View File
@@ -1,25 +1,32 @@
const BLUE_HUE = 211
const RED_HUE = 346
const GREEN_HUE = 152
import {
BLUE_HUE,
RED_HUE,
GREEN_HUE,
generateScale,
} from '#/alf/util/colorGeneration'
export const scale = generateScale(6, 100)
// dim shifted 6% lighter
export const dimScale = generateScale(12, 100)
export const color = {
trueBlack: '#000000',
gray_0: `hsl(${BLUE_HUE}, 20%, 100%)`,
gray_25: `hsl(${BLUE_HUE}, 20%, 97%)`,
gray_50: `hsl(${BLUE_HUE}, 20%, 95%)`,
gray_100: `hsl(${BLUE_HUE}, 20%, 90%)`,
gray_200: `hsl(${BLUE_HUE}, 20%, 80%)`,
gray_300: `hsl(${BLUE_HUE}, 20%, 70%)`,
gray_400: `hsl(${BLUE_HUE}, 20%, 60%)`,
gray_500: `hsl(${BLUE_HUE}, 20%, 50%)`,
gray_600: `hsl(${BLUE_HUE}, 24%, 42%)`,
gray_700: `hsl(${BLUE_HUE}, 24%, 34%)`,
gray_800: `hsl(${BLUE_HUE}, 28%, 26%)`,
gray_900: `hsl(${BLUE_HUE}, 28%, 18%)`,
gray_950: `hsl(${BLUE_HUE}, 28%, 10%)`,
gray_975: `hsl(${BLUE_HUE}, 28%, 7%)`,
gray_1000: `hsl(${BLUE_HUE}, 28%, 4%)`,
gray_0: `hsl(${BLUE_HUE}, 20%, ${scale[14]}%)`,
gray_25: `hsl(${BLUE_HUE}, 20%, ${scale[13]}%)`,
gray_50: `hsl(${BLUE_HUE}, 20%, ${scale[12]}%)`,
gray_100: `hsl(${BLUE_HUE}, 20%, ${scale[11]}%)`,
gray_200: `hsl(${BLUE_HUE}, 20%, ${scale[10]}%)`,
gray_300: `hsl(${BLUE_HUE}, 20%, ${scale[9]}%)`,
gray_400: `hsl(${BLUE_HUE}, 20%, ${scale[8]}%)`,
gray_500: `hsl(${BLUE_HUE}, 20%, ${scale[7]}%)`,
gray_600: `hsl(${BLUE_HUE}, 24%, ${scale[6]}%)`,
gray_700: `hsl(${BLUE_HUE}, 24%, ${scale[5]}%)`,
gray_800: `hsl(${BLUE_HUE}, 28%, ${scale[4]}%)`,
gray_900: `hsl(${BLUE_HUE}, 28%, ${scale[3]}%)`,
gray_950: `hsl(${BLUE_HUE}, 28%, ${scale[2]}%)`,
gray_975: `hsl(${BLUE_HUE}, 28%, ${scale[1]}%)`,
gray_1000: `hsl(${BLUE_HUE}, 28%, ${scale[0]}%)`,
blue_25: `hsl(${BLUE_HUE}, 99%, 97%)`,
blue_50: `hsl(${BLUE_HUE}, 99%, 95%)`,
+17
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
})
}
+13 -13
View File
@@ -1,25 +1,25 @@
import {Platform} from 'react-native'
import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection'
export function web(value: any) {
return Platform.select({
web: value,
})
if (isWeb) {
return value
}
}
export function ios(value: any) {
return Platform.select({
ios: value,
})
if (isIOS) {
return value
}
}
export function android(value: any) {
return Platform.select({
android: value,
})
if (isAndroid) {
return value
}
}
export function native(value: any) {
return Platform.select({
native: value,
})
if (isNative) {
return value
}
}
+10 -9
View File
@@ -13,7 +13,7 @@ export function useColorModeTheme(): ThemeName {
React.useLayoutEffect(() => {
const theme = getThemeName(colorScheme, colorMode, darkTheme)
updateDocument(theme)
updateSystemBackground(theme)
SystemUI.setBackgroundColorAsync(getBackgroundColor(theme))
}, [colorMode, colorScheme, darkTheme])
return React.useMemo(
@@ -42,23 +42,24 @@ function updateDocument(theme: ThemeName) {
if (isWeb && typeof window !== 'undefined') {
// @ts-ignore web only
const html = window.document.documentElement
// @ts-ignore web only
const meta = window.document.querySelector('meta[name="theme-color"]')
// remove any other color mode classes
html.className = html.className.replace(/(theme)--\w+/g, '')
html.classList.add(`theme--${theme}`)
// set color to 'theme-color' meta tag
meta?.setAttribute('content', getBackgroundColor(theme))
}
}
function updateSystemBackground(theme: ThemeName) {
function getBackgroundColor(theme: ThemeName): string {
switch (theme) {
case 'light':
SystemUI.setBackgroundColorAsync(light.atoms.bg.backgroundColor)
break
return light.atoms.bg.backgroundColor
case 'dark':
SystemUI.setBackgroundColorAsync(dark.atoms.bg.backgroundColor)
break
return dark.atoms.bg.backgroundColor
case 'dim':
SystemUI.setBackgroundColorAsync(dim.atoms.bg.backgroundColor)
break
return dim.atoms.bg.backgroundColor
}
}
+87 -63
View File
@@ -27,7 +27,7 @@ export type ButtonColor =
| 'gradient_sunset'
| 'gradient_nordic'
| 'gradient_bonfire'
export type ButtonSize = 'small' | 'large'
export type ButtonSize = 'tiny' | 'small' | 'large'
export type ButtonShape = 'round' | 'square' | 'default'
export type VariantProps = {
/**
@@ -48,25 +48,32 @@ export type VariantProps = {
shape?: ButtonShape
}
export type ButtonProps = React.PropsWithChildren<
Pick<PressableProps, 'disabled' | 'onPress'> &
AccessibilityProps &
VariantProps & {
testID?: string
label: string
style?: StyleProp<ViewStyle>
}
>
export type ButtonState = {
hovered: boolean
focused: boolean
pressed: boolean
disabled: boolean
}
export type ButtonContext = VariantProps & ButtonState
export type ButtonProps = Pick<
PressableProps,
'disabled' | 'onPress' | 'testID'
> &
AccessibilityProps &
VariantProps & {
testID?: string
label: string
style?: StyleProp<ViewStyle>
children:
| React.ReactNode
| string
| ((context: ButtonContext) => React.ReactNode | string)
}
export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
const Context = React.createContext<
VariantProps & {
hovered: boolean
focused: boolean
pressed: boolean
disabled: boolean
}
>({
const Context = React.createContext<VariantProps & ButtonState>({
hovered: false,
focused: false,
pressed: false,
@@ -158,7 +165,7 @@ export function Button({
if (!disabled) {
baseStyles.push(a.border, {
borderColor: tokens.color.blue_500,
borderColor: t.palette.primary_500,
})
hoverStyles.push(a.border, {
backgroundColor: light
@@ -167,7 +174,7 @@ export function Button({
})
} else {
baseStyles.push(a.border, {
borderColor: light ? tokens.color.blue_200 : tokens.color.blue_900,
borderColor: light ? t.palette.primary_200 : t.palette.primary_900,
})
}
} else if (variant === 'ghost') {
@@ -184,20 +191,14 @@ export function Button({
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: light
? tokens.color.gray_50
: tokens.color.gray_900,
backgroundColor: t.palette.contrast_50,
})
hoverStyles.push({
backgroundColor: light
? tokens.color.gray_100
: tokens.color.gray_950,
backgroundColor: t.palette.contrast_100,
})
} else {
baseStyles.push({
backgroundColor: light
? tokens.color.gray_200
: tokens.color.gray_950,
backgroundColor: t.palette.contrast_200,
})
}
} else if (variant === 'outline') {
@@ -207,21 +208,19 @@ export function Button({
if (!disabled) {
baseStyles.push(a.border, {
borderColor: light ? tokens.color.gray_300 : tokens.color.gray_700,
borderColor: t.palette.contrast_300,
})
hoverStyles.push(a.border, t.atoms.bg_contrast_50)
hoverStyles.push(t.atoms.bg_contrast_50)
} else {
baseStyles.push(a.border, {
borderColor: light ? tokens.color.gray_200 : tokens.color.gray_800,
borderColor: t.palette.contrast_200,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push(t.atoms.bg)
hoverStyles.push({
backgroundColor: light
? tokens.color.gray_100
: tokens.color.gray_900,
backgroundColor: t.palette.contrast_100,
})
}
}
@@ -229,14 +228,14 @@ export function Button({
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: t.palette.negative_400,
backgroundColor: t.palette.negative_500,
})
hoverStyles.push({
backgroundColor: t.palette.negative_500,
backgroundColor: t.palette.negative_600,
})
} else {
baseStyles.push({
backgroundColor: t.palette.negative_600,
backgroundColor: t.palette.negative_700,
})
}
} else if (variant === 'outline') {
@@ -246,7 +245,7 @@ export function Button({
if (!disabled) {
baseStyles.push(a.border, {
borderColor: t.palette.negative_400,
borderColor: t.palette.negative_500,
})
hoverStyles.push(a.border, {
backgroundColor: light
@@ -266,7 +265,7 @@ export function Button({
hoverStyles.push({
backgroundColor: light
? t.palette.negative_100
: t.palette.negative_950,
: t.palette.negative_975,
})
}
}
@@ -277,6 +276,8 @@ export function Button({
baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_md)
} else if (size === 'small') {
baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm)
} else if (size === 'tiny') {
baseStyles.push({paddingVertical: 4}, a.px_sm, a.rounded_xs, a.gap_xs)
}
} else if (shape === 'round' || shape === 'square') {
if (size === 'large') {
@@ -287,12 +288,18 @@ export function Button({
}
} else if (size === 'small') {
baseStyles.push({height: 40, width: 40})
} else if (size === 'tiny') {
baseStyles.push({height: 20, width: 20})
}
if (shape === 'round') {
baseStyles.push(a.rounded_full)
} else if (shape === 'square') {
baseStyles.push(a.rounded_sm)
if (size === 'tiny') {
baseStyles.push(a.rounded_xs)
} else {
baseStyles.push(a.rounded_sm)
}
}
}
@@ -338,7 +345,7 @@ export function Button({
}
}, [variant, color])
const context = React.useMemo(
const context = React.useMemo<ButtonContext>(
() => ({
...state,
variant,
@@ -349,6 +356,8 @@ export function Button({
[state, variant, color, size, disabled],
)
const flattenedBaseStyles = flatten(baseStyles)
return (
<Pressable
role="button"
@@ -362,15 +371,14 @@ export function Button({
disabled: disabled || false,
}}
style={[
flatten(style),
a.flex_row,
a.align_center,
a.justify_center,
a.overflow_hidden,
a.justify_center,
...baseStyles,
flattenedBaseStyles,
...(state.hovered || state.pressed ? hoverStyles : []),
...(state.focused ? focusStyles : []),
flatten(style),
]}
onPressIn={onPressIn}
onPressOut={onPressOut}
@@ -379,21 +387,31 @@ export function Button({
onFocus={onFocus}
onBlur={onBlur}>
{variant === 'gradient' && (
<LinearGradient
colors={
state.hovered || state.pressed || state.focused
? gradientHoverColors
: gradientColors
}
locations={gradientLocations}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[a.absolute, a.inset_0]}
/>
<View
style={[
a.absolute,
a.inset_0,
a.overflow_hidden,
{borderRadius: flattenedBaseStyles.borderRadius},
]}>
<LinearGradient
colors={
state.hovered || state.pressed || state.focused
? gradientHoverColors
: gradientColors
}
locations={gradientLocations}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[a.absolute, a.inset_0]}
/>
</View>
)}
<Context.Provider value={context}>
{typeof children === 'string' ? (
<ButtonText>{children}</ButtonText>
) : typeof children === 'function' ? (
children(context)
) : (
children
)}
@@ -435,31 +453,31 @@ export function useSharedButtonTextStyles() {
if (variant === 'solid' || variant === 'gradient') {
if (!disabled) {
baseStyles.push({
color: light ? tokens.color.gray_700 : tokens.color.gray_100,
color: t.palette.contrast_700,
})
} else {
baseStyles.push({
color: light ? tokens.color.gray_400 : tokens.color.gray_700,
color: t.palette.contrast_400,
})
}
} else if (variant === 'outline') {
if (!disabled) {
baseStyles.push({
color: light ? tokens.color.gray_600 : tokens.color.gray_300,
color: t.palette.contrast_600,
})
} else {
baseStyles.push({
color: light ? tokens.color.gray_400 : tokens.color.gray_700,
color: t.palette.contrast_300,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push({
color: light ? tokens.color.gray_600 : tokens.color.gray_300,
color: t.palette.contrast_600,
})
} else {
baseStyles.push({
color: light ? tokens.color.gray_400 : tokens.color.gray_600,
color: t.palette.contrast_300,
})
}
}
@@ -493,6 +511,8 @@ export function useSharedButtonTextStyles() {
if (size === 'large') {
baseStyles.push(a.text_md, android({paddingBottom: 1}))
} else if (size === 'tiny') {
baseStyles.push(a.text_xs, android({paddingBottom: 1}))
} else {
baseStyles.push(a.text_sm, android({paddingBottom: 1}))
}
@@ -514,9 +534,11 @@ export function ButtonText({children, style, ...rest}: ButtonTextProps) {
export function ButtonIcon({
icon: Comp,
position,
size: iconSize,
}: {
icon: React.ComponentType<SVGIconProps>
position?: 'left' | 'right'
size?: SVGIconProps['size']
}) {
const {size, disabled} = useButtonContext()
const textStyles = useSharedButtonTextStyles()
@@ -532,7 +554,9 @@ export function ButtonIcon({
},
]}>
<Comp
size={size === 'large' ? 'md' : 'sm'}
size={
iconSize ?? (size === 'large' ? 'md' : size === 'tiny' ? 'xs' : 'sm')
}
style={[{color: textStyles.color, pointerEvents: 'none'}]}
/>
</View>
+23 -9
View File
@@ -1,7 +1,11 @@
import React from 'react'
import {useDialogStateContext} from '#/state/dialogs'
import {DialogContextProps, DialogControlProps} from '#/components/Dialog/types'
import {
DialogContextProps,
DialogControlRefProps,
DialogOuterProps,
} from '#/components/Dialog/types'
export const Context = React.createContext<DialogContextProps>({
close: () => {},
@@ -11,13 +15,14 @@ export function useDialogContext() {
return React.useContext(Context)
}
export function useDialogControl() {
export function useDialogControl(): DialogOuterProps['control'] {
const id = React.useId()
const control = React.useRef<DialogControlProps>({
const control = React.useRef<DialogControlRefProps>({
open: () => {},
close: () => {},
})
const {activeDialogs} = useDialogStateContext()
const {activeDialogs, openDialogs} = useDialogStateContext()
const isOpen = openDialogs.includes(id)
React.useEffect(() => {
activeDialogs.current.set(id, control)
@@ -27,9 +32,18 @@ export function useDialogControl() {
}
}, [id, activeDialogs])
return {
ref: control,
open: () => control.current.open(),
close: () => control.current.close(),
}
return React.useMemo<DialogOuterProps['control']>(
() => ({
id,
ref: control,
isOpen,
open: () => {
control.current.open()
},
close: cb => {
control.current.close(cb)
},
}),
[id, control, isOpen],
)
}
+147 -62
View File
@@ -1,16 +1,21 @@
import React, {useImperativeHandle} from 'react'
import {View, Dimensions} from 'react-native'
import {View, Dimensions, Keyboard, Pressable} from 'react-native'
import BottomSheet, {
BottomSheetBackdrop,
BottomSheetBackdropProps,
BottomSheetScrollView,
BottomSheetTextInput,
BottomSheetView,
useBottomSheet,
WINDOW_HEIGHT,
} from '@gorhom/bottom-sheet'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import Animated, {useAnimatedStyle} from 'react-native-reanimated'
import {useTheme, atoms as a} from '#/alf'
import {useTheme, atoms as a, flatten} from '#/alf'
import {Portal} from '#/components/Portal'
import {createInput} from '#/components/forms/TextField'
import {logger} from '#/logger'
import {useDialogStateControlContext} from '#/state/dialogs'
import {
DialogOuterProps,
@@ -24,6 +29,47 @@ export * from '#/components/Dialog/types'
// @ts-ignore
export const Input = createInput(BottomSheetTextInput)
function Backdrop(props: BottomSheetBackdropProps) {
const t = useTheme()
const bottomSheet = useBottomSheet()
const animatedStyle = useAnimatedStyle(() => {
const opacity =
(Math.abs(WINDOW_HEIGHT - props.animatedPosition.value) - 50) / 1000
return {
opacity: Math.min(Math.max(opacity, 0), 0.55),
}
})
const onPress = React.useCallback(() => {
bottomSheet.close()
}, [bottomSheet])
return (
<Animated.View
style={[
t.atoms.bg_contrast_300,
{
top: 0,
left: 0,
right: 0,
bottom: 0,
position: 'absolute',
},
animatedStyle,
]}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Dialog backdrop"
accessibilityHint="Press the backdrop to close the dialog"
style={{flex: 1}}
onPress={onPress}
/>
</Animated.View>
)
}
export function Outer({
children,
control,
@@ -35,12 +81,32 @@ export function Outer({
const sheetOptions = nativeOptions?.sheet || {}
const hasSnapPoints = !!sheetOptions.snapPoints
const insets = useSafeAreaInsets()
const closeCallback = React.useRef<() => void>()
const {setDialogIsOpen} = useDialogStateControlContext()
const open = React.useCallback<DialogControlProps['open']>((i = 0) => {
sheet.current?.snapToIndex(i)
}, [])
/*
* Used to manage open/closed, but index is otherwise handled internally by `BottomSheet`
*/
const [openIndex, setOpenIndex] = React.useState(-1)
const close = React.useCallback(() => {
/*
* `openIndex` is the index of the snap point to open the bottom sheet to. If >0, the bottom sheet is open.
*/
const isOpen = openIndex > -1
const open = React.useCallback<DialogControlProps['open']>(
({index} = {}) => {
setDialogIsOpen(control.id, true)
// can be set to any index of `snapPoints`, but `0` is the first i.e. "open"
setOpenIndex(index || 0)
},
[setOpenIndex, setDialogIsOpen, control.id],
)
const close = React.useCallback<DialogControlProps['close']>(cb => {
if (cb && typeof cb === 'function') {
closeCallback.current = cb
}
sheet.current?.close()
}, [])
@@ -53,81 +119,91 @@ export function Outer({
[open, close],
)
const onChange = React.useCallback(
(index: number) => {
if (index === -1) {
onClose?.()
}
},
[onClose],
)
const onCloseInner = React.useCallback(() => {
Keyboard.dismiss()
try {
closeCallback.current?.()
} catch (e: any) {
logger.error(`Dialog closeCallback failed`, {
message: e.message,
})
} finally {
closeCallback.current = undefined
}
setDialogIsOpen(control.id, false)
onClose?.()
setOpenIndex(-1)
}, [control.id, onClose, setDialogIsOpen])
const context = React.useMemo(() => ({close}), [close])
return (
<Portal>
<BottomSheet
enableDynamicSizing={!hasSnapPoints}
enablePanDownToClose
keyboardBehavior="interactive"
android_keyboardInputMode="adjustResize"
keyboardBlurBehavior="restore"
topInset={insets.top}
{...sheetOptions}
ref={sheet}
index={-1}
backgroundStyle={{backgroundColor: 'transparent'}}
backdropComponent={props => (
<BottomSheetBackdrop
opacity={0.4}
appearsOnIndex={0}
disappearsOnIndex={-1}
{...props}
/>
)}
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
handleStyle={{display: 'none'}}
onChange={onChange}>
<Context.Provider value={context}>
<View
style={[
a.absolute,
a.inset_0,
t.atoms.bg,
{
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
height: Dimensions.get('window').height * 2,
},
]}
/>
{children}
</Context.Provider>
</BottomSheet>
</Portal>
isOpen && (
<Portal>
<View
// iOS
accessibilityViewIsModal
// Android
importantForAccessibility="yes"
style={[a.absolute, a.inset_0]}>
<BottomSheet
enableDynamicSizing={!hasSnapPoints}
enablePanDownToClose
keyboardBehavior="interactive"
android_keyboardInputMode="adjustResize"
keyboardBlurBehavior="restore"
topInset={insets.top}
{...sheetOptions}
snapPoints={sheetOptions.snapPoints || ['100%']}
ref={sheet}
index={openIndex}
backgroundStyle={{backgroundColor: 'transparent'}}
backdropComponent={Backdrop}
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
handleStyle={{display: 'none'}}
onClose={onCloseInner}>
<Context.Provider value={context}>
<View
style={[
a.absolute,
a.inset_0,
t.atoms.bg,
{
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
height: Dimensions.get('window').height * 2,
},
]}
/>
{children}
</Context.Provider>
</BottomSheet>
</View>
</Portal>
)
)
}
// TODO a11y props here, or is that handled by the sheet?
export function Inner(props: DialogInnerProps) {
export function Inner({children, style}: DialogInnerProps) {
const insets = useSafeAreaInsets()
return (
<BottomSheetView
style={[
a.p_lg,
a.p_xl,
{
paddingTop: 40,
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
paddingBottom: insets.bottom + a.pb_5xl.paddingBottom,
},
flatten(style),
]}>
{props.children}
{children}
</BottomSheetView>
)
}
export function ScrollableInner(props: DialogInnerProps) {
export function ScrollableInner({children, style}: DialogInnerProps) {
const insets = useSafeAreaInsets()
return (
<BottomSheetScrollView
@@ -136,13 +212,15 @@ export function ScrollableInner(props: DialogInnerProps) {
style={[
a.flex_1, // main diff is this
a.p_xl,
a.h_full,
{
paddingTop: 40,
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
},
flatten(style),
]}>
{props.children}
{children}
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
</BottomSheetScrollView>
)
@@ -150,8 +228,15 @@ export function ScrollableInner(props: DialogInnerProps) {
export function Handle() {
const t = useTheme()
const onTouchStart = React.useCallback(() => {
Keyboard.dismiss()
}, [])
return (
<View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}>
<View
style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}
onTouchStart={onTouchStart}>
<View
style={[
a.rounded_sm,
+37 -28
View File
@@ -5,11 +5,14 @@ import Animated, {FadeInDown, FadeIn} from 'react-native-reanimated'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useTheme, atoms as a, useBreakpoints, web} from '#/alf'
import {useTheme, atoms as a, useBreakpoints, web, flatten} from '#/alf'
import {Portal} from '#/components/Portal'
import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types'
import {Context} from '#/components/Dialog/context'
import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {useDialogStateControlContext} from '#/state/dialogs'
export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
export * from '#/components/Dialog/types'
@@ -18,27 +21,30 @@ export {Input} from '#/components/forms/TextField'
const stopPropagation = (e: any) => e.stopPropagation()
export function Outer({
children,
control,
onClose,
children,
}: React.PropsWithChildren<DialogOuterProps>) {
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const [isOpen, setIsOpen] = React.useState(false)
const [isVisible, setIsVisible] = React.useState(true)
const {setDialogIsOpen} = useDialogStateControlContext()
const open = React.useCallback(() => {
setIsOpen(true)
}, [setIsOpen])
setDialogIsOpen(control.id, true)
}, [setIsOpen, setDialogIsOpen, control.id])
const close = React.useCallback(async () => {
setIsVisible(false)
await new Promise(resolve => setTimeout(resolve, 150))
setIsOpen(false)
setIsVisible(true)
setDialogIsOpen(control.id, false)
onClose?.()
}, [onClose, setIsOpen])
}, [onClose, setIsOpen, setDialogIsOpen, control.id])
useImperativeHandle(
control.ref,
@@ -147,7 +153,7 @@ export function Inner({
a.rounded_md,
a.w_full,
a.border,
gtMobile ? a.p_xl : a.p_lg,
gtMobile ? a.p_2xl : a.p_xl,
t.atoms.bg,
{
maxWidth: 600,
@@ -156,7 +162,7 @@ export function Inner({
shadowOpacity: t.name === 'light' ? 0.1 : 0.4,
shadowRadius: 30,
},
...(Array.isArray(style) ? style : [style || {}]),
flatten(style),
]}>
{children}
</Animated.View>
@@ -170,25 +176,28 @@ export function Handle() {
return null
}
/**
* TODO(eric) unused rn
*/
// export function Close() {
// const {_} = useLingui()
// const t = useTheme()
// const {close} = useDialogContext()
// return (
// <View
// style={[
// a.absolute,
// a.z_10,
// {
// top: a.pt_lg.paddingTop,
// right: a.pr_lg.paddingRight,
// },
// ]}>
// <Button onPress={close} label={_(msg`Close active dialog`)}>
// </Button>
// </View>
// )
// }
export function Close() {
const {_} = useLingui()
const {close} = React.useContext(Context)
return (
<View
style={[
a.absolute,
a.z_10,
{
top: a.pt_md.paddingTop,
right: a.pr_md.paddingRight,
},
]}>
<Button
size="small"
variant="ghost"
color="secondary"
shape="round"
onPress={() => close()}
label={_(msg`Close active dialog`)}>
<ButtonIcon icon={X} size="md" />
</Button>
</View>
)
}
+34 -15
View File
@@ -1,24 +1,46 @@
import React from 'react'
import type {ViewStyle, AccessibilityProps} from 'react-native'
import type {AccessibilityProps} from 'react-native'
import {BottomSheetProps} from '@gorhom/bottom-sheet'
import {ViewStyleProp} from '#/alf'
type A11yProps = Required<AccessibilityProps>
export type DialogContextProps = {
close: () => void
/**
* Mutated by useImperativeHandle to provide a public API for controlling the
* dialog. The methods here will actually become the handlers defined within
* the `Dialog.Outer` component.
*/
export type DialogControlRefProps = {
open: (options?: DialogControlOpenOptions) => void
close: (callback?: () => void) => void
}
export type DialogControlProps = {
open: (index?: number) => void
close: () => void
/**
* The return type of the useDialogControl hook.
*/
export type DialogControlProps = DialogControlRefProps & {
id: string
ref: React.RefObject<DialogControlRefProps>
isOpen: boolean
}
export type DialogContextProps = {
close: DialogControlProps['close']
}
export type DialogControlOpenOptions = {
/**
* NATIVE ONLY
*
* Optional index of the snap point to open the bottom sheet to. Defaults to
* 0, which is the first snap point (i.e. "open").
*/
index?: number
}
export type DialogOuterProps = {
control: {
ref: React.RefObject<DialogControlProps>
open: (index?: number) => void
close: () => void
}
control: DialogControlProps
onClose?: () => void
nativeOptions?: {
sheet?: Omit<BottomSheetProps, 'children'>
@@ -26,10 +48,7 @@ export type DialogOuterProps = {
webOptions?: {}
}
type DialogInnerPropsBase<T> = React.PropsWithChildren<{
style?: ViewStyle
}> &
T
type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> & T
export type DialogInnerProps =
| DialogInnerPropsBase<{
label?: undefined
+97 -75
View File
@@ -1,9 +1,5 @@
import React from 'react'
import {
GestureResponderEvent,
Linking,
TouchableWithoutFeedback,
} from 'react-native'
import {GestureResponderEvent} from 'react-native'
import {
useLinkProps,
useNavigation,
@@ -13,7 +9,7 @@ import {sanitizeUrl} from '@braintree/sanitize-url'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {isWeb} from '#/platform/detection'
import {useTheme, web, flatten, TextStyleProp} from '#/alf'
import {useTheme, web, flatten, TextStyleProp, atoms as a} from '#/alf'
import {Button, ButtonProps} from '#/components/Button'
import {AllNavigatorParams, NavigationProp} from '#/lib/routes/types'
import {
@@ -23,7 +19,8 @@ import {
} from '#/lib/strings/url-helpers'
import {useModalControls} from '#/state/modals'
import {router} from '#/routes'
import {Text} from '#/components/Typography'
import {Text, TextProps} from '#/components/Typography'
import {useOpenLink} from 'state/preferences/in-app-browser'
/**
* Only available within a `Link`, since that inherits from `Button`.
@@ -35,6 +32,13 @@ type BaseLinkProps = Pick<
Parameters<typeof useLinkProps<AllNavigatorParams>>[0],
'to'
> & {
testID?: string
/**
* Label for a11y. Defaults to the href.
*/
label?: string
/**
* The React Navigation `StackAction` to perform when the link is pressed.
*/
@@ -45,14 +49,28 @@ type BaseLinkProps = Pick<
*
* Note: atm this only works for `InlineLink`s with a string child.
*/
warnOnMismatchingTextChild?: boolean
disableMismatchWarning?: boolean
/**
* Callback for when the link is pressed. Prevent default and return `false`
* to exit early and prevent navigation.
*
* DO NOT use this for navigation, that's what the `to` prop is for.
*/
onPress?: (e: GestureResponderEvent) => void | false
/**
* Web-only attribute. Sets `download` attr on web.
*/
download?: string
}
export function useLink({
to,
displayText,
action = 'push',
warnOnMismatchingTextChild,
disableMismatchWarning,
onPress: outerOnPress,
}: BaseLinkProps & {
displayText: string
}) {
@@ -63,11 +81,16 @@ export function useLink({
})
const isExternal = isExternalUrl(href)
const {openModal, closeModal} = useModalControls()
const openLink = useOpenLink()
const onPress = React.useCallback(
(e: GestureResponderEvent) => {
const exitEarlyIfFalse = outerOnPress?.(e)
if (exitEarlyIfFalse === false) return
const requiresWarning = Boolean(
warnOnMismatchingTextChild &&
!disableMismatchWarning &&
displayText &&
isExternal &&
linkRequiresWarning(href, displayText),
@@ -85,7 +108,7 @@ export function useLink({
e.preventDefault()
if (isExternal) {
Linking.openURL(href)
openLink(href)
} else {
/**
* A `GestureResponderEvent`, but cast to `any` to avoid using a bunch
@@ -103,7 +126,7 @@ export function useLink({
href.startsWith('http') ||
href.startsWith('mailto')
) {
Linking.openURL(href)
openLink(href)
} else {
closeModal() // close any active modals
@@ -124,14 +147,16 @@ export function useLink({
}
},
[
href,
isExternal,
warnOnMismatchingTextChild,
navigation,
action,
outerOnPress,
disableMismatchWarning,
displayText,
closeModal,
isExternal,
href,
openModal,
openLink,
closeModal,
action,
navigation,
],
)
@@ -142,17 +167,8 @@ export function useLink({
}
}
export type LinkProps = Omit<BaseLinkProps, 'warnOnMismatchingTextChild'> &
Omit<ButtonProps, 'style' | 'onPress' | 'disabled' | 'label'> & {
/**
* Label for a11y. Defaults to the href.
*/
label?: string
/**
* Web-only attribute. Sets `download` attr on web.
*/
download?: string
}
export type LinkProps = Omit<BaseLinkProps, 'disableMismatchWarning'> &
Omit<ButtonProps, 'onPress' | 'disabled' | 'label'>
/**
* A interactive element that renders as a `<a>` tag on the web. On mobile it
@@ -166,6 +182,7 @@ export function Link({
children,
to,
action = 'push',
onPress: outerOnPress,
download,
...rest
}: LinkProps) {
@@ -173,24 +190,26 @@ export function Link({
to,
displayText: typeof children === 'string' ? children : '',
action,
onPress: outerOnPress,
})
return (
<Button
label={href}
{...rest}
style={[a.justify_start, flatten(rest.style)]}
role="link"
accessibilityRole="link"
href={href}
onPress={onPress}
onPress={download ? undefined : onPress}
{...web({
hrefAttrs: {
target: isExternal ? 'blank' : undefined,
target: download ? undefined : isExternal ? 'blank' : undefined,
rel: isExternal ? 'noopener noreferrer' : undefined,
download,
},
dataSet: {
// default to no underline, apply this ourselves
// no underline, only `InlineLink` has underlines
noUnderline: '1',
},
})}>
@@ -200,21 +219,18 @@ export function Link({
}
export type InlineLinkProps = React.PropsWithChildren<
BaseLinkProps &
TextStyleProp & {
/**
* Label for a11y. Defaults to the href.
*/
label?: string
}
BaseLinkProps & TextStyleProp & Pick<TextProps, 'selectable'>
>
export function InlineLink({
children,
to,
action = 'push',
warnOnMismatchingTextChild,
disableMismatchWarning,
style,
onPress: outerOnPress,
download,
selectable,
...rest
}: InlineLinkProps) {
const t = useTheme()
@@ -223,52 +239,58 @@ export function InlineLink({
to,
displayText: stringChildren ? children : '',
action,
warnOnMismatchingTextChild,
disableMismatchWarning,
onPress: outerOnPress,
})
const {
state: hovered,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const {
state: pressed,
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
const flattenedStyle = flatten(style)
return (
<TouchableWithoutFeedback
accessibilityRole="button"
onPress={onPress}
<Text
selectable={selectable}
label={href}
{...rest}
style={[
{color: t.palette.primary_500},
(hovered || focused || pressed) && {
...web({outline: 0}),
textDecorationLine: 'underline',
textDecorationColor: flattenedStyle.color ?? t.palette.primary_500,
},
flattenedStyle,
]}
role="link"
onPress={download ? undefined : onPress}
onPressIn={onPressIn}
onPressOut={onPressOut}
onFocus={onFocus}
onBlur={onBlur}>
<Text
label={href}
{...rest}
style={[
{color: t.palette.primary_500},
(focused || pressed) && {
outline: 0,
textDecorationLine: 'underline',
textDecorationColor: t.palette.primary_500,
},
flatten(style),
]}
role="link"
accessibilityRole="link"
href={href}
{...web({
hrefAttrs: {
target: isExternal ? 'blank' : undefined,
rel: isExternal ? 'noopener noreferrer' : undefined,
},
dataSet: stringChildren
? {}
: {
// default to no underline, apply this ourselves
noUnderline: '1',
},
})}>
{children}
</Text>
</TouchableWithoutFeedback>
onBlur={onBlur}
onMouseEnter={onHoverIn}
onMouseLeave={onHoverOut}
accessibilityRole="link"
href={href}
{...web({
hrefAttrs: {
target: download ? undefined : isExternal ? 'blank' : undefined,
rel: isExternal ? 'noopener noreferrer' : undefined,
download,
},
dataSet: {
// default to no underline, apply this ourselves
noUnderline: '1',
},
})}>
{children}
</Text>
)
}
+249
View File
@@ -0,0 +1,249 @@
import React from 'react'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {View} from 'react-native'
import {CenteredView} from 'view/com/util/Views'
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, gtTablet} = 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 (
<CenteredView
style={[
a.flex_1,
a.align_center,
!gtMobile ? a.justify_between : a.gap_5xl,
t.atoms.border_contrast_low,
{paddingTop: 175, paddingBottom: 110},
]}
sideBorders={gtMobile}
topBorder={!gtTablet}>
{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>
</>
)}
</CenteredView>
)
}
+11 -2
View File
@@ -7,11 +7,12 @@ import Animated, {
withTiming,
} from 'react-native-reanimated'
import {atoms as a} from '#/alf'
import {atoms as a, useTheme, flatten} from '#/alf'
import {Props, useCommonSVGProps} from '#/components/icons/common'
import {Loader_Stroke2_Corner0_Rounded as Icon} from '#/components/icons/Loader'
export function Loader(props: Props) {
const t = useTheme()
const common = useCommonSVGProps(props)
const rotation = useSharedValue(0)
@@ -35,7 +36,15 @@ export function Loader(props: Props) {
{width: common.size, height: common.size},
animatedStyles,
]}>
<Icon {...props} style={[a.absolute, a.inset_0, props.style]} />
<Icon
{...props}
style={[
a.absolute,
a.inset_0,
t.atoms.text_contrast_high,
flatten(props.style),
]}
/>
</Animated.View>
)
}
+8
View File
@@ -0,0 +1,8 @@
import React from 'react'
import type {ContextType} from '#/components/Menu/types'
export const Context = React.createContext<ContextType>({
// @ts-ignore
control: null,
})
+190
View File
@@ -0,0 +1,190 @@
import React from 'react'
import {View, Pressable} from 'react-native'
import flattenReactChildren from 'react-keyed-flatten-children'
import {atoms as a, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Text} from '#/components/Typography'
import {Context} from '#/components/Menu/context'
import {
ContextType,
TriggerProps,
ItemProps,
GroupProps,
ItemTextProps,
ItemIconProps,
} from '#/components/Menu/types'
export {useDialogControl as useMenuControl} from '#/components/Dialog'
export function useMemoControlContext() {
return React.useContext(Context)
}
export function Root({
children,
control,
}: React.PropsWithChildren<{
control?: Dialog.DialogOuterProps['control']
}>) {
const defaultControl = Dialog.useDialogControl()
const context = React.useMemo<ContextType>(
() => ({
control: control || defaultControl,
}),
[control, defaultControl],
)
return <Context.Provider value={context}>{children}</Context.Provider>
}
export function Trigger({children, label}: TriggerProps) {
const {control} = React.useContext(Context)
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const {
state: pressed,
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
return children({
isNative: true,
control,
state: {
hovered: false,
focused,
pressed,
},
props: {
onPress: control.open,
onFocus,
onBlur,
onPressIn,
onPressOut,
accessibilityLabel: label,
},
})
}
export function Outer({children}: React.PropsWithChildren<{}>) {
const context = React.useContext(Context)
return (
<Dialog.Outer control={context.control}>
<Dialog.Handle />
{/* Re-wrap with context since Dialogs are portal-ed to root */}
<Context.Provider value={context}>
<Dialog.ScrollableInner label="Menu TODO">
<View style={[a.gap_lg]}>{children}</View>
<View style={{height: a.gap_lg.gap}} />
</Dialog.ScrollableInner>
</Context.Provider>
</Dialog.Outer>
)
}
export function Item({children, label, style, onPress, ...rest}: ItemProps) {
const t = useTheme()
const {control} = React.useContext(Context)
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const {
state: pressed,
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
return (
<Pressable
{...rest}
accessibilityHint=""
accessibilityLabel={label}
onPress={e => {
onPress(e)
if (!e.defaultPrevented) {
control?.close()
}
}}
onFocus={onFocus}
onBlur={onBlur}
onPressIn={onPressIn}
onPressOut={onPressOut}
style={[
a.flex_row,
a.align_center,
a.gap_sm,
a.px_md,
a.rounded_md,
a.border,
t.atoms.bg_contrast_25,
t.atoms.border_contrast_low,
{minHeight: 44, paddingVertical: 10},
style,
(focused || pressed) && [t.atoms.bg_contrast_50],
]}>
{children}
</Pressable>
)
}
export function ItemText({children, style}: ItemTextProps) {
const t = useTheme()
return (
<Text
numberOfLines={1}
ellipsizeMode="middle"
style={[
a.flex_1,
a.text_md,
a.font_bold,
t.atoms.text_contrast_medium,
{paddingTop: 3},
style,
]}>
{children}
</Text>
)
}
export function ItemIcon({icon: Comp}: ItemIconProps) {
const t = useTheme()
return <Comp size="lg" fill={t.atoms.text_contrast_medium.color} />
}
export function Group({children, style}: GroupProps) {
const t = useTheme()
return (
<View
style={[
a.rounded_md,
a.overflow_hidden,
a.border,
t.atoms.border_contrast_low,
style,
]}>
{flattenReactChildren(children).map((child, i) => {
return React.isValidElement(child) && child.type === Item ? (
<React.Fragment key={i}>
{i > 0 ? (
<View style={[a.border_b, t.atoms.border_contrast_low]} />
) : null}
{React.cloneElement(child, {
// @ts-ignore
style: {
borderRadius: 0,
borderWidth: 0,
},
})}
</React.Fragment>
) : null
})}
</View>
)
}
export function Divider() {
return null
}
+247
View File
@@ -0,0 +1,247 @@
import React from 'react'
import {View, Pressable} from 'react-native'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import * as Dialog from '#/components/Dialog'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {atoms as a, useTheme, flatten, web} from '#/alf'
import {Text} from '#/components/Typography'
import {
ContextType,
TriggerProps,
ItemProps,
GroupProps,
ItemTextProps,
ItemIconProps,
} from '#/components/Menu/types'
import {Context} from '#/components/Menu/context'
export function useMenuControl(): Dialog.DialogControlProps {
const id = React.useId()
const [isOpen, setIsOpen] = React.useState(false)
return React.useMemo(
() => ({
id,
ref: {current: null},
isOpen,
open() {
setIsOpen(true)
},
close() {
setIsOpen(false)
},
}),
[id, isOpen, setIsOpen],
)
}
export function useMemoControlContext() {
return React.useContext(Context)
}
export function Root({
children,
control,
}: React.PropsWithChildren<{
control?: Dialog.DialogOuterProps['control']
}>) {
const defaultControl = useMenuControl()
const context = React.useMemo<ContextType>(
() => ({
control: control || defaultControl,
}),
[control, defaultControl],
)
const onOpenChange = React.useCallback(
(open: boolean) => {
if (context.control.isOpen && !open) {
context.control.close()
} else if (!context.control.isOpen && open) {
context.control.open()
}
},
[context.control],
)
return (
<Context.Provider value={context}>
<DropdownMenu.Root
open={context.control.isOpen}
onOpenChange={onOpenChange}>
{children}
</DropdownMenu.Root>
</Context.Provider>
)
}
export function Trigger({children, label, style}: TriggerProps) {
const {control} = React.useContext(Context)
const {
state: hovered,
onIn: onMouseEnter,
onOut: onMouseLeave,
} = useInteractionState()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
return (
<DropdownMenu.Trigger asChild>
<Pressable
accessibilityHint=""
accessibilityLabel={label}
onFocus={onFocus}
onBlur={onBlur}
style={flatten([style, web({outline: 0})])}
onPointerDown={() => {
control.open()
}}
{...web({
onMouseEnter,
onMouseLeave,
})}>
{children({
isNative: false,
control,
state: {
hovered,
focused,
pressed: false,
},
props: {},
})}
</Pressable>
</DropdownMenu.Trigger>
)
}
export function Outer({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
return (
<DropdownMenu.Portal>
<DropdownMenu.Content sideOffset={5} loop aria-label="Test">
<View
style={[
a.rounded_sm,
a.p_xs,
t.name === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25,
t.atoms.shadow_md,
]}>
{children}
</View>
<DropdownMenu.Arrow
className="DropdownMenuArrow"
fill={
(t.name === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25)
.backgroundColor
}
/>
</DropdownMenu.Content>
</DropdownMenu.Portal>
)
}
export function Item({children, label, onPress, ...rest}: ItemProps) {
const t = useTheme()
const {control} = React.useContext(Context)
const {
state: hovered,
onIn: onMouseEnter,
onOut: onMouseLeave,
} = useInteractionState()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
return (
<DropdownMenu.Item asChild>
<Pressable
{...rest}
className="radix-dropdown-item"
accessibilityHint=""
accessibilityLabel={label}
onPress={e => {
onPress(e)
/**
* Ported forward from Radix
* @see https://www.radix-ui.com/primitives/docs/components/dropdown-menu#item
*/
if (!e.defaultPrevented) {
control.close()
}
}}
onFocus={onFocus}
onBlur={onBlur}
// need `flatten` here for Radix compat
style={flatten([
a.flex_row,
a.align_center,
a.gap_sm,
a.py_sm,
a.rounded_xs,
{minHeight: 32, paddingHorizontal: 10},
web({outline: 0}),
(hovered || focused) && [
web({outline: '0 !important'}),
t.name === 'light'
? t.atoms.bg_contrast_25
: t.atoms.bg_contrast_50,
],
])}
{...web({
onMouseEnter,
onMouseLeave,
})}>
{children}
</Pressable>
</DropdownMenu.Item>
)
}
export function ItemText({children, style}: ItemTextProps) {
const t = useTheme()
return (
<Text style={[a.flex_1, a.font_bold, t.atoms.text_contrast_high, style]}>
{children}
</Text>
)
}
export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
const t = useTheme()
return (
<Comp
size="md"
fill={t.atoms.text_contrast_medium.color}
style={[
position === 'left' && {
marginLeft: -2,
},
position === 'right' && {
marginRight: -2,
marginLeft: 12,
},
]}
/>
)
}
export function Group({children}: GroupProps) {
return children
}
export function Divider() {
const t = useTheme()
return (
<DropdownMenu.Separator
style={flatten([
a.my_xs,
t.atoms.bg_contrast_100,
{
height: 1,
},
])}
/>
)
}
+72
View File
@@ -0,0 +1,72 @@
import React from 'react'
import {GestureResponderEvent, PressableProps} from 'react-native'
import {Props as SVGIconProps} from '#/components/icons/common'
import * as Dialog from '#/components/Dialog'
import {TextStyleProp, ViewStyleProp} from '#/alf'
export type ContextType = {
control: Dialog.DialogOuterProps['control']
}
export type TriggerProps = ViewStyleProp & {
children(props: TriggerChildProps): React.ReactNode
label: string
}
export type TriggerChildProps =
| {
isNative: true
control: Dialog.DialogOuterProps['control']
state: {
/**
* Web only, `false` on native
*/
hovered: false
focused: boolean
pressed: boolean
}
/**
* We don't necessarily know what these will be spread on to, so we
* should add props one-by-one.
*
* On web, these properties are applied to a parent `Pressable`, so this
* object is empty.
*/
props: {
onPress: () => void
onFocus: () => void
onBlur: () => void
onPressIn: () => void
onPressOut: () => void
accessibilityLabel: string
}
}
| {
isNative: false
control: Dialog.DialogOuterProps['control']
state: {
hovered: boolean
focused: boolean
/**
* Native only, `false` on web
*/
pressed: false
}
props: {}
}
export type ItemProps = React.PropsWithChildren<
Omit<PressableProps, 'style'> &
ViewStyleProp & {
label: string
onPress: (e: GestureResponderEvent) => void
}
>
export type ItemTextProps = React.PropsWithChildren<TextStyleProp & {}>
export type ItemIconProps = React.PropsWithChildren<{
icon: React.ComponentType<SVGIconProps>
position?: 'left' | 'right'
}>
export type GroupProps = React.PropsWithChildren<ViewStyleProp & {}>
+2 -2
View File
@@ -41,7 +41,7 @@ export function Outer({
<Dialog.Inner
accessibilityLabelledBy={titleId}
accessibilityDescribedBy={descriptionId}
style={{width: 'auto', maxWidth: 400}}>
style={[{width: 'auto', maxWidth: 400}]}>
{children}
</Dialog.Inner>
</Context.Provider>
@@ -89,7 +89,7 @@ export function Cancel({
color="secondary"
size="small"
label={_(msg`Cancel`)}
onPress={close}>
onPress={() => close()}>
{children}
</Button>
)
+125 -32
View File
@@ -1,11 +1,15 @@
import React from 'react'
import {RichText as RichTextAPI, AppBskyRichtextFacet} from '@atproto/api'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {atoms as a, TextStyleProp} from '#/alf'
import {atoms as a, TextStyleProp, flatten, useTheme, web, native} from '#/alf'
import {InlineLink} from '#/components/Link'
import {Text} from '#/components/Typography'
import {Text, TextProps} from '#/components/Typography'
import {toShortUrl} from 'lib/strings/url-helpers'
import {getAgent} from '#/state/session'
import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
import {isNative} from '#/platform/detection'
import {useInteractionState} from '#/components/hooks/useInteractionState'
const WORD_WRAP = {wordWrap: 1}
@@ -15,34 +19,24 @@ export function RichText({
style,
numberOfLines,
disableLinks,
resolveFacets = false,
}: TextStyleProp & {
value: RichTextAPI | string
testID?: string
numberOfLines?: number
disableLinks?: boolean
resolveFacets?: boolean
}) {
const detected = React.useRef(false)
const [richText, setRichText] = React.useState<RichTextAPI>(() =>
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
selectable,
enableTags = false,
authorHandle,
}: TextStyleProp &
Pick<TextProps, 'selectable'> & {
value: RichTextAPI | string
testID?: string
numberOfLines?: number
disableLinks?: boolean
enableTags?: boolean
authorHandle?: string
}) {
const richText = React.useMemo(
() =>
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
[value],
)
const styles = [a.leading_normal, style]
React.useEffect(() => {
if (!resolveFacets) return
async function detectFacets() {
const rt = new RichTextAPI({text: richText.text})
await rt.detectFacets(getAgent())
setRichText(rt)
}
if (!detected.current) {
detected.current = true
detectFacets()
}
}, [richText, setRichText, resolveFacets])
const styles = [a.leading_snug, flatten(style)]
const {text, facets} = richText
@@ -50,6 +44,7 @@ export function RichText({
if (text.length <= 5 && /^\p{Extended_Pictographic}+$/u.test(text)) {
return (
<Text
selectable={selectable}
testID={testID}
style={[
{
@@ -65,6 +60,7 @@ export function RichText({
}
return (
<Text
selectable={selectable}
testID={testID}
style={styles}
numberOfLines={numberOfLines}
@@ -81,6 +77,7 @@ export function RichText({
for (const segment of richText.segments()) {
const link = segment.link
const mention = segment.mention
const tag = segment.tag
if (
mention &&
AppBskyRichtextFacet.validateMention(mention).success &&
@@ -88,6 +85,7 @@ export function RichText({
) {
els.push(
<InlineLink
selectable={selectable}
key={key}
to={`/profile/${mention.did}`}
style={[...styles, {pointerEvents: 'auto'}]}
@@ -102,16 +100,32 @@ export function RichText({
} else {
els.push(
<InlineLink
selectable={selectable}
key={key}
to={link.uri}
style={[...styles, {pointerEvents: 'auto'}]}
// @ts-ignore TODO
dataSet={WORD_WRAP}
warnOnMismatchingLabel>
dataSet={WORD_WRAP}>
{toShortUrl(segment.text)}
</InlineLink>,
)
}
} else if (
!disableLinks &&
enableTags &&
tag &&
AppBskyRichtextFacet.validateTag(tag).success
) {
els.push(
<RichTextTag
key={key}
text={segment.text}
tag={tag.tag}
style={styles}
selectable={selectable}
authorHandle={authorHandle}
/>,
)
} else {
els.push(segment.text)
}
@@ -120,6 +134,7 @@ export function RichText({
return (
<Text
selectable={selectable}
testID={testID}
style={styles}
numberOfLines={numberOfLines}
@@ -129,3 +144,81 @@ export function RichText({
</Text>
)
}
function RichTextTag({
text,
tag,
style,
selectable,
authorHandle,
}: {
text: string
tag: string
selectable?: boolean
authorHandle?: string
} & TextStyleProp) {
const t = useTheme()
const {_} = useLingui()
const control = useTagMenuControl()
const {
state: hovered,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const {
state: pressed,
onIn: onPressIn,
onOut: onPressOut,
} = useInteractionState()
const open = React.useCallback(() => {
control.open()
}, [control])
/*
* N.B. On web, this is wrapped in another pressable comopnent with a11y
* labels, etc. That's why only some of these props are applied here.
*/
return (
<React.Fragment>
<TagMenu control={control} tag={tag} authorHandle={authorHandle}>
<Text
selectable={selectable}
{...native({
accessibilityLabel: _(msg`Hashtag: #${tag}`),
accessibilityHint: _(msg`Click here to open tag menu for #${tag}`),
accessibilityRole: isNative ? 'button' : undefined,
onPress: open,
onPressIn: onPressIn,
onPressOut: onPressOut,
})}
{...web({
onMouseEnter: onHoverIn,
onMouseLeave: onHoverOut,
})}
// @ts-ignore
onFocus={onFocus}
onBlur={onBlur}
style={[
style,
{
pointerEvents: 'auto',
color: t.palette.primary_500,
},
web({
cursor: 'pointer',
}),
(hovered || focused || pressed) && {
...web({outline: 0}),
textDecorationLine: 'underline',
textDecorationColor: t.palette.primary_500,
},
]}>
{text}
</Text>
</TagMenu>
</React.Fragment>
)
}
+275
View File
@@ -0,0 +1,275 @@
import React from 'react'
import {View} from 'react-native'
import {useNavigation} from '@react-navigation/native'
import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro'
import {atoms as a, native, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {Text} from '#/components/Typography'
import {Button, ButtonText} from '#/components/Button'
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {Divider} from '#/components/Divider'
import {Link} from '#/components/Link'
import {makeSearchLink} from '#/lib/routes/links'
import {NavigationProp} from '#/lib/routes/types'
import {
usePreferencesQuery,
useUpsertMutedWordsMutation,
useRemoveMutedWordMutation,
} from '#/state/queries/preferences'
import {Loader} from '#/components/Loader'
import {isInvalidHandle} from '#/lib/strings/handles'
export function useTagMenuControl() {
return Dialog.useDialogControl()
}
export function TagMenu({
children,
control,
tag,
authorHandle,
}: React.PropsWithChildren<{
control: Dialog.DialogOuterProps['control']
/**
* This should be the sanitized tag value from the facet itself, not the
* "display" value with a leading `#`.
*/
tag: string
authorHandle?: string
}>) {
const {_} = useLingui()
const t = useTheme()
const navigation = useNavigation<NavigationProp>()
const {isLoading: isPreferencesLoading, data: preferences} =
usePreferencesQuery()
const {
mutateAsync: upsertMutedWord,
variables: optimisticUpsert,
reset: resetUpsert,
} = useUpsertMutedWordsMutation()
const {
mutateAsync: removeMutedWord,
variables: optimisticRemove,
reset: resetRemove,
} = useRemoveMutedWordMutation()
const displayTag = '#' + tag
const isMuted = Boolean(
(preferences?.mutedWords?.find(
m => m.value === tag && m.targets.includes('tag'),
) ??
optimisticUpsert?.find(
m => m.value === tag && m.targets.includes('tag'),
)) &&
!(optimisticRemove?.value === tag),
)
return (
<>
{children}
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.Inner label={_(msg`Tag menu: ${displayTag}`)}>
{isPreferencesLoading ? (
<View style={[a.w_full, a.align_center]}>
<Loader size="lg" />
</View>
) : (
<>
<View
style={[
a.rounded_md,
a.border,
a.mb_md,
t.atoms.border_contrast_low,
t.atoms.bg_contrast_25,
]}>
<Link
label={_(msg`Search for all posts with tag ${displayTag}`)}
to={makeSearchLink({query: displayTag})}
onPress={e => {
e.preventDefault()
control.close(() => {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
})
})
return false
}}>
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.justify_start,
a.gap_md,
a.px_lg,
a.py_md,
]}>
<Search size="lg" style={[t.atoms.text_contrast_medium]} />
<Text
numberOfLines={1}
ellipsizeMode="middle"
style={[
a.flex_1,
a.text_md,
a.font_bold,
native({top: 2}),
t.atoms.text_contrast_medium,
]}>
<Trans>
See{' '}
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
{displayTag}
</Text>{' '}
posts
</Trans>
</Text>
</View>
</Link>
{authorHandle && !isInvalidHandle(authorHandle) && (
<>
<Divider />
<Link
label={_(
msg`Search for all posts by @${authorHandle} with tag ${displayTag}`,
)}
to={makeSearchLink({
query: displayTag,
from: authorHandle,
})}
onPress={e => {
e.preventDefault()
control.close(() => {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
author: authorHandle,
})
})
return false
}}>
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.justify_start,
a.gap_md,
a.px_lg,
a.py_md,
]}>
<Person
size="lg"
style={[t.atoms.text_contrast_medium]}
/>
<Text
numberOfLines={1}
ellipsizeMode="middle"
style={[
a.flex_1,
a.text_md,
a.font_bold,
native({top: 2}),
t.atoms.text_contrast_medium,
]}>
<Trans>
See{' '}
<Text
style={[a.text_md, a.font_bold, t.atoms.text]}>
{displayTag}
</Text>{' '}
posts by this user
</Trans>
</Text>
</View>
</Link>
</>
)}
{preferences ? (
<>
<Divider />
<Button
label={
isMuted
? _(msg`Unmute all ${displayTag} posts`)
: _(msg`Mute all ${displayTag} posts`)
}
onPress={() => {
control.close(() => {
if (isMuted) {
resetUpsert()
removeMutedWord({
value: tag,
targets: ['tag'],
})
} else {
resetRemove()
upsertMutedWord([{value: tag, targets: ['tag']}])
}
})
}}>
<View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.justify_start,
a.gap_md,
a.px_lg,
a.py_md,
]}>
<Mute
size="lg"
style={[t.atoms.text_contrast_medium]}
/>
<Text
numberOfLines={1}
ellipsizeMode="middle"
style={[
a.flex_1,
a.text_md,
a.font_bold,
native({top: 2}),
t.atoms.text_contrast_medium,
]}>
{isMuted ? _(msg`Unmute`) : _(msg`Mute`)}{' '}
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
{displayTag}
</Text>{' '}
<Trans>posts</Trans>
</Text>
</View>
</Button>
</>
) : null}
</View>
<Button
label={_(msg`Close this dialog`)}
size="small"
variant="ghost"
color="secondary"
onPress={() => control.close()}>
<ButtonText>Cancel</ButtonText>
</Button>
</>
)}
</Dialog.Inner>
</Dialog.Outer>
</>
)
}
+149
View File
@@ -0,0 +1,149 @@
import React from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {isInvalidHandle} from '#/lib/strings/handles'
import {EventStopper} from '#/view/com/util/EventStopper'
import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown'
import {NavigationProp} from '#/lib/routes/types'
import {
usePreferencesQuery,
useUpsertMutedWordsMutation,
useRemoveMutedWordMutation,
} from '#/state/queries/preferences'
import {enforceLen} from '#/lib/strings/helpers'
import {web} from '#/alf'
import * as Dialog from '#/components/Dialog'
export function useTagMenuControl(): Dialog.DialogControlProps {
return {
id: '',
// @ts-ignore
ref: null,
open: () => {
throw new Error(`TagMenu controls are only available on native platforms`)
},
close: () => {
throw new Error(`TagMenu controls are only available on native platforms`)
},
}
}
export function TagMenu({
children,
tag,
authorHandle,
}: React.PropsWithChildren<{
/**
* This should be the sanitized tag value from the facet itself, not the
* "display" value with a leading `#`.
*/
tag: string
authorHandle?: string
}>) {
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
const {data: preferences} = usePreferencesQuery()
const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} =
useUpsertMutedWordsMutation()
const {mutateAsync: removeMutedWord, variables: optimisticRemove} =
useRemoveMutedWordMutation()
const isMuted = Boolean(
(preferences?.mutedWords?.find(
m => m.value === tag && m.targets.includes('tag'),
) ??
optimisticUpsert?.find(
m => m.value === tag && m.targets.includes('tag'),
)) &&
!(optimisticRemove?.value === tag),
)
const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle')
const dropdownItems = React.useMemo(() => {
return [
{
label: _(msg`See ${truncatedTag} posts`),
onPress() {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
})
},
testID: 'tagMenuSearch',
icon: {
ios: {
name: 'magnifyingglass',
},
android: '',
web: 'magnifying-glass',
},
},
authorHandle &&
!isInvalidHandle(authorHandle) && {
label: _(msg`See ${truncatedTag} posts by user`),
onPress() {
navigation.push('Hashtag', {
tag: encodeURIComponent(tag),
author: authorHandle,
})
},
testID: 'tagMenuSeachByUser',
icon: {
ios: {
name: 'magnifyingglass',
},
android: '',
web: ['far', 'user'],
},
},
preferences && {
label: 'separator',
},
preferences && {
label: isMuted
? _(msg`Unmute ${truncatedTag}`)
: _(msg`Mute ${truncatedTag}`),
onPress() {
if (isMuted) {
removeMutedWord({value: tag, targets: ['tag']})
} else {
upsertMutedWord([{value: tag, targets: ['tag']}])
}
},
testID: 'tagMenuMute',
icon: {
ios: {
name: 'speaker.slash',
},
android: 'ic_menu_sort_alphabetically',
web: isMuted ? 'eye' : ['far', 'eye-slash'],
},
},
].filter(Boolean)
}, [
_,
authorHandle,
isMuted,
navigation,
preferences,
tag,
truncatedTag,
upsertMutedWord,
removeMutedWord,
])
return (
<EventStopper>
<NativeDropdown
accessibilityLabel={_(msg`Click here to open tag menu for ${tag}`)}
accessibilityHint=""
// @ts-ignore
items={dropdownItems}
triggerStyle={web({
textAlign: 'left',
})}>
{children}
</NativeDropdown>
</EventStopper>
)
}
+19 -19
View File
@@ -1,7 +1,16 @@
import React from 'react'
import {Text as RNText, TextStyle, TextProps} from 'react-native'
import {Text as RNText, TextStyle, TextProps as RNTextProps} from 'react-native'
import {UITextView} from 'react-native-ui-text-view'
import {useTheme, atoms, web, flatten} from '#/alf'
import {isIOS} from '#/platform/detection'
export type TextProps = RNTextProps & {
/**
* Lets the user select text, to use the native copy and paste functionality.
*/
selectable?: boolean
}
/**
* Util to calculate lineHeight from a text size atom and a leading atom
@@ -44,27 +53,24 @@ function normalizeTextStyles(styles: TextStyle[]) {
/**
* Our main text component. Use this most of the time.
*/
export function Text({style, ...rest}: TextProps) {
export function Text({style, selectable, ...rest}: TextProps) {
const t = useTheme()
const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)])
return <RNText style={s} {...rest} />
return selectable && isIOS ? (
<UITextView style={s} {...rest} />
) : (
<RNText selectable={selectable} style={s} {...rest} />
)
}
export function createHeadingElement({level}: {level: number}) {
return function HeadingElement({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'heading',
'aria-level': level,
}) || {}
return (
<RNText
{...attr}
{...rest}
style={normalizeTextStyles([t.atoms.text, flatten(style)])}
/>
)
return <Text {...attr} {...rest} style={style} />
}
}
@@ -78,21 +84,15 @@ export const H4 = createHeadingElement({level: 4})
export const H5 = createHeadingElement({level: 5})
export const H6 = createHeadingElement({level: 6})
export function P({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'paragraph',
}) || {}
return (
<RNText
<Text
{...attr}
{...rest}
style={normalizeTextStyles([
atoms.text_md,
atoms.leading_normal,
t.atoms.text,
flatten(style),
])}
style={[atoms.text_md, atoms.leading_normal, flatten(style)]}
/>
)
}
+29
View File
@@ -0,0 +1,29 @@
import React from 'react'
import * as Dialog from '#/components/Dialog'
type Control = Dialog.DialogOuterProps['control']
type ControlsContext = {
mutedWordsDialogControl: Control
}
const ControlsContext = React.createContext({
mutedWordsDialogControl: {} as Control,
})
export function useGlobalDialogsControlContext() {
return React.useContext(ControlsContext)
}
export function Provider({children}: React.PropsWithChildren<{}>) {
const mutedWordsDialogControl = Dialog.useDialogControl()
const ctx = React.useMemo(
() => ({mutedWordsDialogControl}),
[mutedWordsDialogControl],
)
return (
<ControlsContext.Provider value={ctx}>{children}</ControlsContext.Provider>
)
}
+389
View File
@@ -0,0 +1,389 @@
import React from 'react'
import {Keyboard, View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api'
import {
usePreferencesQuery,
useUpsertMutedWordsMutation,
useRemoveMutedWordMutation,
} from '#/state/queries/preferences'
import {isNative} from '#/platform/detection'
import {
atoms as a,
useTheme,
useBreakpoints,
ViewStyleProp,
web,
native,
} from '#/alf'
import {Text} from '#/components/Typography'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
import {Divider} from '#/components/Divider'
import {Loader} from '#/components/Loader'
import {logger} from '#/logger'
import * as Dialog from '#/components/Dialog'
import * as Toggle from '#/components/forms/Toggle'
import * as Prompt from '#/components/Prompt'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
export function MutedWordsDialog() {
const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<MutedWordsInner control={control} />
</Dialog.Outer>
)
}
function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
const t = useTheme()
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const {
isLoading: isPreferencesLoading,
data: preferences,
error: preferencesError,
} = usePreferencesQuery()
const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
const [field, setField] = React.useState('')
const [options, setOptions] = React.useState(['content'])
const [error, setError] = React.useState('')
const submit = React.useCallback(async () => {
const sanitizedValue = sanitizeMutedWordValue(field)
const targets = ['tag', options.includes('content') && 'content'].filter(
Boolean,
) as AppBskyActorDefs.MutedWord['targets']
if (!sanitizedValue || !targets.length) {
setField('')
setError(_(msg`Please enter a valid word, tag, or phrase to mute`))
return
}
try {
// send raw value and rely on SDK as sanitization source of truth
await addMutedWord([{value: field, targets}])
setField('')
} catch (e: any) {
logger.error(`Failed to save muted word`, {message: e.message})
setError(e.message)
}
}, [_, field, options, addMutedWord, setField])
return (
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
<View onTouchStart={Keyboard.dismiss}>
<Text
style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
<Trans>Add muted words and tags</Trans>
</Text>
<Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>
Posts can be muted based on their text, their tags, or both.
</Trans>
</Text>
<View style={[a.pb_lg]}>
<Dialog.Input
autoCorrect={false}
autoCapitalize="none"
autoComplete="off"
label={_(msg`Enter a word or tag`)}
placeholder={_(msg`Enter a word or tag`)}
value={field}
onChangeText={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>
</Dialog.ScrollableInner>
)
}
function MutedWordRow({
style,
word,
}: ViewStyleProp & {word: AppBskyActorDefs.MutedWord}) {
const t = useTheme()
const {_} = useLingui()
const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
const control = Prompt.usePromptControl()
const remove = React.useCallback(async () => {
control.close()
removeMutedWord(word)
}, [removeMutedWord, word, control])
return (
<>
<Prompt.Outer control={control}>
<Prompt.Title>
<Trans>Are you sure?</Trans>
</Prompt.Title>
<Prompt.Description>
<Trans>
This will delete {word.value} from your muted words. You can always
add it back later.
</Trans>
</Prompt.Description>
<Prompt.Actions>
<Prompt.Cancel>
<ButtonText>
<Trans>Nevermind</Trans>
</ButtonText>
</Prompt.Cancel>
<Prompt.Action onPress={remove}>
<ButtonText>
<Trans>Remove</Trans>
</ButtonText>
</Prompt.Action>
</Prompt.Actions>
</Prompt.Outer>
<View
style={[
a.py_md,
a.px_lg,
a.flex_row,
a.align_center,
a.justify_between,
a.rounded_md,
a.gap_md,
style,
]}>
<Text
style={[
a.flex_1,
a.leading_snug,
a.w_full,
a.font_bold,
t.atoms.text_contrast_high,
web({
overflowWrap: 'break-word',
wordBreak: 'break-word',
}),
]}>
{word.value}
</Text>
<View style={[a.flex_row, a.align_center, a.justify_end, a.gap_sm]}>
{word.targets.map(target => (
<View
key={target}
style={[a.py_xs, a.px_sm, a.rounded_sm, t.atoms.bg_contrast_100]}>
<Text
style={[a.text_xs, a.font_bold, t.atoms.text_contrast_medium]}>
{target === 'content' ? _(msg`text`) : _(msg`tag`)}
</Text>
</View>
))}
<Button
label={_(msg`Remove mute word from your list`)}
size="tiny"
shape="round"
variant="ghost"
color="secondary"
onPress={() => control.open()}
style={[a.ml_sm]}>
<ButtonIcon icon={isPending ? Loader : X} />
</Button>
</View>
</View>
</>
)
}
function TargetToggle({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
const ctx = Toggle.useItemContext()
const {gtMobile} = useBreakpoints()
return (
<View
style={[
a.flex_row,
a.align_center,
a.justify_between,
a.gap_xs,
a.flex_1,
a.py_sm,
a.px_sm,
gtMobile && a.px_md,
a.rounded_sm,
t.atoms.bg_contrast_50,
(ctx.hovered || ctx.focused) && t.atoms.bg_contrast_100,
ctx.selected && [
{
backgroundColor:
t.name === 'light' ? t.palette.primary_50 : t.palette.primary_975,
},
],
ctx.disabled && {
opacity: 0.8,
},
]}>
{children}
</View>
)
}
@@ -1,8 +1,5 @@
import React from 'react'
import {View, Pressable} from 'react-native'
import DateTimePicker, {
BaseProps as DateTimePickerProps,
} from '@react-native-community/datetimepicker'
import {useTheme, atoms} from '#/alf'
import {Text} from '#/components/Typography'
@@ -15,6 +12,8 @@ import {
localizeDate,
toSimpleDateString,
} from '#/components/forms/DateField/utils'
import DatePicker from 'react-native-date-picker'
import {isAndroid} from 'platform/detection'
export * as utils from '#/components/forms/DateField/utils'
export const Label = TextField.Label
@@ -38,20 +37,20 @@ export function DateField({
const {chromeFocus, chromeError, chromeErrorHover} =
TextField.useSharedInputStyles()
const onChangeInternal = React.useCallback<
Required<DateTimePickerProps>['onChange']
>(
(_event, date) => {
const onChangeInternal = React.useCallback(
(date: Date) => {
setOpen(false)
if (date) {
const formatted = toSimpleDateString(date)
onChangeDate(formatted)
}
const formatted = toSimpleDateString(date)
onChangeDate(formatted)
},
[onChangeDate, setOpen],
)
const onCancel = React.useCallback(() => {
setOpen(false)
}, [])
return (
<View style={[atoms.relative, atoms.w_full]}>
<Pressable
@@ -89,18 +88,18 @@ export function DateField({
</Pressable>
{open && (
<DateTimePicker
<DatePicker
modal={isAndroid}
open={isAndroid}
theme={t.name === 'light' ? 'light' : 'dark'}
date={new Date(value)}
onConfirm={onChangeInternal}
onCancel={onCancel}
mode="date"
testID={`${testID}-datepicker`}
aria-label={label}
accessibilityLabel={label}
accessibilityHint={undefined}
testID={`${testID}-datepicker`}
mode="date"
timeZoneName={'Etc/UTC'}
display="spinner"
// @ts-ignore applies in iOS only -prf
themeVariant={t.name === 'dark' ? 'dark' : 'light'}
value={new Date(value)}
onChange={onChangeInternal}
/>
)}
</View>
+8 -12
View File
@@ -1,13 +1,11 @@
import React from 'react'
import {View} from 'react-native'
import DateTimePicker, {
DateTimePickerEvent,
} from '@react-native-community/datetimepicker'
import {useTheme, atoms} from '#/alf'
import * as TextField from '#/components/forms/TextField'
import {toSimpleDateString} from '#/components/forms/DateField/utils'
import {DateFieldProps} from '#/components/forms/DateField/types'
import DatePicker from 'react-native-date-picker'
export * as utils from '#/components/forms/DateField/utils'
export const Label = TextField.Label
@@ -28,7 +26,7 @@ export function DateField({
const t = useTheme()
const onChangeInternal = React.useCallback(
(event: DateTimePickerEvent, date: Date | undefined) => {
(date: Date | undefined) => {
if (date) {
const formatted = toSimpleDateString(date)
onChangeDate(formatted)
@@ -39,17 +37,15 @@ export function DateField({
return (
<View style={[atoms.relative, atoms.w_full]}>
<DateTimePicker
<DatePicker
theme={t.name === 'light' ? 'light' : 'dark'}
date={new Date(value)}
onDateChange={onChangeInternal}
mode="date"
testID={`${testID}-datepicker`}
aria-label={label}
accessibilityLabel={label}
accessibilityHint={undefined}
testID={`${testID}-datepicker`}
mode="date"
timeZoneName={'Etc/UTC'}
display="spinner"
themeVariant={t.name === 'dark' ? 'dark' : 'light'}
value={new Date(value)}
onChange={onChangeInternal}
/>
</View>
)
+3 -3
View File
@@ -10,7 +10,7 @@ import {
} from 'react-native'
import {HITSLOP_20} from 'lib/constants'
import {useTheme, atoms as a, web, tokens, android} from '#/alf'
import {useTheme, atoms as a, web, android} from '#/alf'
import {Text} from '#/components/Typography'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Props as SVGIconProps} from '#/components/icons/common'
@@ -72,7 +72,7 @@ export function Root({children, isInvalid = false}: RootProps) {
return (
<Context.Provider value={context}>
<View
style={[a.flex_row, a.align_center, a.relative, a.w_full, a.px_md]}
style={[a.flex_row, a.align_center, a.relative, a.flex_1, a.px_md]}
{...web({
onClick: () => inputRef.current?.focus(),
onMouseOver: onHoverIn,
@@ -110,7 +110,7 @@ export function useSharedInputStyles() {
{
backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
borderColor: tokens.color.red_500,
borderColor: t.palette.negative_500,
},
]
+12 -26
View File
@@ -5,6 +5,7 @@ import {HITSLOP_10} from 'lib/constants'
import {useTheme, atoms as a, web, native, flatten, ViewStyleProp} from '#/alf'
import {Text} from '#/components/Typography'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check'
export type ItemState = {
name: string
@@ -300,7 +301,7 @@ export function createSharedToggleStyles({
if (isInvalid) {
base.push({
backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_975,
borderColor:
t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
})
@@ -309,7 +310,7 @@ export function createSharedToggleStyles({
baseHover.push({
backgroundColor:
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
borderColor: t.palette.negative_500,
borderColor: t.palette.negative_600,
})
}
}
@@ -331,15 +332,14 @@ export function createSharedToggleStyles({
export function Checkbox() {
const t = useTheme()
const {selected, hovered, focused, disabled, isInvalid} = useItemContext()
const {baseStyles, baseHoverStyles, indicatorStyles} =
createSharedToggleStyles({
theme: t,
hovered,
focused,
selected,
disabled,
isInvalid,
})
const {baseStyles, baseHoverStyles} = createSharedToggleStyles({
theme: t,
hovered,
focused,
selected,
disabled,
isInvalid,
})
return (
<View
style={[
@@ -355,21 +355,7 @@ export function Checkbox() {
baseStyles,
hovered || focused ? baseHoverStyles : {},
]}>
{selected ? (
<View
style={[
a.absolute,
a.rounded_2xs,
{height: 12, width: 12},
selected
? {
backgroundColor: t.palette.primary_500,
}
: {},
indicatorStyles,
]}
/>
) : null}
{selected ? <Checkmark size="xs" fill={t.palette.primary_500} /> : null}
</View>
)
}
+4
View File
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
export const Check_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M21.59 3.193a1 1 0 0 1 .217 1.397l-11.706 16a1 1 0 0 1-1.429.193l-6.294-5a1 1 0 1 1 1.244-1.566l5.48 4.353 11.09-15.16a1 1 0 0 1 1.398-.217Z',
})
export const CheckThick_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M21.474 2.98a2.5 2.5 0 0 1 .545 3.494l-10.222 14a2.5 2.5 0 0 1-3.528.52L2.49 16.617a2.5 2.5 0 0 1 3.018-3.986l3.75 2.84L17.98 3.525a2.5 2.5 0 0 1 3.493-.545Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Clipboard_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M8.17 4A3.001 3.001 0 0 1 11 2h2c1.306 0 2.418.835 2.83 2H17a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3h1.17ZM8 6H7a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1h-1v1a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V6Zm6 0V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v1h4Z',
})
+1 -1
View File
@@ -1,5 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Group3_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M17 16H21.1456C20.8246 11.4468 17.7199 9.48509 15.0001 10.1147M10 4C10 5.65685 8.65685 7 7 7C5.34315 7 4 5.65685 4 4C4 2.34315 5.34315 1 7 1C8.65685 1 10 2.34315 10 4ZM18.5 4.5C18.5 5.88071 17.3807 7 16 7C14.6193 7 13.5 5.88071 13.5 4.5C13.5 3.11929 14.6193 2 16 2C17.3807 2 18.5 3.11929 18.5 4.5ZM1 17H13C12.3421 7.66667 1.65792 7.66667 1 17Z',
path: 'M8 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM4 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm13-1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm-3.5 1.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0Zm5.826 7.376c-.919-.779-2.052-1.03-3.1-.787a1 1 0 0 1-.451-1.949c1.671-.386 3.45.028 4.844 1.211 1.397 1.185 2.348 3.084 2.524 5.579a1 1 0 0 1-.997 1.07H18a1 1 0 1 1 0-2h3.007c-.29-1.47-.935-2.49-1.681-3.124ZM3.126 19h9.747c-.61-3.495-2.867-5-4.873-5-2.006 0-4.263 1.505-4.873 5ZM8 12c3.47 0 6.64 2.857 6.998 7.93A1 1 0 0 1 14 21H2a1 1 0 0 1-.998-1.07C1.36 14.857 4.53 12 8 12Z',
})
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const MagnifyingGlass2_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M11 5a6 6 0 1 0 0 12 6 6 0 0 0 0-12Zm-8 6a8 8 0 1 1 14.32 4.906l3.387 3.387a1 1 0 0 1-1.414 1.414l-3.387-3.387A8 8 0 0 1 3 11Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Mute_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M20.707 3.293a1 1 0 0 1 0 1.414l-16 16a1 1 0 0 1-1.414-1.414l2.616-2.616A1.998 1.998 0 0 1 5 15V9a2 2 0 0 1 2-2h2.697l5.748-3.832A1 1 0 0 1 17 4v1.586l2.293-2.293a1 1 0 0 1 1.414 0ZM15 7.586 7.586 15H7V9h2.697a2 2 0 0 0 1.11-.336L15 5.87v1.717Zm2 3.657-2 2v4.888l-2.933-1.955-1.442 1.442 4.82 3.214A1 1 0 0 0 17 20v-8.757Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const PageText_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M5 2a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H5Zm1 18V4h12v16H6Zm3-6a1 1 0 1 0 0 2h2a1 1 0 1 0 0-2H9Zm-1-3a1 1 0 0 1 1-1h6a1 1 0 1 1 0 2H9a1 1 0 0 1-1-1Zm1-5a1 1 0 0 0 0 2h6a1 1 0 1 0 0-2H9Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Person_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C3.917 15.521 7.242 12 12 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 19.5 21h-15a1 1 0 0 1-.996-1.094Z',
})
@@ -0,0 +1,692 @@
import {describe, it, expect} from '@jest/globals'
import {RichText} from '@atproto/api'
import {hasMutedWord} from '../moderatePost_wrapped'
describe(`hasMutedWord`, () => {
describe(`tags`, () => {
it(`match: outline tag`, () => {
const rt = new RichText({
text: `This is a post #inlineTag`,
})
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{value: 'outlineTag', targets: ['tag']}],
text: rt.text,
facets: rt.facets,
outlineTags: ['outlineTag'],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: inline tag`, () => {
const rt = new RichText({
text: `This is a post #inlineTag`,
})
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{value: 'inlineTag', targets: ['tag']}],
text: rt.text,
facets: rt.facets,
outlineTags: ['outlineTag'],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: content target matches inline tag`, () => {
const rt = new RichText({
text: `This is a post #inlineTag`,
})
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{value: 'inlineTag', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: ['outlineTag'],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`no match: only tag targets`, () => {
const rt = new RichText({
text: `This is a post`,
})
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{value: 'inlineTag', targets: ['tag']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(false)
})
})
describe(`early exits`, () => {
it(`match: single character 希`, () => {
/**
* @see https://bsky.app/profile/mukuuji.bsky.social/post/3klji4fvsdk2c
*/
const rt = new RichText({
text: `改善希望です`,
})
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{value: '希', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`no match: long muted word, short post`, () => {
const rt = new RichText({
text: `hey`,
})
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{value: 'politics', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(false)
})
it(`match: exact text`, () => {
const rt = new RichText({
text: `javascript`,
})
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{value: 'javascript', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
})
describe(`general content`, () => {
it(`match: word within post`, () => {
const rt = new RichText({
text: `This is a post about javascript`,
})
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{value: 'javascript', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`no match: partial word`, () => {
const rt = new RichText({
text: `Use your brain, Eric`,
})
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{value: 'ai', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(false)
})
it(`match: multiline`, () => {
const rt = new RichText({
text: `Use your\n\tbrain, Eric`,
})
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{value: 'brain', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: :)`, () => {
const rt = new RichText({
text: `So happy :)`,
})
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{value: `:)`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
})
describe(`punctuation semi-fuzzy`, () => {
describe(`yay!`, () => {
const rt = new RichText({
text: `We're federating, yay!`,
})
rt.detectFacetsWithoutResolution()
it(`match: yay!`, () => {
const match = hasMutedWord({
mutedWords: [{value: 'yay!', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: yay`, () => {
const match = hasMutedWord({
mutedWords: [{value: 'yay', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
})
describe(`y!ppee!!`, () => {
const rt = new RichText({
text: `We're federating, y!ppee!!`,
})
rt.detectFacetsWithoutResolution()
it(`match: y!ppee`, () => {
const match = hasMutedWord({
mutedWords: [{value: 'y!ppee', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
// single exclamation point, source has double
it(`no match: y!ppee!`, () => {
const match = hasMutedWord({
mutedWords: [{value: 'y!ppee!', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
})
describe(`Why so S@assy?`, () => {
const rt = new RichText({
text: `Why so S@assy?`,
})
rt.detectFacetsWithoutResolution()
it(`match: S@assy`, () => {
const match = hasMutedWord({
mutedWords: [{value: 'S@assy', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: s@assy`, () => {
const match = hasMutedWord({
mutedWords: [{value: 's@assy', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
})
describe(`New York Times`, () => {
const rt = new RichText({
text: `New York Times`,
})
rt.detectFacetsWithoutResolution()
// case insensitive
it(`match: new york times`, () => {
const match = hasMutedWord({
mutedWords: [{value: 'new york times', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
})
describe(`!command`, () => {
const rt = new RichText({
text: `Idk maybe a bot !command`,
})
rt.detectFacetsWithoutResolution()
it(`match: !command`, () => {
const match = hasMutedWord({
mutedWords: [{value: `!command`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: command`, () => {
const match = hasMutedWord({
mutedWords: [{value: `command`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`no match: !command`, () => {
const rt = new RichText({
text: `Idk maybe a bot command`,
})
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{value: `!command`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(false)
})
})
describe(`e/acc`, () => {
const rt = new RichText({
text: `I'm e/acc pilled`,
})
rt.detectFacetsWithoutResolution()
it(`match: e/acc`, () => {
const match = hasMutedWord({
mutedWords: [{value: `e/acc`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: acc`, () => {
const match = hasMutedWord({
mutedWords: [{value: `acc`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
})
describe(`super-bad`, () => {
const rt = new RichText({
text: `I'm super-bad`,
})
rt.detectFacetsWithoutResolution()
it(`match: super-bad`, () => {
const match = hasMutedWord({
mutedWords: [{value: `super-bad`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: super`, () => {
const match = hasMutedWord({
mutedWords: [{value: `super`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: super bad`, () => {
const match = hasMutedWord({
mutedWords: [{value: `super bad`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: superbad`, () => {
const match = hasMutedWord({
mutedWords: [{value: `superbad`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(false)
})
})
describe(`idk_what_this_would_be`, () => {
const rt = new RichText({
text: `Weird post with idk_what_this_would_be`,
})
rt.detectFacetsWithoutResolution()
it(`match: idk what this would be`, () => {
const match = hasMutedWord({
mutedWords: [{value: `idk what this would be`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`no match: idk what this would be for`, () => {
// extra word
const match = hasMutedWord({
mutedWords: [
{value: `idk what this would be for`, targets: ['content']},
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(false)
})
it(`match: idk`, () => {
// extra word
const match = hasMutedWord({
mutedWords: [{value: `idk`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: idkwhatthiswouldbe`, () => {
const match = hasMutedWord({
mutedWords: [{value: `idkwhatthiswouldbe`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(false)
})
})
describe(`parentheses`, () => {
const rt = new RichText({
text: `Post with context(iykyk)`,
})
rt.detectFacetsWithoutResolution()
it(`match: context(iykyk)`, () => {
const match = hasMutedWord({
mutedWords: [{value: `context(iykyk)`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: context`, () => {
const match = hasMutedWord({
mutedWords: [{value: `context`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: iykyk`, () => {
const match = hasMutedWord({
mutedWords: [{value: `iykyk`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: (iykyk)`, () => {
const match = hasMutedWord({
mutedWords: [{value: `(iykyk)`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
})
describe(`🦋`, () => {
const rt = new RichText({
text: `Post with 🦋`,
})
rt.detectFacetsWithoutResolution()
it(`match: 🦋`, () => {
const match = hasMutedWord({
mutedWords: [{value: `🦋`, targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
})
})
describe(`phrases`, () => {
describe(`I like turtles, or how I learned to stop worrying and love the internet.`, () => {
const rt = new RichText({
text: `I like turtles, or how I learned to stop worrying and love the internet.`,
})
rt.detectFacetsWithoutResolution()
it(`match: stop worrying`, () => {
const match = hasMutedWord({
mutedWords: [{value: 'stop worrying', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`match: turtles, or how`, () => {
const match = hasMutedWord({
mutedWords: [{value: 'turtles, or how', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
})
})
describe(`languages without spaces`, () => {
// I love turtles, or how I learned to stop worrying and love the internet
describe(`私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`, () => {
const rt = new RichText({
text: `私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`,
})
rt.detectFacetsWithoutResolution()
// internet
it(`match: インターネット`, () => {
const match = hasMutedWord({
mutedWords: [{value: 'インターネット', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
languages: ['ja'],
isOwnPost: false,
})
expect(match).toBe(true)
})
})
})
describe(`doesn't mute own post`, () => {
it(`does mute if it isn't own post`, () => {
const rt = new RichText({
text: `Mute words!`,
})
const match = hasMutedWord({
mutedWords: [{value: 'words', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: false,
})
expect(match).toBe(true)
})
it(`doesn't mute own post when muted word is in text`, () => {
const rt = new RichText({
text: `Mute words!`,
})
const match = hasMutedWord({
mutedWords: [{value: 'words', targets: ['content']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: true,
})
expect(match).toBe(false)
})
it(`doesn't mute own post when muted word is in tags`, () => {
const rt = new RichText({
text: `Mute #words!`,
})
const match = hasMutedWord({
mutedWords: [{value: 'words', targets: ['tags']}],
text: rt.text,
facets: rt.facets,
outlineTags: [],
isOwnPost: true,
})
expect(match).toBe(false)
})
})
})
+3 -3
View File
@@ -104,18 +104,18 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
// add image embed if present
if (opts.images?.length) {
logger.info(`Uploading images`, {
logger.debug(`Uploading images`, {
count: opts.images.length,
})
const images: AppBskyEmbedImages.Image[] = []
for (const image of opts.images) {
opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
logger.info(`Compressing image`)
logger.debug(`Compressing image`)
await image.compress()
const path = image.compressed?.path ?? image.path
const {width, height} = image.compressed || image
logger.info(`Uploading image`)
logger.debug(`Uploading image`)
const res = await uploadBlob(agent, path, 'image/jpeg')
images.push({
image: res.data.blob,
+13 -90
View File
@@ -3,9 +3,8 @@ import {Insets, Platform} from 'react-native'
export const LOCAL_DEV_SERVICE =
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
export const STAGING_SERVICE = 'https://staging.bsky.dev'
export const PROD_SERVICE = 'https://bsky.social'
export const DEFAULT_SERVICE = PROD_SERVICE
export const BSKY_SERVICE = 'https://bsky.social'
export const DEFAULT_SERVICE = BSKY_SERVICE
const HELP_DESK_LANG = 'en-us'
export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
@@ -36,92 +35,12 @@ export const MAX_GRAPHEME_LENGTH = 300
// but increasing limit per user feedback
export const MAX_ALT_TEXT = 1000
export function IS_LOCAL_DEV(url: string) {
return url.includes('localhost')
export function IS_PROD_SERVICE(url?: string) {
return url && url !== STAGING_SERVICE && url !== LOCAL_DEV_SERVICE
}
export function IS_STAGING(url: string) {
return url.startsWith('https://staging.bsky.dev')
}
export function IS_PROD(url: string) {
// NOTE
// until open federation, "production" is defined as the main server
// this definition will not work once federation is enabled!
// -prf
return (
url.startsWith('https://bsky.social') ||
url.startsWith('https://api.bsky.app') ||
/bsky\.network\/?$/.test(url)
)
}
export const PROD_TEAM_HANDLES = [
'jay.bsky.social',
'pfrazee.com',
'divy.zone',
'dholms.xyz',
'why.bsky.world',
'iamrosewang.bsky.social',
]
export const STAGING_TEAM_HANDLES = [
'arcalinea.staging.bsky.dev',
'paul.staging.bsky.dev',
'paul2.staging.bsky.dev',
]
export const DEV_TEAM_HANDLES = ['alice.test', 'bob.test', 'carla.test']
export function TEAM_HANDLES(serviceUrl: string) {
if (serviceUrl.includes('localhost')) {
return DEV_TEAM_HANDLES
} else if (serviceUrl.includes('staging')) {
return STAGING_TEAM_HANDLES
} else {
return PROD_TEAM_HANDLES
}
}
export const STAGING_DEFAULT_FEED = (rkey: string) =>
`at://did:plc:wqzurwm3kmaig6e6hnc2gqwo/app.bsky.feed.generator/${rkey}`
export const PROD_DEFAULT_FEED = (rkey: string) =>
`at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/${rkey}`
export async function DEFAULT_FEEDS(
serviceUrl: string,
resolveHandle: (name: string) => Promise<string>,
) {
// TODO: remove this when the test suite no longer relies on it
if (IS_LOCAL_DEV(serviceUrl)) {
// local dev
const aliceDid = await resolveHandle('alice.test')
return {
pinned: [
`at://${aliceDid}/app.bsky.feed.generator/alice-favs`,
`at://${aliceDid}/app.bsky.feed.generator/alice-favs2`,
],
saved: [
`at://${aliceDid}/app.bsky.feed.generator/alice-favs`,
`at://${aliceDid}/app.bsky.feed.generator/alice-favs2`,
],
}
} else if (IS_STAGING(serviceUrl)) {
// staging
return {
pinned: [STAGING_DEFAULT_FEED('whats-hot')],
saved: [
STAGING_DEFAULT_FEED('bsky-team'),
STAGING_DEFAULT_FEED('with-friends'),
STAGING_DEFAULT_FEED('whats-hot'),
STAGING_DEFAULT_FEED('hot-classic'),
],
}
} else {
// production
return {
pinned: [PROD_DEFAULT_FEED('whats-hot')],
saved: [PROD_DEFAULT_FEED('whats-hot')],
}
}
}
export const POST_IMG_MAX = {
width: 2000,
@@ -135,13 +54,11 @@ export const STAGING_LINK_META_PROXY =
export const PROD_LINK_META_PROXY = 'https://cardyb.bsky.app/v1/extract?url='
export function LINK_META_PROXY(serviceUrl: string) {
if (IS_LOCAL_DEV(serviceUrl)) {
return STAGING_LINK_META_PROXY
} else if (IS_STAGING(serviceUrl)) {
return STAGING_LINK_META_PROXY
} else {
if (IS_PROD_SERVICE(serviceUrl)) {
return PROD_LINK_META_PROXY
}
return STAGING_LINK_META_PROXY
}
export const STATUS_PAGE_URL = 'https://status.bsky.app/'
@@ -158,3 +75,9 @@ export const HITSLOP_20 = createHitslop(20)
export const HITSLOP_30 = createHitslop(30)
export const BACK_HITSLOP = HITSLOP_30
export const MAX_POST_LINES = 25
export const BSKY_FEED_OWNER_DIDS = [
'did:plc:z72i7hdynmk6r22z27h6tvur',
'did:plc:vpkhqolt662uhesyj6nxm7ys',
'did:plc:q6gjnaw2blty4crticxkmujt',
]
-256
View File
@@ -1,256 +0,0 @@
import {CountryCode} from 'libphonenumber-js'
// ISO 3166-1 alpha-2 codes
export interface CountryCodeMap {
code2: CountryCode
name: string
}
export const COUNTRY_CODES: CountryCodeMap[] = [
{code2: 'AF', name: 'Afghanistan (+93)'},
{code2: 'AX', name: 'Åland Islands (+358)'},
{code2: 'AL', name: 'Albania (+355)'},
{code2: 'DZ', name: 'Algeria (+213)'},
{code2: 'AS', name: 'American Samoa (+1)'},
{code2: 'AD', name: 'Andorra (+376)'},
{code2: 'AO', name: 'Angola (+244)'},
{code2: 'AI', name: 'Anguilla (+1)'},
{code2: 'AG', name: 'Antigua and Barbuda (+1)'},
{code2: 'AR', name: 'Argentina (+54)'},
{code2: 'AM', name: 'Armenia (+374)'},
{code2: 'AW', name: 'Aruba (+297)'},
{code2: 'AU', name: 'Australia (+61)'},
{code2: 'AT', name: 'Austria (+43)'},
{code2: 'AZ', name: 'Azerbaijan (+994)'},
{code2: 'BS', name: 'Bahamas (+1)'},
{code2: 'BH', name: 'Bahrain (+973)'},
{code2: 'BD', name: 'Bangladesh (+880)'},
{code2: 'BB', name: 'Barbados (+1)'},
{code2: 'BY', name: 'Belarus (+375)'},
{code2: 'BE', name: 'Belgium (+32)'},
{code2: 'BZ', name: 'Belize (+501)'},
{code2: 'BJ', name: 'Benin (+229)'},
{code2: 'BM', name: 'Bermuda (+1)'},
{code2: 'BT', name: 'Bhutan (+975)'},
{code2: 'BO', name: 'Bolivia (Plurinational State of) (+591)'},
{code2: 'BQ', name: 'Bonaire, Sint Eustatius and Saba (+599)'},
{code2: 'BA', name: 'Bosnia and Herzegovina (+387)'},
{code2: 'BW', name: 'Botswana (+267)'},
{code2: 'BR', name: 'Brazil (+55)'},
{code2: 'IO', name: 'British Indian Ocean Territory (+246)'},
{code2: 'BN', name: 'Brunei Darussalam (+673)'},
{code2: 'BG', name: 'Bulgaria (+359)'},
{code2: 'BF', name: 'Burkina Faso (+226)'},
{code2: 'BI', name: 'Burundi (+257)'},
{code2: 'CV', name: 'Cabo Verde (+238)'},
{code2: 'KH', name: 'Cambodia (+855)'},
{code2: 'CM', name: 'Cameroon (+237)'},
{code2: 'CA', name: 'Canada (+1)'},
{code2: 'KY', name: 'Cayman Islands (+1)'},
{code2: 'CF', name: 'Central African Republic (+236)'},
{code2: 'TD', name: 'Chad (+235)'},
{code2: 'CL', name: 'Chile (+56)'},
{code2: 'CN', name: 'China (+86)'},
{code2: 'CX', name: 'Christmas Island (+61)'},
{code2: 'CC', name: 'Cocos (Keeling) Islands (+61)'},
{code2: 'CO', name: 'Colombia (+57)'},
{code2: 'KM', name: 'Comoros (+269)'},
{code2: 'CG', name: 'Congo (+242)'},
{code2: 'CD', name: 'Congo, Democratic Republic of the (+243)'},
{code2: 'CK', name: 'Cook Islands (+682)'},
{code2: 'CR', name: 'Costa Rica (+506)'},
{code2: 'CI', name: "Côte d'Ivoire (+225)"},
{code2: 'HR', name: 'Croatia (+385)'},
{code2: 'CU', name: 'Cuba (+53)'},
{code2: 'CW', name: 'Curaçao (+599)'},
{code2: 'CY', name: 'Cyprus (+357)'},
{code2: 'CZ', name: 'Czechia (+420)'},
{code2: 'DK', name: 'Denmark (+45)'},
{code2: 'DJ', name: 'Djibouti (+253)'},
{code2: 'DM', name: 'Dominica (+1)'},
{code2: 'DO', name: 'Dominican Republic (+1)'},
{code2: 'EC', name: 'Ecuador (+593)'},
{code2: 'EG', name: 'Egypt (+20)'},
{code2: 'SV', name: 'El Salvador (+503)'},
{code2: 'GQ', name: 'Equatorial Guinea (+240)'},
{code2: 'ER', name: 'Eritrea (+291)'},
{code2: 'EE', name: 'Estonia (+372)'},
{code2: 'SZ', name: 'Eswatini (+268)'},
{code2: 'ET', name: 'Ethiopia (+251)'},
{code2: 'FK', name: 'Falkland Islands (Malvinas) (+500)'},
{code2: 'FO', name: 'Faroe Islands (+298)'},
{code2: 'FJ', name: 'Fiji (+679)'},
{code2: 'FI', name: 'Finland (+358)'},
{code2: 'FR', name: 'France (+33)'},
{code2: 'GF', name: 'French Guiana (+594)'},
{code2: 'PF', name: 'French Polynesia (+689)'},
{code2: 'GA', name: 'Gabon (+241)'},
{code2: 'GM', name: 'Gambia (+220)'},
{code2: 'GE', name: 'Georgia (+995)'},
{code2: 'DE', name: 'Germany (+49)'},
{code2: 'GH', name: 'Ghana (+233)'},
{code2: 'GI', name: 'Gibraltar (+350)'},
{code2: 'GR', name: 'Greece (+30)'},
{code2: 'GL', name: 'Greenland (+299)'},
{code2: 'GD', name: 'Grenada (+1)'},
{code2: 'GP', name: 'Guadeloupe (+590)'},
{code2: 'GU', name: 'Guam (+1)'},
{code2: 'GT', name: 'Guatemala (+502)'},
{code2: 'GG', name: 'Guernsey (+44)'},
{code2: 'GN', name: 'Guinea (+224)'},
{code2: 'GW', name: 'Guinea-Bissau (+245)'},
{code2: 'GY', name: 'Guyana (+592)'},
{code2: 'HT', name: 'Haiti (+509)'},
{code2: 'VA', name: 'Holy See (+39)'},
{code2: 'HN', name: 'Honduras (+504)'},
{code2: 'HK', name: 'Hong Kong (+852)'},
{code2: 'HU', name: 'Hungary (+36)'},
{code2: 'IS', name: 'Iceland (+354)'},
{code2: 'IN', name: 'India (+91)'},
{code2: 'ID', name: 'Indonesia (+62)'},
{code2: 'IR', name: 'Iran (Islamic Republic of) (+98)'},
{code2: 'IQ', name: 'Iraq (+964)'},
{code2: 'IE', name: 'Ireland (+353)'},
{code2: 'IM', name: 'Isle of Man (+44)'},
{code2: 'IL', name: 'Israel (+972)'},
{code2: 'IT', name: 'Italy (+39)'},
{code2: 'JM', name: 'Jamaica (+1)'},
{code2: 'JP', name: 'Japan (+81)'},
{code2: 'JE', name: 'Jersey (+44)'},
{code2: 'JO', name: 'Jordan (+962)'},
{code2: 'KZ', name: 'Kazakhstan (+7)'},
{code2: 'KE', name: 'Kenya (+254)'},
{code2: 'KI', name: 'Kiribati (+686)'},
{code2: 'KP', name: "Korea (Democratic People's Republic of) (+850)"},
{code2: 'KR', name: 'Korea, Republic of (+82)'},
{code2: 'KW', name: 'Kuwait (+965)'},
{code2: 'KG', name: 'Kyrgyzstan (+996)'},
{code2: 'LA', name: "Lao People's Democratic Republic (+856)"},
{code2: 'LV', name: 'Latvia (+371)'},
{code2: 'LB', name: 'Lebanon (+961)'},
{code2: 'LS', name: 'Lesotho (+266)'},
{code2: 'LR', name: 'Liberia (+231)'},
{code2: 'LY', name: 'Libya (+218)'},
{code2: 'LI', name: 'Liechtenstein (+423)'},
{code2: 'LT', name: 'Lithuania (+370)'},
{code2: 'LU', name: 'Luxembourg (+352)'},
{code2: 'MO', name: 'Macao (+853)'},
{code2: 'MG', name: 'Madagascar (+261)'},
{code2: 'MW', name: 'Malawi (+265)'},
{code2: 'MY', name: 'Malaysia (+60)'},
{code2: 'MV', name: 'Maldives (+960)'},
{code2: 'ML', name: 'Mali (+223)'},
{code2: 'MT', name: 'Malta (+356)'},
{code2: 'MH', name: 'Marshall Islands (+692)'},
{code2: 'MQ', name: 'Martinique (+596)'},
{code2: 'MR', name: 'Mauritania (+222)'},
{code2: 'MU', name: 'Mauritius (+230)'},
{code2: 'YT', name: 'Mayotte (+262)'},
{code2: 'MX', name: 'Mexico (+52)'},
{code2: 'FM', name: 'Micronesia (Federated States of) (+691)'},
{code2: 'MD', name: 'Moldova, Republic of (+373)'},
{code2: 'MC', name: 'Monaco (+377)'},
{code2: 'MN', name: 'Mongolia (+976)'},
{code2: 'ME', name: 'Montenegro (+382)'},
{code2: 'MS', name: 'Montserrat (+1)'},
{code2: 'MA', name: 'Morocco (+212)'},
{code2: 'MZ', name: 'Mozambique (+258)'},
{code2: 'MM', name: 'Myanmar (+95)'},
{code2: 'NA', name: 'Namibia (+264)'},
{code2: 'NR', name: 'Nauru (+674)'},
{code2: 'NP', name: 'Nepal (+977)'},
{code2: 'NL', name: 'Netherlands, Kingdom of the (+31)'},
{code2: 'NC', name: 'New Caledonia (+687)'},
{code2: 'NZ', name: 'New Zealand (+64)'},
{code2: 'NI', name: 'Nicaragua (+505)'},
{code2: 'NE', name: 'Niger (+227)'},
{code2: 'NG', name: 'Nigeria (+234)'},
{code2: 'NU', name: 'Niue (+683)'},
{code2: 'NF', name: 'Norfolk Island (+672)'},
{code2: 'MK', name: 'North Macedonia (+389)'},
{code2: 'MP', name: 'Northern Mariana Islands (+1)'},
{code2: 'NO', name: 'Norway (+47)'},
{code2: 'OM', name: 'Oman (+968)'},
{code2: 'PK', name: 'Pakistan (+92)'},
{code2: 'PW', name: 'Palau (+680)'},
{code2: 'PS', name: 'Palestine, State of (+970)'},
{code2: 'PA', name: 'Panama (+507)'},
{code2: 'PG', name: 'Papua New Guinea (+675)'},
{code2: 'PY', name: 'Paraguay (+595)'},
{code2: 'PE', name: 'Peru (+51)'},
{code2: 'PH', name: 'Philippines (+63)'},
{code2: 'PL', name: 'Poland (+48)'},
{code2: 'PT', name: 'Portugal (+351)'},
{code2: 'PR', name: 'Puerto Rico (+1)'},
{code2: 'QA', name: 'Qatar (+974)'},
{code2: 'RE', name: 'Réunion (+262)'},
{code2: 'RO', name: 'Romania (+40)'},
{code2: 'RU', name: 'Russian Federation (+7)'},
{code2: 'RW', name: 'Rwanda (+250)'},
{code2: 'BL', name: 'Saint Barthélemy (+590)'},
{code2: 'SH', name: 'Saint Helena, Ascension and Tristan da Cunha (+290)'},
{code2: 'KN', name: 'Saint Kitts and Nevis (+1)'},
{code2: 'LC', name: 'Saint Lucia (+1)'},
{code2: 'MF', name: 'Saint Martin (French part) (+590)'},
{code2: 'PM', name: 'Saint Pierre and Miquelon (+508)'},
{code2: 'VC', name: 'Saint Vincent and the Grenadines (+1)'},
{code2: 'WS', name: 'Samoa (+685)'},
{code2: 'SM', name: 'San Marino (+378)'},
{code2: 'ST', name: 'Sao Tome and Principe (+239)'},
{code2: 'SA', name: 'Saudi Arabia (+966)'},
{code2: 'SN', name: 'Senegal (+221)'},
{code2: 'RS', name: 'Serbia (+381)'},
{code2: 'SC', name: 'Seychelles (+248)'},
{code2: 'SL', name: 'Sierra Leone (+232)'},
{code2: 'SG', name: 'Singapore (+65)'},
{code2: 'SX', name: 'Sint Maarten (Dutch part) (+1)'},
{code2: 'SK', name: 'Slovakia (+421)'},
{code2: 'SI', name: 'Slovenia (+386)'},
{code2: 'SB', name: 'Solomon Islands (+677)'},
{code2: 'SO', name: 'Somalia (+252)'},
{code2: 'ZA', name: 'South Africa (+27)'},
{code2: 'SS', name: 'South Sudan (+211)'},
{code2: 'ES', name: 'Spain (+34)'},
{code2: 'LK', name: 'Sri Lanka (+94)'},
{code2: 'SD', name: 'Sudan (+249)'},
{code2: 'SR', name: 'Suriname (+597)'},
{code2: 'SJ', name: 'Svalbard and Jan Mayen (+47)'},
{code2: 'SE', name: 'Sweden (+46)'},
{code2: 'CH', name: 'Switzerland (+41)'},
{code2: 'SY', name: 'Syrian Arab Republic (+963)'},
{code2: 'TW', name: 'Taiwan (+886)'},
{code2: 'TJ', name: 'Tajikistan (+992)'},
{code2: 'TZ', name: 'Tanzania, United Republic of (+255)'},
{code2: 'TH', name: 'Thailand (+66)'},
{code2: 'TL', name: 'Timor-Leste (+670)'},
{code2: 'TG', name: 'Togo (+228)'},
{code2: 'TK', name: 'Tokelau (+690)'},
{code2: 'TO', name: 'Tonga (+676)'},
{code2: 'TT', name: 'Trinidad and Tobago (+1)'},
{code2: 'TN', name: 'Tunisia (+216)'},
{code2: 'TR', name: 'Türkiye (+90)'},
{code2: 'TM', name: 'Turkmenistan (+993)'},
{code2: 'TC', name: 'Turks and Caicos Islands (+1)'},
{code2: 'TV', name: 'Tuvalu (+688)'},
{code2: 'UG', name: 'Uganda (+256)'},
{code2: 'UA', name: 'Ukraine (+380)'},
{code2: 'AE', name: 'United Arab Emirates (+971)'},
{
code2: 'GB',
name: 'United Kingdom of Great Britain and Northern Ireland (+44)',
},
{code2: 'US', name: 'United States of America (+1)'},
{code2: 'UY', name: 'Uruguay (+598)'},
{code2: 'UZ', name: 'Uzbekistan (+998)'},
{code2: 'VU', name: 'Vanuatu (+678)'},
{code2: 'VE', name: 'Venezuela (Bolivarian Republic of) (+58)'},
{code2: 'VN', name: 'Viet Nam (+84)'},
{code2: 'VG', name: 'Virgin Islands (British) (+1)'},
{code2: 'VI', name: 'Virgin Islands (U.S.) (+1)'},
{code2: 'WF', name: 'Wallis and Futuna (+681)'},
{code2: 'EH', name: 'Western Sahara (+212)'},
{code2: 'YE', name: 'Yemen (+967)'},
{code2: 'ZM', name: 'Zambia (+260)'},
{code2: 'ZW', name: 'Zimbabwe (+263)'},
]
+11
View File
@@ -0,0 +1,11 @@
import React from 'react'
import {Dimensions} from 'react-native'
const MIN_POST_HEIGHT = 100
export function useInitialNumToRender(minItemHeight: number = MIN_POST_HEIGHT) {
return React.useMemo(() => {
const screenHeight = Dimensions.get('window').height
return Math.ceil(screenHeight / minItemHeight) + 1
}, [minItemHeight])
}
+93
View File
@@ -0,0 +1,93 @@
import React from 'react'
import * as Linking from 'expo-linking'
import {isNative} from 'platform/detection'
import {useComposerControls} from 'state/shell'
import {useSession} from 'state/session'
import {useCloseAllActiveElements} from 'state/util'
type IntentType = 'compose'
const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
export function useIntentHandler() {
const incomingUrl = Linking.useURL()
const composeIntent = useComposeIntent()
React.useEffect(() => {
const handleIncomingURL = (url: string) => {
// We want to be able to support bluesky:// deeplinks. It's unnatural for someone to use a deeplink with three
// slashes, like bluesky:///intent/follow. However, supporting just two slashes causes us to have to take care
// of two cases when parsing the url. If we ensure there is a third slash, we can always ensure the first
// path parameter is in pathname rather than in hostname.
if (url.startsWith('bluesky://') && !url.startsWith('bluesky:///')) {
url = url.replace('bluesky://', 'bluesky:///')
}
const urlp = new URL(url)
const [_, intent, intentType] = urlp.pathname.split('/')
// On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the
// intent check. On web, we have to check the first part of the path since we have an actual hostname
const isIntent = intent === 'intent'
const params = urlp.searchParams
if (!isIntent) return
switch (intentType as IntentType) {
case 'compose': {
composeIntent({
text: params.get('text'),
imageUrisStr: params.get('imageUris'),
})
}
}
}
if (incomingUrl) handleIncomingURL(incomingUrl)
}, [incomingUrl, composeIntent])
}
function useComposeIntent() {
const closeAllActiveElements = useCloseAllActiveElements()
const {openComposer} = useComposerControls()
const {hasSession} = useSession()
return React.useCallback(
({
text,
imageUrisStr,
}: {
text: string | null
imageUrisStr: string | null // unused for right now, will be used later with intents
}) => {
if (!hasSession) return
closeAllActiveElements()
const imageUris = imageUrisStr
?.split(',')
.filter(part => {
// For some security, we're going to filter out any image uri that is external. We don't want someone to
// be able to provide some link like "bluesky://intent/compose?imageUris=https://IHaveYourIpNow.com/image.jpeg
// and we load that image
if (part.includes('https://') || part.includes('http://')) {
return false
}
// We also should just filter out cases that don't have all the info we need
return VALID_IMAGE_REGEX.test(part)
})
.map(part => {
const [uri, width, height] = part.split('|')
return {uri, width: Number(width), height: Number(height)}
})
setTimeout(() => {
openComposer({
text: text ?? undefined,
imageUris: isNative ? imageUris : undefined,
})
}, 500)
},
[hasSession, closeAllActiveElements, openComposer],
)
}
+1 -1
View File
@@ -26,7 +26,7 @@ export interface LinkMeta {
export async function getLinkMeta(
agent: BskyAgent,
url: string,
timeout = 5e3,
timeout = 15e3,
): Promise<LinkMeta> {
if (isBskyAppUrl(url)) {
return extractBskyMeta(agent, url)
+323 -1
View File
@@ -2,19 +2,152 @@ import {
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
moderatePost,
AppBskyActorDefs,
AppBskyFeedPost,
AppBskyRichtextFacet,
AppBskyEmbedImages,
AppBskyEmbedExternal,
} from '@atproto/api'
type ModeratePost = typeof moderatePost
type Options = Parameters<ModeratePost>[1] & {
hiddenPosts?: string[]
mutedWords?: AppBskyActorDefs.MutedWord[]
}
const REGEX = {
LEADING_TRAILING_PUNCTUATION: /(?:^\p{P}+|\p{P}+$)/gu,
ESCAPE: /[[\]{}()*+?.\\^$|\s]/g,
SEPARATORS: /[\/\-\\—\(\)\[\]\_]+/g,
WORD_BOUNDARY: /[\s\n\t\r\f\v]+?/g,
}
/**
* List of 2-letter lang codes for languages that either don't use spaces, or
* don't use spaces in a way conducive to word-based filtering.
*
* For these, we use a simple `String.includes` to check for a match.
*/
const LANGUAGE_EXCEPTIONS = [
'ja', // Japanese
'zh', // Chinese
'ko', // Korean
'th', // Thai
'vi', // Vietnamese
]
export function hasMutedWord({
mutedWords,
text,
facets,
outlineTags,
languages,
isOwnPost,
}: {
mutedWords: AppBskyActorDefs.MutedWord[]
text: string
facets?: AppBskyRichtextFacet.Main[]
outlineTags?: string[]
languages?: string[]
isOwnPost: boolean
}) {
if (isOwnPost) return false
const exception = LANGUAGE_EXCEPTIONS.includes(languages?.[0] || '')
const tags = ([] as string[])
.concat(outlineTags || [])
.concat(
facets
?.filter(facet => {
return facet.features.find(feature =>
AppBskyRichtextFacet.isTag(feature),
)
})
.map(t => t.features[0].tag as string) || [],
)
.map(t => t.toLowerCase())
for (const mute of mutedWords) {
const mutedWord = mute.value.toLowerCase()
const postText = text.toLowerCase()
// `content` applies to tags as well
if (tags.includes(mutedWord)) return true
// rest of the checks are for `content` only
if (!mute.targets.includes('content')) continue
// single character or other exception, has to use includes
if ((mutedWord.length === 1 || exception) && postText.includes(mutedWord))
return true
// too long
if (mutedWord.length > postText.length) continue
// exact match
if (mutedWord === postText) return true
// any muted phrase with space or punctuation
if (/(?:\s|\p{P})+?/u.test(mutedWord) && postText.includes(mutedWord))
return true
// check individual character groups
const words = postText.split(REGEX.WORD_BOUNDARY)
for (const word of words) {
if (word === mutedWord) return true
// compare word without leading/trailing punctuation, but allow internal
// punctuation (such as `s@ssy`)
const wordTrimmedPunctuation = word.replace(
REGEX.LEADING_TRAILING_PUNCTUATION,
'',
)
if (mutedWord === wordTrimmedPunctuation) return true
if (mutedWord.length > wordTrimmedPunctuation.length) continue
// handle hyphenated, slash separated words, etc
if (REGEX.SEPARATORS.test(wordTrimmedPunctuation)) {
// check against full normalized phrase
const wordNormalizedSeparators = wordTrimmedPunctuation.replace(
REGEX.SEPARATORS,
' ',
)
const mutedWordNormalizedSeparators = mutedWord.replace(
REGEX.SEPARATORS,
' ',
)
// hyphenated (or other sep) to spaced words
if (wordNormalizedSeparators === mutedWordNormalizedSeparators)
return true
/* Disabled for now e.g. `super-cool` to `supercool`
const wordNormalizedCompressed = wordNormalizedSeparators.replace(
REGEX.WORD_BOUNDARY,
'',
)
const mutedWordNormalizedCompressed =
mutedWordNormalizedSeparators.replace(/\s+?/g, '')
// hyphenated (or other sep) to non-hyphenated contiguous word
if (mutedWordNormalizedCompressed === wordNormalizedCompressed)
return true
*/
// then individual parts of separated phrases/words
const wordParts = wordTrimmedPunctuation.split(REGEX.SEPARATORS)
for (const wp of wordParts) {
// still retain internal punctuation
if (wp === mutedWord) return true
}
}
}
}
return false
}
export function moderatePost_wrapped(
subject: Parameters<ModeratePost>[0],
opts: Options,
) {
const {hiddenPosts = [], ...options} = opts
const {hiddenPosts = [], mutedWords = [], ...options} = opts
const moderations = moderatePost(subject, options)
const isOwnPost = subject.author.did === opts.userDid
if (hiddenPosts.includes(subject.uri)) {
moderations.content.filter = true
@@ -29,8 +162,53 @@ export function moderatePost_wrapped(
}
}
if (AppBskyFeedPost.isRecord(subject.record)) {
let muted = hasMutedWord({
mutedWords,
text: subject.record.text,
facets: subject.record.facets || [],
outlineTags: subject.record.tags || [],
languages: subject.record.langs,
isOwnPost,
})
if (
subject.record.embed &&
AppBskyEmbedImages.isMain(subject.record.embed)
) {
for (const image of subject.record.embed.images) {
muted =
muted ||
hasMutedWord({
mutedWords,
text: image.alt,
facets: [],
outlineTags: [],
languages: subject.record.langs,
isOwnPost,
})
}
}
if (muted) {
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,
}
}
}
}
if (subject.embed) {
let embedHidden = false
let embedMuted = false
let externalMuted = false
if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
embedHidden = hiddenPosts.includes(subject.embed.record.uri)
}
@@ -40,6 +218,139 @@ export function moderatePost_wrapped(
) {
embedHidden = hiddenPosts.includes(subject.embed.record.record.uri)
}
if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
if (AppBskyFeedPost.isRecord(subject.embed.record.value)) {
const embeddedPost = subject.embed.record.value
embedMuted =
embedMuted ||
hasMutedWord({
mutedWords,
text: embeddedPost.text,
facets: embeddedPost.facets,
outlineTags: embeddedPost.tags,
languages: embeddedPost.langs,
isOwnPost,
})
if (AppBskyEmbedImages.isMain(embeddedPost.embed)) {
for (const image of embeddedPost.embed.images) {
embedMuted =
embedMuted ||
hasMutedWord({
mutedWords,
text: image.alt,
facets: [],
outlineTags: [],
languages: embeddedPost.langs,
isOwnPost,
})
}
}
if (AppBskyEmbedExternal.isMain(embeddedPost.embed)) {
const {external} = embeddedPost.embed
embedMuted =
embedMuted ||
hasMutedWord({
mutedWords,
text: external.title + ' ' + external.description,
facets: [],
outlineTags: [],
languages: [],
isOwnPost,
})
}
if (AppBskyEmbedRecordWithMedia.isMain(embeddedPost.embed)) {
if (AppBskyEmbedExternal.isMain(embeddedPost.embed.media)) {
const {external} = embeddedPost.embed.media
embedMuted =
embedMuted ||
hasMutedWord({
mutedWords,
text: external.title + ' ' + external.description,
facets: [],
outlineTags: [],
languages: [],
isOwnPost,
})
}
if (AppBskyEmbedImages.isMain(embeddedPost.embed.media)) {
for (const image of embeddedPost.embed.media.images) {
embedMuted =
embedMuted ||
hasMutedWord({
mutedWords,
text: image.alt,
facets: [],
outlineTags: [],
languages: AppBskyFeedPost.isRecord(embeddedPost.record)
? embeddedPost.langs
: [],
isOwnPost,
})
}
}
}
}
}
if (AppBskyEmbedExternal.isView(subject.embed)) {
const {external} = subject.embed
externalMuted =
externalMuted ||
hasMutedWord({
mutedWords,
text: external.title + ' ' + external.description,
facets: [],
outlineTags: [],
languages: [],
isOwnPost,
})
}
if (
AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
) {
if (AppBskyFeedPost.isRecord(subject.embed.record.record.value)) {
const post = subject.embed.record.record.value
embedMuted =
embedMuted ||
hasMutedWord({
mutedWords,
text: post.text,
facets: post.facets,
outlineTags: post.tags,
languages: post.langs,
isOwnPost,
})
}
if (AppBskyEmbedImages.isView(subject.embed.media)) {
for (const image of subject.embed.media.images) {
embedMuted =
embedMuted ||
hasMutedWord({
mutedWords,
text: image.alt,
facets: [],
outlineTags: [],
languages: AppBskyFeedPost.isRecord(subject.record)
? subject.record.langs
: [],
isOwnPost,
})
}
}
}
if (embedHidden) {
moderations.embed.filter = true
moderations.embed.blur = true
@@ -51,6 +362,17 @@ export function moderatePost_wrapped(
priority: 1,
}
}
} else if (externalMuted || embedMuted) {
moderations.content.filter = true
moderations.content.blur = true
if (!moderations.content.cause) {
moderations.content.cause = {
// @ts-ignore Temporary extension to the moderation system -prf
type: 'muted-word',
source: {type: 'user'},
priority: 1,
}
}
}
}
+7
View File
@@ -67,6 +67,13 @@ export function describeModerationCause(
description: 'You have hidden this post',
}
}
// @ts-ignore Temporary extension to the moderation system -prf
if (cause.type === 'muted-word') {
return {
name: 'Post hidden by muted word',
description: `You've chosen to hide a word or tag within this post.`,
}
}
return cause.labelDef.strings[context].en
}
+10
View File
@@ -25,3 +25,13 @@ export function makeCustomFeedLink(
export function makeListLink(did: string, rkey: string, ...segments: string[]) {
return [`/profile`, did, 'lists', rkey, ...segments].join('/')
}
export function makeTagLink(did: string) {
return `/search?q=${encodeURIComponent(did)}`
}
export function makeSearchLink(props: {query: string; from?: 'me' | string}) {
return `/search?q=${encodeURIComponent(
props.query + (props.from ? ` from:${props.from}` : ''),
)}`
}
+5 -1
View File
@@ -30,9 +30,11 @@ export type CommonNavigatorParams = {
CopyrightPolicy: undefined
AppPasswords: undefined
SavedFeeds: undefined
PreferencesHomeFeed: undefined
PreferencesFollowingFeed: undefined
PreferencesThreads: undefined
PreferencesExternalEmbeds: undefined
Search: {q?: string}
Hashtag: {tag: string; author?: string}
}
export type BottomTabNavigatorParams = CommonNavigatorParams & {
@@ -68,6 +70,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
Search: {q?: string}
Feeds: undefined
Notifications: undefined
Hashtag: {tag: string; author?: string}
}
export type AllNavigatorParams = CommonNavigatorParams & {
@@ -80,6 +83,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
NotificationsTab: undefined
Notifications: undefined
MyProfileTab: undefined
Hashtag: {tag: string; author?: string}
}
// NOTE
+63
View File
@@ -0,0 +1,63 @@
import React from 'react'
import {
Statsig,
StatsigProvider,
useGate as useStatsigGate,
} from 'statsig-react-native-expo'
import {useSession} from '../../state/session'
import {sha256} from 'js-sha256'
const statsigOptions = {
environment: {
tier: process.env.NODE_ENV === 'development' ? 'development' : 'production',
},
// Don't block on waiting for network. The fetched config will kick in on next load.
// This ensures the UI is always consistent and doesn't update mid-session.
// Note this makes cold load (no local storage) and private mode return `false` for all gates.
initTimeoutMs: 1,
}
export function logEvent(
eventName: string,
value?: string | number | null,
metadata?: Record<string, string> | null,
) {
Statsig.logEvent(eventName, value, metadata)
}
export function useGate(gateName: string) {
const {isLoading, value} = useStatsigGate(gateName)
if (isLoading) {
// This should not happen because of waitForInitialization={true}.
console.error('Did not expected isLoading to ever be true.')
}
return value
}
function toStatsigUser(did: string | undefined) {
let userID: string | undefined
if (did) {
userID = sha256(did)
}
return {userID}
}
export function Provider({children}: {children: React.ReactNode}) {
const {currentAccount} = useSession()
const currentStatsigUser = React.useMemo(
() => toStatsigUser(currentAccount?.did),
[currentAccount?.did],
)
return (
<StatsigProvider
sdkKey="client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV"
mountKey={currentStatsigUser.userID}
user={currentStatsigUser}
// This isn't really blocking due to short initTimeoutMs above.
// However, it ensures `isLoading` is always `false`.
waitForInitialization={true}
options={statsigOptions}>
{children}
</StatsigProvider>
)
}
+63
View File
@@ -0,0 +1,63 @@
import React from 'react'
import {
Statsig,
StatsigProvider,
useGate as useStatsigGate,
} from 'statsig-react'
import {useSession} from '../../state/session'
import {sha256} from 'js-sha256'
const statsigOptions = {
environment: {
tier: process.env.NODE_ENV === 'development' ? 'development' : 'production',
},
// Don't block on waiting for network. The fetched config will kick in on next load.
// This ensures the UI is always consistent and doesn't update mid-session.
// Note this makes cold load (no local storage) and private mode return `false` for all gates.
initTimeoutMs: 1,
}
export function logEvent(
eventName: string,
value?: string | number | null,
metadata?: Record<string, string> | null,
) {
Statsig.logEvent(eventName, value, metadata)
}
export function useGate(gateName: string) {
const {isLoading, value} = useStatsigGate(gateName)
if (isLoading) {
// This should not happen because of waitForInitialization={true}.
console.error('Did not expected isLoading to ever be true.')
}
return value
}
function toStatsigUser(did: string | undefined) {
let userID: string | undefined
if (did) {
userID = sha256(did)
}
return {userID}
}
export function Provider({children}: {children: React.ReactNode}) {
const {currentAccount} = useSession()
const currentStatsigUser = React.useMemo(
() => toStatsigUser(currentAccount?.did),
[currentAccount?.did],
)
return (
<StatsigProvider
sdkKey="client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV"
mountKey={currentStatsigUser.userID}
user={currentStatsigUser}
// This isn't really blocking due to short initTimeoutMs above.
// However, it ensures `isLoading` is always `false`.
waitForInitialization={true}
options={statsigOptions}>
{children}
</StatsigProvider>
)
}
+14 -14
View File
@@ -343,45 +343,45 @@ export function parseEmbedPlayerFromUrl(
}
}
export function getPlayerHeight({
export function getPlayerAspect({
type,
width,
hasThumb,
width,
}: {
type: EmbedPlayerParams['type']
width: number
hasThumb: boolean
}) {
if (!hasThumb) return (width / 16) * 9
width: number
}): {aspectRatio?: number; height?: number} {
if (!hasThumb) return {aspectRatio: 16 / 9}
switch (type) {
case 'youtube_video':
case 'twitch_video':
case 'vimeo_video':
return (width / 16) * 9
return {aspectRatio: 16 / 9}
case 'youtube_short':
if (SCREEN_HEIGHT < 600) {
return ((width / 9) * 16) / 1.75
return {aspectRatio: (9 / 16) * 1.75}
} else {
return ((width / 9) * 16) / 1.5
return {aspectRatio: (9 / 16) * 1.5}
}
case 'spotify_album':
case 'apple_music_album':
case 'apple_music_playlist':
case 'spotify_playlist':
case 'soundcloud_set':
return 380
return {height: 380}
case 'spotify_song':
if (width <= 300) {
return 155
return {height: 155}
}
return 232
return {height: 232}
case 'soundcloud_track':
return 165
return {height: 165}
case 'apple_music_song':
return 150
return {height: 150}
default:
return width
return {aspectRatio: 16 / 9}
}
}
+29
View File
@@ -1,3 +1,8 @@
// Regex from the go implementation
// https://github.com/bluesky-social/indigo/blob/main/atproto/syntax/handle.go#L10
const VALIDATE_REGEX =
/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
export function makeValidHandle(str: string): string {
if (str.length > 20) {
str = str.slice(0, 20)
@@ -19,3 +24,27 @@ export function isInvalidHandle(handle: string): boolean {
export function sanitizeHandle(handle: string, prefix = ''): string {
return isInvalidHandle(handle) ? '⚠Invalid Handle' : `${prefix}${handle}`
}
export interface IsValidHandle {
handleChars: boolean
frontLength: boolean
totalLength: boolean
overall: boolean
}
// More checks from https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/handle/index.ts#L72
export function validateHandle(str: string, userDomain: string): IsValidHandle {
const fullHandle = createFullHandle(str, userDomain)
const results = {
handleChars:
!str || (VALIDATE_REGEX.test(fullHandle) && !str.includes('.')),
frontLength: str.length >= 3,
totalLength: fullHandle.length <= 253,
}
return {
...results,
overall: !Object.values(results).includes(false),
}
}
+19 -2
View File
@@ -8,10 +8,27 @@ export function pluralize(n: number, base: string, plural?: string): string {
return base + 's'
}
export function enforceLen(str: string, len: number, ellipsis = false): string {
export function enforceLen(
str: string,
len: number,
ellipsis = false,
mode: 'end' | 'middle' = 'end',
): string {
str = str || ''
if (str.length > len) {
return str.slice(0, len) + (ellipsis ? '...' : '')
if (ellipsis) {
if (mode === 'end') {
return str.slice(0, len) + '…'
} else if (mode === 'middle') {
const half = Math.floor(len / 2)
return str.slice(0, half) + '…' + str.slice(-half)
} else {
// fallback
return str.slice(0, len)
}
} else {
return str.slice(0, len)
}
}
return str
}
+1 -1
View File
@@ -23,7 +23,7 @@ export function ago(date: number | string | Date): string {
} else if (diffSeconds < DAY) {
return `${Math.floor(diffSeconds / HOUR)}h`
} else if (diffSeconds < MONTH) {
return `${Math.floor(diffSeconds / DAY)}d`
return `${Math.round(diffSeconds / DAY)}d`
} else if (diffSeconds < YEAR) {
return `${Math.floor(diffSeconds / MONTH)}mo`
} else {
+14 -12
View File
@@ -1,5 +1,5 @@
import {AtUri} from '@atproto/api'
import {PROD_SERVICE} from 'lib/constants'
import {BSKY_SERVICE} from 'lib/constants'
import TLDs from 'tlds'
import psl from 'psl'
@@ -28,7 +28,7 @@ export function makeRecordUri(
export function toNiceDomain(url: string): string {
try {
const urlp = new URL(url)
if (`https://${urlp.host}` === PROD_SERVICE) {
if (`https://${urlp.host}` === BSKY_SERVICE) {
return 'Bluesky Social'
}
return urlp.host ? urlp.host : url
@@ -148,6 +148,11 @@ export function feedUriToHref(url: string): string {
export function linkRequiresWarning(uri: string, label: string) {
const labelDomain = labelToDomain(label)
// If the uri started with a / we know it is internal.
if (uri.startsWith('/')) {
return false
}
let urip
try {
urip = new URL(uri)
@@ -156,18 +161,15 @@ export function linkRequiresWarning(uri: string, label: string) {
}
const host = urip.hostname.toLowerCase()
if (host === 'bsky.app') {
// Hosts that end with bsky.app or bsky.social should be trusted by default.
if (
host.endsWith('bsky.app') ||
host.endsWith('bsky.social') ||
host.endsWith('blueskyweb.xyz')
) {
// if this is a link to internal content,
// warn if it represents itself as a URL to another app
if (
labelDomain &&
labelDomain !== 'bsky.app' &&
isPossiblyAUrl(labelDomain)
) {
return true
}
return false
return !!labelDomain && labelDomain !== host && isPossiblyAUrl(labelDomain)
} else {
// if this is a link to external content,
// warn if the label doesnt match the target
+20 -1
View File
@@ -306,7 +306,7 @@ export const darkTheme: Theme = {
// non-standard
textVeryLight: darkPalette.contrast_400,
replyLine: darkPalette.contrast_100,
replyLine: darkPalette.contrast_200,
replyLineDot: darkPalette.contrast_200,
unreadNotifBg: darkPalette.primary_975,
unreadNotifBorder: darkPalette.primary_900,
@@ -344,6 +344,25 @@ export const dimTheme: Theme = {
default: {
...darkTheme.palette.default,
background: dimPalette.black,
backgroundLight: dimPalette.contrast_50,
text: dimPalette.white,
textLight: dimPalette.contrast_700,
textInverted: dimPalette.black,
link: dimPalette.primary_500,
border: dimPalette.contrast_100,
borderDark: dimPalette.contrast_200,
icon: dimPalette.contrast_500,
// non-standard
textVeryLight: dimPalette.contrast_400,
replyLine: dimPalette.contrast_200,
replyLineDot: dimPalette.contrast_200,
unreadNotifBg: dimPalette.primary_975,
unreadNotifBorder: dimPalette.primary_900,
postCtrl: dimPalette.contrast_500,
brandText: dimPalette.primary_500,
emptyStateIcon: dimPalette.contrast_300,
borderLinkHover: dimPalette.contrast_300,
},
},
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
// @ts-ignore Web-only. On RN, this is set by Metro.
window.__BUNDLE_START_TIME__ = performance.now()
+2 -1
View File
@@ -24,7 +24,7 @@ export const router = new Router({
Debug: '/sys/debug',
Log: '/sys/log',
AppPasswords: '/settings/app-passwords',
PreferencesHomeFeed: '/settings/home-feed',
PreferencesFollowingFeed: '/settings/following-feed',
PreferencesThreads: '/settings/threads',
PreferencesExternalEmbeds: '/settings/external-embeds',
SavedFeeds: '/settings/saved-feeds',
@@ -33,4 +33,5 @@ export const router = new Router({
TermsOfService: '/support/tos',
CommunityGuidelines: '/support/community-guidelines',
CopyrightPolicy: '/support/copyright',
Hashtag: '/hashtag/:tag',
})
+165
View File
@@ -0,0 +1,165 @@
import React from 'react'
import {ListRenderItemInfo, Pressable} from 'react-native'
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 {ArrowOutOfBox_Stroke2_Corner0_Rounded} from '#/components/icons/ArrowOutOfBox'
import {shareUrl} from 'lib/sharing'
import {HITSLOP_10} from 'lib/constants'
import {isNative} from 'platform/detection'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
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 {_} = useLingui()
const initialNumToRender = useInitialNumToRender()
const [isPTR, setIsPTR] = React.useState(false)
const fullTag = React.useMemo(() => {
return `#${decodeURIComponent(tag)}`
}, [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/${decodeURIComponent(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 (
<>
<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}
/>
}
initialNumToRender={initialNumToRender}
windowSize={11}
/>
)}
</>
)
}
@@ -114,7 +114,7 @@ export function AdultContentEnabledPref({
</Trans>
</Prompt.Description>
<Prompt.Actions>
<Prompt.Action onPress={prompt.close}>OK</Prompt.Action>
<Prompt.Action onPress={() => prompt.close()}>OK</Prompt.Action>
</Prompt.Actions>
</Prompt.Outer>
</>
+9 -3
View File
@@ -3,7 +3,6 @@ import {View} from 'react-native'
import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro'
import {IS_PROD} from '#/env'
import {atoms as a, useBreakpoints} from '#/alf'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {ListMagnifyingGlass_Stroke2_Corner0_Rounded as ListMagnifyingGlass} from '#/components/icons/ListMagnifyingGlass'
@@ -22,22 +21,29 @@ import {
import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
import {aggregateInterestItems} from '#/screens/Onboarding/util'
import {IconCircle} from '#/components/IconCircle'
import {IS_PROD_SERVICE} from 'lib/constants'
import {useSession} from 'state/session'
export function StepTopicalFeeds() {
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const {track} = useAnalytics()
const {currentAccount} = useSession()
const {state, dispatch, interestsDisplayNames} = React.useContext(Context)
const [selectedFeedUris, setSelectedFeedUris] = React.useState<string[]>([])
const [saving, setSaving] = React.useState(false)
const suggestedFeedUris = React.useMemo(() => {
if (!IS_PROD) return []
if (!IS_PROD_SERVICE(currentAccount?.service)) return []
return aggregateInterestItems(
state.interestsStepResults.selectedInterests,
state.interestsStepResults.apiResponse.suggestedFeedUris,
state.interestsStepResults.apiResponse.suggestedFeedUris.default,
).slice(0, 10)
}, [state.interestsStepResults])
}, [
currentAccount?.service,
state.interestsStepResults.apiResponse.suggestedFeedUris,
state.interestsStepResults.selectedInterests,
])
const interestsText = React.useMemo(() => {
const i = state.interestsStepResults.selectedInterests.map(
+1 -1
View File
@@ -260,7 +260,7 @@ export function reducer(
})
if (s.activeStep !== state.activeStep) {
logger.info(`onboarding: step changed`, {activeStep: state.activeStep})
logger.debug(`onboarding: step changed`, {activeStep: state.activeStep})
}
return state
+42 -9
View File
@@ -1,20 +1,32 @@
import React from 'react'
import {DialogControlProps} from '#/components/Dialog'
import {DialogControlRefProps} from '#/components/Dialog'
import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context'
const DialogContext = React.createContext<{
/**
* The currently active `useDialogControl` hooks.
*/
activeDialogs: React.MutableRefObject<
Map<string, React.MutableRefObject<DialogControlProps>>
Map<string, React.MutableRefObject<DialogControlRefProps>>
>
/**
* The currently open dialogs, referenced by their IDs, generated from
* `useId`.
*/
openDialogs: string[]
}>({
activeDialogs: {
current: new Map(),
},
openDialogs: [],
})
const DialogControlContext = React.createContext<{
closeAllDialogs(): void
closeAllDialogs(): boolean
setDialogIsOpen(id: string, isOpen: boolean): void
}>({
closeAllDialogs: () => {},
closeAllDialogs: () => false,
setDialogIsOpen: () => {},
})
export function useDialogStateContext() {
@@ -27,17 +39,38 @@ export function useDialogStateControlContext() {
export function Provider({children}: React.PropsWithChildren<{}>) {
const activeDialogs = React.useRef<
Map<string, React.MutableRefObject<DialogControlProps>>
Map<string, React.MutableRefObject<DialogControlRefProps>>
>(new Map())
const [openDialogs, setOpenDialogs] = React.useState<string[]>([])
const closeAllDialogs = React.useCallback(() => {
activeDialogs.current.forEach(dialog => dialog.current.close())
}, [])
const context = React.useMemo(() => ({activeDialogs}), [])
const controls = React.useMemo(() => ({closeAllDialogs}), [closeAllDialogs])
return openDialogs.length > 0
}, [openDialogs])
const setDialogIsOpen = React.useCallback(
(id: string, isOpen: boolean) => {
setOpenDialogs(prev => {
const filtered = prev.filter(dialogId => dialogId !== id) as string[]
return isOpen ? [...filtered, id] : filtered
})
},
[setOpenDialogs],
)
const context = React.useMemo(
() => ({activeDialogs, openDialogs}),
[openDialogs],
)
const controls = React.useMemo(
() => ({closeAllDialogs, setDialogIsOpen}),
[closeAllDialogs, setDialogIsOpen],
)
return (
<DialogContext.Provider value={context}>
<DialogControlContext.Provider value={controls}>
{children}
<GlobalDialogsProvider>{children}</GlobalDialogsProvider>
</DialogControlContext.Provider>
</DialogContext.Provider>
)
+23 -2
View File
@@ -4,11 +4,21 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
import {openPicker} from 'lib/media/picker'
import {getImageDim} from 'lib/media/manip'
interface InitialImageUri {
uri: string
width: number
height: number
}
export class GalleryModel {
images: ImageModel[] = []
constructor() {
constructor(uris?: {uri: string; width: number; height: number}[]) {
makeAutoObservable(this)
if (uris) {
this.addFromUris(uris)
}
}
get isEmpty() {
@@ -23,7 +33,7 @@ export class GalleryModel {
return this.images.some(image => image.altText.trim() === '')
}
async add(image_: Omit<RNImage, 'size'>) {
*add(image_: Omit<RNImage, 'size'>) {
if (this.size >= 4) {
return
}
@@ -86,4 +96,15 @@ export class GalleryModel {
}),
)
}
async addFromUris(uris: InitialImageUri[]) {
for (const uriObj of uris) {
this.add({
mime: 'image/jpeg',
height: uriObj.height,
width: uriObj.width,
path: uriObj.uri,
})
}
}
}
@@ -26,7 +26,7 @@ test('migrate: fresh install', async () => {
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
expect(read).toHaveBeenCalledTimes(1)
expect(logger.info).toHaveBeenCalledWith(
expect(logger.debug).toHaveBeenCalledWith(
'persisted state: no migration needed',
)
})
@@ -38,7 +38,7 @@ test('migrate: fresh install, existing new storage', async () => {
expect(AsyncStorage.getItem).toHaveBeenCalledWith('root')
expect(read).toHaveBeenCalledTimes(1)
expect(logger.info).toHaveBeenCalledWith(
expect(logger.debug).toHaveBeenCalledWith(
'persisted state: no migration needed',
)
})
@@ -68,7 +68,7 @@ test('migrate: has legacy data', async () => {
await migrate()
expect(write).toHaveBeenCalledWith(transform(fixtures.LEGACY_DATA_DUMP))
expect(logger.info).toHaveBeenCalledWith(
expect(logger.debug).toHaveBeenCalledWith(
'persisted state: migrated legacy storage',
)
})
+3 -3
View File
@@ -19,7 +19,7 @@ const _emitter = new EventEmitter()
* the Provider.
*/
export async function init() {
logger.info('persisted state: initializing')
logger.debug('persisted state: initializing')
broadcast.onmessage = onBroadcastMessage
@@ -27,11 +27,11 @@ export async function init() {
await migrate() // migrate old store
const stored = await store.read() // check for new store
if (!stored) {
logger.info('persisted state: initializing default storage')
logger.debug('persisted state: initializing default storage')
await store.write(defaults) // opt: init new store
}
_state = stored || defaults // return new store
logger.log('persisted state: initialized')
logger.debug('persisted state: initialized')
} catch (e) {
logger.error('persisted state: failed to load root state from storage', {
message: e,
+4 -4
View File
@@ -121,7 +121,7 @@ export function transform(legacy: Partial<LegacySchema>): Schema {
* local storage AND old storage exists.
*/
export async function migrate() {
logger.info('persisted state: check need to migrate')
logger.debug('persisted state: check need to migrate')
try {
const rawLegacyData = await AsyncStorage.getItem(
@@ -131,7 +131,7 @@ export async function migrate() {
const alreadyMigrated = Boolean(newData)
if (!alreadyMigrated && rawLegacyData) {
logger.info('persisted state: migrating legacy storage')
logger.debug('persisted state: migrating legacy storage')
const legacyData = JSON.parse(rawLegacyData)
const newData = transform(legacyData)
@@ -139,14 +139,14 @@ export async function migrate() {
if (validate.success) {
await write(newData)
logger.info('persisted state: migrated legacy storage')
logger.debug('persisted state: migrated legacy storage')
} else {
logger.error('persisted state: legacy data failed validation', {
message: validate.error,
})
}
} else {
logger.info('persisted state: no migration needed')
logger.debug('persisted state: no migration needed')
}
} catch (e: any) {
logger.error(e, {
+50 -77
View File
@@ -1,11 +1,9 @@
import React from 'react'
import {
useQuery,
useInfiniteQuery,
InfiniteData,
QueryKey,
useMutation,
useQueryClient,
} from '@tanstack/react-query'
import {
AtUri,
@@ -15,7 +13,6 @@ import {
AppBskyUnspeccedGetPopularFeedGenerators,
} from '@atproto/api'
import {logger} from '#/logger'
import {router} from '#/routes'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
@@ -219,83 +216,59 @@ const FOLLOWING_FEED_STUB: FeedSourceInfo = {
likeUri: '',
}
export function usePinnedFeedsInfos(): {
feeds: FeedSourceInfo[]
hasPinnedCustom: boolean
isLoading: boolean
} {
const queryClient = useQueryClient()
const [tabs, setTabs] = React.useState<FeedSourceInfo[]>([
FOLLOWING_FEED_STUB,
])
const [isLoading, setLoading] = React.useState(true)
const {data: preferences} = usePreferencesQuery()
export function usePinnedFeedsInfos() {
const {data: preferences, isLoading: isLoadingPrefs} = usePreferencesQuery()
const pinnedUris = preferences?.feeds?.pinned ?? []
const hasPinnedCustom = React.useMemo<boolean>(() => {
return tabs.some(tab => tab !== FOLLOWING_FEED_STUB)
}, [tabs])
return useQuery({
staleTime: STALE.INFINITY,
enabled: !isLoadingPrefs,
queryKey: ['pinnedFeedsInfos', pinnedUris.join(',')],
queryFn: async () => {
let resolved = new Map()
React.useEffect(() => {
if (!preferences?.feeds?.pinned) return
const uris = preferences.feeds.pinned
async function fetchFeedInfo() {
const reqs = []
for (const uri of uris) {
const cached = queryClient.getQueryData<FeedSourceInfo>(
feedSourceInfoQueryKey({uri}),
)
if (cached) {
reqs.push(cached)
} else {
reqs.push(
(async () => {
// these requests can fail, need to filter those out
try {
return await queryClient.fetchQuery({
staleTime: STALE.SECONDS.FIFTEEN,
queryKey: feedSourceInfoQueryKey({uri}),
queryFn: async () => {
const type = getFeedTypeFromUri(uri)
if (type === 'feed') {
const res =
await getAgent().app.bsky.feed.getFeedGenerator({
feed: uri,
})
return hydrateFeedGenerator(res.data.view)
} else {
const res = await getAgent().app.bsky.graph.getList({
list: uri,
limit: 1,
})
return hydrateList(res.data.list)
}
},
})
} catch (e) {
// expected failure
logger.info(`usePinnedFeedsInfos: failed to fetch ${uri}`, {
error: e,
})
}
})(),
)
}
// Get all feeds. We can do this in a batch.
const feedUris = pinnedUris.filter(
uri => getFeedTypeFromUri(uri) === 'feed',
)
let feedsPromise = Promise.resolve()
if (feedUris.length > 0) {
feedsPromise = getAgent()
.app.bsky.feed.getFeedGenerators({
feeds: feedUris,
})
.then(res => {
for (let feedView of res.data.feeds) {
resolved.set(feedView.uri, hydrateFeedGenerator(feedView))
}
})
}
const views = (await Promise.all(reqs)).filter(
Boolean,
) as FeedSourceInfo[]
// Get all lists. This currently has to be done individually.
const listUris = pinnedUris.filter(
uri => getFeedTypeFromUri(uri) === 'list',
)
const listsPromises = listUris.map(listUri =>
getAgent()
.app.bsky.graph.getList({
list: listUri,
limit: 1,
})
.then(res => {
const listView = res.data.list
resolved.set(listView.uri, hydrateList(listView))
}),
)
setTabs([FOLLOWING_FEED_STUB].concat(views))
setLoading(false)
}
fetchFeedInfo()
}, [queryClient, setTabs, preferences?.feeds?.pinned])
return {feeds: tabs, hasPinnedCustom, isLoading}
// The returned result will have the original order.
const result = [FOLLOWING_FEED_STUB]
await Promise.allSettled([feedsPromise, ...listsPromises])
for (let pinnedUri of pinnedUris) {
if (resolved.has(pinnedUri)) {
result.push(resolved.get(pinnedUri))
}
}
return result
},
})
}
-17
View File
@@ -133,23 +133,6 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
return query
}
/**
* This helper is used by the post-thread placeholder function to
* find a post in the query-data cache
*/
export function findPostInQueryData(
queryClient: QueryClient,
uri: string,
): AppBskyFeedDefs.PostView | undefined {
const generator = findAllPostsInQueryData(queryClient, uri)
const result = generator.next()
if (result.done) {
return undefined
} else {
return result.value
}
}
export function* findAllPostsInQueryData(
queryClient: QueryClient,
uri: string,
+41 -35
View File
@@ -1,6 +1,11 @@
import React, {useCallback, useEffect, useRef} from 'react'
import {AppState} from 'react-native'
import {AppBskyFeedDefs, AppBskyFeedPost, PostModeration} from '@atproto/api'
import {
AppBskyFeedDefs,
AppBskyFeedPost,
AtUri,
PostModeration,
} from '@atproto/api'
import {
useInfiniteQuery,
InfiniteData,
@@ -29,6 +34,7 @@ import {KnownError} from '#/view/com/posts/FeedErrorMessage'
import {embedViewRecordToPostView, getEmbeddedPost} from './util'
import {useModerationOpts} from './preferences'
import {queryClient} from 'lib/react-query'
import {BSKY_FEED_OWNER_DIDS} from 'lib/constants'
type ActorDid = string
type AuthorFilter =
@@ -137,24 +143,41 @@ export function usePostFeedQuery(
cursor: undefined,
}
const res = await api.fetch({cursor, limit: PAGE_SIZE})
precacheFeedPostProfiles(queryClient, res.feed)
try {
const res = await api.fetch({cursor, limit: PAGE_SIZE})
precacheFeedPostProfiles(queryClient, res.feed)
/*
* If this is a public view, we need to check if posts fail moderation.
* If all fail, we throw an error. If only some fail, we continue and let
* moderations happen later, which results in some posts being shown and
* some not.
*/
if (!getAgent().session) {
assertSomePostsPassModeration(res.feed)
}
/*
* If this is a public view, we need to check if posts fail moderation.
* If all fail, we throw an error. If only some fail, we continue and let
* moderations happen later, which results in some posts being shown and
* some not.
*/
if (!getAgent().session) {
assertSomePostsPassModeration(res.feed)
}
return {
api,
cursor: res.cursor,
feed: res.feed,
fetchedAt: Date.now(),
return {
api,
cursor: res.cursor,
feed: res.feed,
fetchedAt: Date.now(),
}
} catch (e) {
const feedDescParts = feedDesc.split('|')
const feedOwnerDid = new AtUri(feedDescParts[1]).hostname
if (
feedDescParts[0] === 'feedgen' &&
BSKY_FEED_OWNER_DIDS.includes(feedOwnerDid)
) {
logger.error(`Bluesky feed may be offline: ${feedOwnerDid}`, {
feedDesc,
jsError: e,
})
}
throw e
}
},
initialPageParam: undefined,
@@ -253,7 +276,7 @@ export function usePostFeedQuery(
.success
) {
return {
_reactKey: `${slice._reactKey}-${i}`,
_reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
uri: item.post.uri,
post: item.post,
record: item.post.record,
@@ -365,23 +388,6 @@ function createApi(
}
}
/**
* This helper is used by the post-thread placeholder function to
* find a post in the query-data cache
*/
export function findPostInQueryData(
queryClient: QueryClient,
uri: string,
): AppBskyFeedDefs.PostView | undefined {
const generator = findAllPostsInQueryData(queryClient, uri)
const result = generator.next()
if (result.done) {
return undefined
} else {
return result.value
}
}
export function* findAllPostsInQueryData(
queryClient: QueryClient,
uri: string,
+40 -26
View File
@@ -8,8 +8,8 @@ import {useQuery, useQueryClient, QueryClient} from '@tanstack/react-query'
import {getAgent} from '#/state/session'
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
import {findPostInQueryData as findPostInFeedQueryData} from './post-feed'
import {findPostInQueryData as findPostInNotifsQueryData} from './notifications/feed'
import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from './post-feed'
import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from './notifications/feed'
import {precacheThreadPostProfiles} from './profile'
import {getEmbeddedPost} from './util'
@@ -82,21 +82,9 @@ export function usePostThreadQuery(uri: string | undefined) {
return undefined
}
{
const item = findPostInQueryData(queryClient, uri)
if (item) {
return threadNodeToPlaceholderThread(item)
}
}
{
const item = findPostInFeedQueryData(queryClient, uri)
if (item) {
return postViewToPlaceholderThread(item)
}
}
{
const item = findPostInNotifsQueryData(queryClient, uri)
if (item) {
return postViewToPlaceholderThread(item)
const post = findPostInQueryData(queryClient, uri)
if (post) {
return post
}
}
return undefined
@@ -171,11 +159,18 @@ function responseToThreadNodes(
AppBskyFeedPost.isRecord(node.post.record) &&
AppBskyFeedPost.validateRecord(node.post.record).success
) {
const post = node.post
// These should normally be present. They're missing only for
// posts that were *just* created. Ideally, the backend would
// know to return zeros. Fill them in manually to compensate.
post.replyCount ??= 0
post.likeCount ??= 0
post.repostCount ??= 0
return {
type: 'post',
_reactKey: node.post.uri,
uri: node.post.uri,
post: node.post,
post: post,
record: node.post.record,
parent:
node.parent && direction !== 'down'
@@ -213,14 +208,24 @@ function responseToThreadNodes(
function findPostInQueryData(
queryClient: QueryClient,
uri: string,
): ThreadNode | undefined {
const generator = findAllPostsInQueryData(queryClient, uri)
const result = generator.next()
if (result.done) {
return undefined
} else {
return result.value
): ThreadNode | void {
let partial
for (let item of findAllPostsInQueryData(queryClient, uri)) {
if (item.type === 'post') {
// Currently, the backend doesn't send full post info in some cases
// (for example, for quoted posts). We use missing `likeCount`
// as a way to detect that. In the future, we should fix this on
// the backend, which will let us always stop on the first result.
const hasAllInfo = item.post.likeCount != null
if (hasAllInfo) {
return item
} else {
partial = item
// Keep searching, we might still find a full post in the cache.
}
}
}
return partial
}
export function* findAllPostsInQueryData(
@@ -236,7 +241,10 @@ export function* findAllPostsInQueryData(
}
for (const item of traverseThread(queryData)) {
if (item.uri === uri) {
yield item
const placeholder = threadNodeToPlaceholderThread(item)
if (placeholder) {
yield placeholder
}
}
const quotedPost =
item.type === 'post' ? getEmbeddedPost(item.post.embed) : undefined
@@ -245,6 +253,12 @@ export function* findAllPostsInQueryData(
}
}
}
for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
yield postViewToPlaceholderThread(post)
}
for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
yield postViewToPlaceholderThread(post)
}
}
function* traverseThread(node: ThreadNode): Generator<ThreadNode, void> {
+3 -1
View File
@@ -7,7 +7,7 @@ import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/
export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] =
{
hideReplies: false,
hideRepliesByUnfollowed: false,
hideRepliesByUnfollowed: true,
hideRepliesByLikeCount: 0,
hideReposts: false,
hideQuotePosts: false,
@@ -49,4 +49,6 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS,
userAge: 13, // TODO(pwi)
interests: {tags: []},
mutedWords: [],
hiddenPosts: [],
}
+49 -2
View File
@@ -1,6 +1,10 @@
import {useMemo} from 'react'
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
import {LabelPreference, BskyFeedViewPreference} from '@atproto/api'
import {
LabelPreference,
BskyFeedViewPreference,
AppBskyActorDefs,
} from '@atproto/api'
import {track} from '#/lib/analytics/analytics'
import {getAge} from '#/lib/strings/time'
@@ -108,6 +112,7 @@ export function useModerationOpts() {
return {
...moderationOpts,
hiddenPosts,
mutedWords: prefs.data.mutedWords || [],
}
}, [currentAccount?.did, prefs.data, hiddenPosts])
return opts
@@ -164,7 +169,7 @@ export function usePreferencesSetBirthDateMutation() {
return useMutation<void, unknown, {birthDate: Date}>({
mutationFn: async ({birthDate}: {birthDate: Date}) => {
await getAgent().setPersonalDetails({birthDate})
await getAgent().setPersonalDetails({birthDate: birthDate.toISOString()})
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
@@ -278,3 +283,45 @@ export function useUnpinFeedMutation() {
},
})
}
export function useUpsertMutedWordsMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (mutedWords: AppBskyActorDefs.MutedWord[]) => {
await getAgent().upsertMutedWords(mutedWords)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
})
},
})
}
export function useUpdateMutedWordMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => {
await getAgent().updateMutedWord(mutedWord)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
})
},
})
}
export function useRemoveMutedWordMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (mutedWord: AppBskyActorDefs.MutedWord) => {
await getAgent().removeMutedWord(mutedWord)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
})
},
})
}
+1
View File
@@ -53,5 +53,6 @@ export function embedViewRecordToPostView(
record: v.value,
indexedAt: v.indexedAt,
labels: v.labels,
embed: v.embeds?.[0],
}
}
+9 -15
View File
@@ -7,7 +7,6 @@ import {networkRetry} from '#/lib/async/retry'
import {logger} from '#/logger'
import * as persisted from '#/state/persisted'
import {PUBLIC_BSKY_AGENT} from '#/state/queries'
import {IS_PROD} from '#/lib/constants'
import {emitSessionDropped} from '../events'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util'
@@ -36,7 +35,6 @@ export type SessionState = {
}
export type StateContext = SessionState & {
hasSession: boolean
isSandbox: boolean
}
export type ApiContext = {
createAccount: (props: {
@@ -84,7 +82,6 @@ const StateContext = React.createContext<StateContext>({
accounts: [],
currentAccount: undefined,
hasSession: false,
isSandbox: false,
})
const ApiContext = React.createContext<ApiContext>({
@@ -136,7 +133,7 @@ function createPersistSessionHandler(
accessJwt: session?.accessJwt,
}
logger.info(`session: persistSession`, {
logger.debug(`session: persistSession`, {
event,
deactivated: refreshedAccount.deactivated,
})
@@ -323,7 +320,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
const logout = React.useCallback<ApiContext['logout']>(async () => {
logger.info(`session: logout`)
logger.debug(`session: logout`)
clearCurrentAccount()
setStateAndPersist(s => {
return {
@@ -377,7 +374,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}
if (canReusePrevSession) {
logger.info(`session: attempting to reuse previous session`)
logger.debug(`session: attempting to reuse previous session`)
agent.session = prevSession
__globalAgent = agent
@@ -387,7 +384,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
if (prevSession.deactivated) {
// don't attempt to resume
// use will be taken to the deactivated screen
logger.info(`session: reusing session for deactivated account`)
logger.debug(`session: reusing session for deactivated account`)
return
}
@@ -413,7 +410,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
__globalAgent = PUBLIC_BSKY_AGENT
})
} else {
logger.info(`session: attempting to resume using previous session`)
logger.debug(`session: attempting to resume using previous session`)
try {
const freshAccount = await resumeSessionWithFreshAccount()
@@ -434,7 +431,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}
async function resumeSessionWithFreshAccount(): Promise<SessionAccount> {
logger.info(`session: resumeSessionWithFreshAccount`)
logger.debug(`session: resumeSessionWithFreshAccount`)
await networkRetry(1, () => agent.resumeSession(prevSession))
@@ -555,11 +552,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
return persisted.onUpdate(() => {
const session = persisted.get('session')
logger.info(`session: persisted onUpdate`, {})
logger.debug(`session: persisted onUpdate`, {})
if (session.currentAccount && session.currentAccount.refreshJwt) {
if (session.currentAccount?.did !== state.currentAccount?.did) {
logger.info(`session: persisted onUpdate, switching accounts`, {
logger.debug(`session: persisted onUpdate, switching accounts`, {
from: {
did: state.currentAccount?.did,
handle: state.currentAccount?.handle,
@@ -572,7 +569,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
initSession(session.currentAccount)
} else {
logger.info(`session: persisted onUpdate, updating session`, {})
logger.debug(`session: persisted onUpdate, updating session`, {})
/*
* Use updated session in this tab's agent. Do not call
@@ -610,9 +607,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
() => ({
...state,
hasSession: !!state.currentAccount,
isSandbox: state.currentAccount
? !IS_PROD(state.currentAccount?.service)
: false,
}),
[state],
)
+2
View File
@@ -38,6 +38,8 @@ export interface ComposerOpts {
quote?: ComposerOptsQuote
mention?: string // handle of user to mention
openPicker?: (pos: DOMRect | undefined) => void
text?: string
imageUris?: {uri: string; width: number; height: number}[]
}
type StateContext = ComposerOpts | undefined
+15 -2
View File
@@ -3,6 +3,7 @@ import {useLightboxControls} from './lightbox'
import {useModalControls} from './modals'
import {useComposerControls} from './shell/composer'
import {useSetDrawerOpen} from './shell/drawer-open'
import {useDialogStateControlContext} from '#/state/dialogs'
/**
* returns true if something was closed
@@ -12,6 +13,7 @@ export function useCloseAnyActiveElement() {
const {closeLightbox} = useLightboxControls()
const {closeModal} = useModalControls()
const {closeComposer} = useComposerControls()
const {closeAllDialogs} = useDialogStateControlContext()
const setDrawerOpen = useSetDrawerOpen()
return useCallback(() => {
if (closeLightbox()) {
@@ -23,9 +25,12 @@ export function useCloseAnyActiveElement() {
if (closeComposer()) {
return true
}
if (closeAllDialogs()) {
return true
}
setDrawerOpen(false)
return false
}, [closeLightbox, closeModal, closeComposer, setDrawerOpen])
}, [closeLightbox, closeModal, closeComposer, setDrawerOpen, closeAllDialogs])
}
/**
@@ -35,11 +40,19 @@ export function useCloseAllActiveElements() {
const {closeLightbox} = useLightboxControls()
const {closeAllModals} = useModalControls()
const {closeComposer} = useComposerControls()
const {closeAllDialogs: closeAlfDialogs} = useDialogStateControlContext()
const setDrawerOpen = useSetDrawerOpen()
return useCallback(() => {
closeLightbox()
closeAllModals()
closeComposer()
closeAlfDialogs()
setDrawerOpen(false)
}, [closeLightbox, closeAllModals, closeComposer, setDrawerOpen])
}, [
closeLightbox,
closeAllModals,
closeComposer,
closeAlfDialogs,
setDrawerOpen,
])
}
@@ -0,0 +1,86 @@
import React from 'react'
import {WebView, WebViewNavigation} from 'react-native-webview'
import {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes'
import {StyleSheet} from 'react-native'
import {CreateAccountState} from 'view/com/auth/create/state'
const ALLOWED_HOSTS = [
'bsky.social',
'bsky.app',
'staging.bsky.app',
'staging.bsky.dev',
'js.hcaptcha.com',
'newassets.hcaptcha.com',
'api2.hcaptcha.com',
]
export function CaptchaWebView({
url,
stateParam,
uiState,
onSuccess,
onError,
}: {
url: string
stateParam: string
uiState?: CreateAccountState
onSuccess: (code: string) => void
onError: () => void
}) {
const redirectHost = React.useMemo(() => {
if (!uiState?.serviceUrl) return 'bsky.app'
return uiState?.serviceUrl &&
new URL(uiState?.serviceUrl).host === 'staging.bsky.dev'
? 'staging.bsky.app'
: 'bsky.app'
}, [uiState?.serviceUrl])
const wasSuccessful = React.useRef(false)
const onShouldStartLoadWithRequest = React.useCallback(
(event: ShouldStartLoadRequest) => {
const urlp = new URL(event.url)
return ALLOWED_HOSTS.includes(urlp.host)
},
[],
)
const onNavigationStateChange = React.useCallback(
(e: WebViewNavigation) => {
if (wasSuccessful.current) return
const urlp = new URL(e.url)
if (urlp.host !== redirectHost) return
const code = urlp.searchParams.get('code')
if (urlp.searchParams.get('state') !== stateParam || !code) {
onError()
return
}
wasSuccessful.current = true
onSuccess(code)
},
[redirectHost, stateParam, onSuccess, onError],
)
return (
<WebView
source={{uri: url}}
javaScriptEnabled
style={styles.webview}
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
onNavigationStateChange={onNavigationStateChange}
scrollEnabled={false}
/>
)
}
const styles = StyleSheet.create({
webview: {
flex: 1,
backgroundColor: 'transparent',
borderRadius: 10,
},
})
@@ -0,0 +1,61 @@
import React from 'react'
import {StyleSheet} from 'react-native'
// @ts-ignore web only, we will always redirect to the app on web (CORS)
const REDIRECT_HOST = new URL(window.location.href).host
export function CaptchaWebView({
url,
stateParam,
onSuccess,
onError,
}: {
url: string
stateParam: string
onSuccess: (code: string) => void
onError: () => void
}) {
const onLoad = React.useCallback(() => {
// @ts-ignore web
const frame: HTMLIFrameElement = document.getElementById(
'captcha-iframe',
) as HTMLIFrameElement
try {
// @ts-ignore web
const href = frame?.contentWindow?.location.href
if (!href) return
const urlp = new URL(href)
// This shouldn't happen with CORS protections, but for good measure
if (urlp.host !== REDIRECT_HOST) return
const code = urlp.searchParams.get('code')
if (urlp.searchParams.get('state') !== stateParam || !code) {
onError()
return
}
onSuccess(code)
} catch (e) {
// We don't need to handle this
}
}, [stateParam, onSuccess, onError])
return (
<iframe
src={url}
style={styles.iframe}
id="captcha-iframe"
onLoad={onLoad}
/>
)
}
const styles = StyleSheet.create({
iframe: {
flex: 1,
borderWidth: 0,
borderRadius: 10,
backgroundColor: 'transparent',
},
})
+43 -32
View File
@@ -13,33 +13,25 @@ import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useOnboardingDispatch} from '#/state/shell'
import {useSessionApi} from '#/state/session'
import {useCreateAccount, submit} from './state'
import {useCreateAccount, useSubmitCreateAccount} from './state'
import {useServiceQuery} from '#/state/queries/service'
import {
usePreferencesSetBirthDateMutation,
useSetSaveFeedsMutation,
DEFAULT_PROD_FEEDS,
} from '#/state/queries/preferences'
import {FEEDBACK_FORM_URL, HITSLOP_10, IS_PROD} from '#/lib/constants'
import {FEEDBACK_FORM_URL, HITSLOP_10} from '#/lib/constants'
import {Step1} from './Step1'
import {Step2} from './Step2'
import {Step3} from './Step3'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {TextLink} from '../../util/Link'
import {getAgent} from 'state/session'
import {createFullHandle, validateHandle} from 'lib/strings/handles'
export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
const {screen} = useAnalytics()
const pal = usePalette('default')
const {_} = useLingui()
const [uiState, uiDispatch] = useCreateAccount()
const onboardingDispatch = useOnboardingDispatch()
const {createAccount} = useSessionApi()
const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
const {isTabletOrDesktop} = useWebMediaQueries()
const submit = useSubmitCreateAccount(uiState, uiDispatch)
React.useEffect(() => {
screen('CreateAccount')
@@ -84,33 +76,52 @@ export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
if (!uiState.canNext) {
return
}
if (uiState.step < 3) {
uiDispatch({type: 'next'})
} else {
if (uiState.step === 2) {
if (!validateHandle(uiState.handle, uiState.userDomain).overall) {
return
}
uiDispatch({type: 'set-processing', value: true})
try {
await submit({
onboardingDispatch,
createAccount,
uiState,
uiDispatch,
_,
const res = await getAgent().resolveHandle({
handle: createFullHandle(uiState.handle, uiState.userDomain),
})
setBirthDate({birthDate: uiState.birthDate})
if (IS_PROD(uiState.serviceUrl)) {
setSavedFeeds(DEFAULT_PROD_FEEDS)
if (res.data.did) {
uiDispatch({
type: 'set-error',
value: _(msg`That handle is already taken.`),
})
return
}
} catch {
// dont need to handle here
} catch (e) {
// Don't need to handle
} finally {
uiDispatch({type: 'set-processing', value: false})
}
if (!uiState.isCaptchaRequired) {
try {
await submit()
} catch {
// dont need to handle here
}
// We don't need to go to the next page if there wasn't a captcha required
return
}
}
uiDispatch({type: 'next'})
}, [
uiState,
uiState.canNext,
uiState.step,
uiState.isCaptchaRequired,
uiState.handle,
uiState.userDomain,
uiDispatch,
onboardingDispatch,
createAccount,
setBirthDate,
setSavedFeeds,
_,
submit,
])
// rendering
+7 -29
View File
@@ -4,7 +4,6 @@ import {
Keyboard,
StyleSheet,
TouchableOpacity,
TouchableWithoutFeedback,
View,
} from 'react-native'
import {CreateAccountState, CreateAccountDispatch, is18} from './state'
@@ -19,7 +18,6 @@ import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {isWeb} from 'platform/detection'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {logger} from '#/logger'
import {
FontAwesomeIcon,
@@ -49,7 +47,6 @@ export function Step1({
}) {
const pal = usePalette('default')
const {_} = useLingui()
const {openModal} = useModalControls()
const serverInputControl = useDialogControl()
const onPressSelectService = React.useCallback(() => {
@@ -57,10 +54,6 @@ export function Step1({
Keyboard.dismiss()
}, [serverInputControl])
const onPressWaitlist = React.useCallback(() => {
openModal({name: 'waitlist'})
}, [openModal])
const birthDate = React.useMemo(() => {
return sanitizeDate(uiState.birthDate)
}, [uiState.birthDate])
@@ -73,6 +66,10 @@ export function Step1({
/>
<StepHeader uiState={uiState} title={_(msg`Your account`)} />
{uiState.error ? (
<ErrorMessage message={uiState.error} style={styles.error} />
) : undefined}
<View style={s.pb20}>
<Text type="md-medium" style={[pal.text, s.mb2]}>
<Trans>Hosting provider</Trans>
@@ -160,23 +157,7 @@ export function Step1({
</View>
)}
{!uiState.inviteCode && uiState.isInviteCodeRequired ? (
<View style={[s.flexRow, s.alignCenter]}>
<Text style={pal.text}>
<Trans>Don't have an invite code?</Trans>{' '}
</Text>
<TouchableWithoutFeedback
onPress={onPressWaitlist}
accessibilityLabel={_(msg`Join the waitlist.`)}
accessibilityHint="">
<View style={styles.touchable}>
<Text style={pal.link}>
<Trans>Join the waitlist.</Trans>
</Text>
</View>
</TouchableWithoutFeedback>
</View>
) : (
{uiState.inviteCode ? (
<>
<View style={s.pb20}>
<Text
@@ -256,12 +237,9 @@ export function Step1({
/>
)}
</>
)}
) : undefined}
</>
)}
{uiState.error ? (
<ErrorMessage message={uiState.error} style={styles.error} />
) : undefined}
</View>
)
}
@@ -269,7 +247,7 @@ export function Step1({
const styles = StyleSheet.create({
error: {
borderRadius: 6,
marginTop: 10,
marginBottom: 10,
},
dateInputButton: {
borderWidth: 1,
+106 -273
View File
@@ -1,35 +1,26 @@
import React from 'react'
import {
ActivityIndicator,
StyleSheet,
TouchableWithoutFeedback,
View,
} from 'react-native'
import RNPickerSelect from 'react-native-picker-select'
import {
CreateAccountState,
CreateAccountDispatch,
requestVerificationCode,
} from './state'
import {View} from 'react-native'
import {CreateAccountState, CreateAccountDispatch} from './state'
import {Text} from 'view/com/util/text/Text'
import {StepHeader} from './StepHeader'
import {s} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {TextInput} from '../util/TextInput'
import {Button} from '../../util/forms/Button'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {isAndroid, isWeb} from 'platform/detection'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import parsePhoneNumber from 'libphonenumber-js'
import {COUNTRY_CODES} from '#/lib/country-codes'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {HITSLOP_10} from '#/lib/constants'
createFullHandle,
IsValidHandle,
validateHandle,
} from 'lib/strings/handles'
import {usePalette} from 'lib/hooks/usePalette'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
import {useFocusEffect} from '@react-navigation/native'
/** STEP 3: Your user handle
* @field User handle
*/
export function Step2({
uiState,
uiDispatch,
@@ -39,269 +30,111 @@ export function Step2({
}) {
const pal = usePalette('default')
const {_} = useLingui()
const {isMobile} = useWebMediaQueries()
const t = useTheme()
const onPressRequest = React.useCallback(() => {
const phoneNumber = parsePhoneNumber(
uiState.verificationPhone,
uiState.phoneCountry,
)
if (phoneNumber && phoneNumber.isValid()) {
requestVerificationCode({uiState, uiDispatch, _})
} else {
uiDispatch({
type: 'set-error',
value: _(
msg`There's something wrong with this number. Please choose your country and enter your full phone number!`,
),
})
}
}, [uiState, uiDispatch, _])
const [validCheck, setValidCheck] = React.useState<IsValidHandle>({
handleChars: false,
frontLength: false,
totalLength: true,
overall: false,
})
const onPressRetry = React.useCallback(() => {
uiDispatch({type: 'set-has-requested-verification-code', value: false})
}, [uiDispatch])
useFocusEffect(
React.useCallback(() => {
setValidCheck(validateHandle(uiState.handle, uiState.userDomain))
const phoneNumberFormatted = React.useMemo(
() =>
uiState.hasRequestedVerificationCode
? parsePhoneNumber(
uiState.verificationPhone,
uiState.phoneCountry,
)?.formatInternational()
: '',
[
uiState.hasRequestedVerificationCode,
uiState.verificationPhone,
uiState.phoneCountry,
],
// Disabling this, because we only want to run this when we focus the screen
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []),
)
const onHandleChange = React.useCallback(
(value: string) => {
if (uiState.error) {
uiDispatch({type: 'set-error', value: ''})
}
setValidCheck(validateHandle(value, uiState.userDomain))
uiDispatch({type: 'set-handle', value})
},
[uiDispatch, uiState.error, uiState.userDomain],
)
return (
<View>
<StepHeader uiState={uiState} title={_(msg`SMS verification`)} />
{!uiState.hasRequestedVerificationCode ? (
<>
<View style={s.pb10}>
<Text
type="md-medium"
style={[pal.text, s.mb2]}
nativeID="phoneCountry">
<Trans>Country</Trans>
<StepHeader uiState={uiState} title={_(msg`Your user handle`)} />
<View style={s.pb10}>
<View style={s.mb20}>
<TextInput
testID="handleInput"
icon="at"
placeholder="e.g. alice"
value={uiState.handle}
editable
autoFocus
autoComplete="off"
autoCorrect={false}
onChange={onHandleChange}
// TODO: Add explicit text label
accessibilityLabel={_(msg`User handle`)}
accessibilityHint={_(msg`Input your user handle`)}
/>
<Text type="lg" style={[pal.text, s.pl5, s.pt10]}>
<Trans>Your full handle will be</Trans>{' '}
<Text type="lg-bold" style={pal.text}>
@{createFullHandle(uiState.handle, uiState.userDomain)}
</Text>
<View
style={[
{position: 'relative'},
isAndroid && {
borderWidth: 1,
borderColor: pal.border.borderColor,
borderRadius: 4,
},
]}>
<RNPickerSelect
placeholder={{}}
value={uiState.phoneCountry}
onValueChange={value =>
uiDispatch({type: 'set-phone-country', value})
}
items={COUNTRY_CODES.filter(l => Boolean(l.code2)).map(l => ({
label: l.name,
value: l.code2,
key: l.code2,
}))}
style={{
inputAndroid: {
backgroundColor: pal.view.backgroundColor,
color: pal.text.color,
fontSize: 21,
letterSpacing: 0.5,
fontWeight: '500',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 4,
},
inputIOS: {
backgroundColor: pal.view.backgroundColor,
color: pal.text.color,
fontSize: 14,
letterSpacing: 0.5,
fontWeight: '500',
paddingHorizontal: 14,
paddingVertical: 8,
borderWidth: 1,
borderColor: pal.border.borderColor,
borderRadius: 4,
},
inputWeb: {
// @ts-ignore web only
cursor: 'pointer',
'-moz-appearance': 'none',
'-webkit-appearance': 'none',
appearance: 'none',
outline: 0,
borderWidth: 1,
borderColor: pal.border.borderColor,
backgroundColor: pal.view.backgroundColor,
color: pal.text.color,
fontSize: 14,
letterSpacing: 0.5,
fontWeight: '500',
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 4,
},
}}
accessibilityLabel={_(msg`Select your phone's country`)}
accessibilityHint=""
accessibilityLabelledBy="phoneCountry"
/>
<View
style={{
position: 'absolute',
top: 1,
right: 1,
bottom: 1,
width: 40,
pointerEvents: 'none',
alignItems: 'center',
justifyContent: 'center',
}}>
<FontAwesomeIcon
icon="chevron-down"
style={pal.text as FontAwesomeIconStyle}
/>
</View>
</Text>
</View>
<View
style={[
a.w_full,
a.rounded_sm,
a.border,
a.p_md,
a.gap_sm,
t.atoms.border_contrast_low,
]}>
{uiState.error ? (
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
<IsValidIcon valid={false} />
<Text style={[t.atoms.text, a.text_md, a.flex]}>
{uiState.error}
</Text>
</View>
</View>
<View style={s.pb20}>
<Text
type="md-medium"
style={[pal.text, s.mb2]}
nativeID="phoneNumber">
<Trans>Phone number</Trans>
) : undefined}
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
<IsValidIcon valid={validCheck.handleChars} />
<Text style={[t.atoms.text, a.text_md, a.flex]}>
<Trans>May only contain letters and numbers</Trans>
</Text>
<TextInput
testID="phoneInput"
icon="phone"
placeholder={_(msg`Enter your phone number`)}
value={uiState.verificationPhone}
editable
onChange={value =>
uiDispatch({type: 'set-verification-phone', value})
}
accessibilityLabel={_(msg`Email`)}
accessibilityHint={_(
msg`Input phone number for SMS verification`,
)}
accessibilityLabelledBy="phoneNumber"
keyboardType="phone-pad"
autoCapitalize="none"
autoComplete="tel"
autoCorrect={false}
autoFocus={true}
</View>
<View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
<IsValidIcon
valid={validCheck.frontLength && validCheck.totalLength}
/>
<Text type="sm" style={[pal.textLight, s.mt5]}>
<Trans>
Please enter a phone number that can receive SMS text messages.
</Trans>
</Text>
</View>
<View style={isMobile ? {} : {flexDirection: 'row'}}>
{uiState.isProcessing ? (
<ActivityIndicator />
{!validCheck.totalLength ? (
<Text style={[t.atoms.text]}>
<Trans>May not be longer than 253 characters</Trans>
</Text>
) : (
<Button
testID="requestCodeBtn"
type="primary"
label={_(msg`Request code`)}
labelStyle={isMobile ? [s.flex1, s.textCenter, s.f17] : []}
style={
isMobile ? {paddingVertical: 12, paddingHorizontal: 20} : {}
}
onPress={onPressRequest}
/>
<Text style={[t.atoms.text, a.text_md]}>
<Trans>Must be at least 3 characters</Trans>
</Text>
)}
</View>
</>
) : (
<>
<View style={s.pb20}>
<View
style={[
s.flexRow,
s.mb5,
s.alignCenter,
{justifyContent: 'space-between'},
]}>
<Text
type="md-medium"
style={pal.text}
nativeID="verificationCode">
<Trans>Verification code</Trans>{' '}
</Text>
<TouchableWithoutFeedback
onPress={onPressRetry}
accessibilityLabel={_(msg`Retry.`)}
accessibilityHint=""
hitSlop={HITSLOP_10}>
<View style={styles.touchable}>
<Text
type="md-medium"
style={pal.link}
nativeID="verificationCode">
<Trans>Retry</Trans>
</Text>
</View>
</TouchableWithoutFeedback>
</View>
<TextInput
testID="codeInput"
icon="hashtag"
placeholder={_(msg`XXXXXX`)}
value={uiState.verificationCode}
editable
onChange={value =>
uiDispatch({type: 'set-verification-code', value})
}
accessibilityLabel={_(msg`Email`)}
accessibilityHint={_(
msg`Input the verification code we have texted to you`,
)}
accessibilityLabelledBy="verificationCode"
keyboardType="phone-pad"
autoCapitalize="none"
autoComplete="one-time-code"
textContentType="oneTimeCode"
autoCorrect={false}
autoFocus={true}
/>
<Text type="sm" style={[pal.textLight, s.mt5]}>
<Trans>
Please enter the verification code sent to{' '}
{phoneNumberFormatted}.
</Trans>
</Text>
</View>
</>
)}
{uiState.error ? (
<ErrorMessage message={uiState.error} style={styles.error} />
) : undefined}
</View>
</View>
</View>
)
}
const styles = StyleSheet.create({
error: {
borderRadius: 6,
marginTop: 10,
},
// @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832.
touchable: {
...(isWeb && {cursor: 'pointer'}),
},
})
function IsValidIcon({valid}: {valid: boolean}) {
const t = useTheme()
if (!valid) {
return <Times size="md" style={{color: t.palette.negative_500}} />
}
return <Check size="md" style={{color: t.palette.positive_700}} />
}
+86 -34
View File
@@ -1,19 +1,23 @@
import React from 'react'
import {StyleSheet, View} from 'react-native'
import {CreateAccountState, CreateAccountDispatch} from './state'
import {Text} from 'view/com/util/text/Text'
import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {
CreateAccountState,
CreateAccountDispatch,
useSubmitCreateAccount,
} from './state'
import {StepHeader} from './StepHeader'
import {s} from 'lib/styles'
import {TextInput} from '../util/TextInput'
import {createFullHandle} from 'lib/strings/handles'
import {usePalette} from 'lib/hooks/usePalette'
import {ErrorMessage} from 'view/com/util/error/ErrorMessage'
import {msg, Trans} from '@lingui/macro'
import {isWeb} from 'platform/detection'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
/** STEP 3: Your user handle
* @field User handle
*/
import {nanoid} from 'nanoid/non-secure'
import {CaptchaWebView} from 'view/com/auth/create/CaptchaWebView'
import {useTheme} from 'lib/ThemeContext'
import {createFullHandle} from 'lib/strings/handles'
const CAPTCHA_PATH = '/gate/signup'
export function Step3({
uiState,
uiDispatch,
@@ -21,33 +25,66 @@ export function Step3({
uiState: CreateAccountState
uiDispatch: CreateAccountDispatch
}) {
const pal = usePalette('default')
const {_} = useLingui()
const theme = useTheme()
const submit = useSubmitCreateAccount(uiState, uiDispatch)
const [completed, setCompleted] = React.useState(false)
const stateParam = React.useMemo(() => nanoid(15), [])
const url = React.useMemo(() => {
const newUrl = new URL(uiState.serviceUrl)
newUrl.pathname = CAPTCHA_PATH
newUrl.searchParams.set(
'handle',
createFullHandle(uiState.handle, uiState.userDomain),
)
newUrl.searchParams.set('state', stateParam)
newUrl.searchParams.set('colorScheme', theme.colorScheme)
console.log(newUrl)
return newUrl.href
}, [
uiState.serviceUrl,
uiState.handle,
uiState.userDomain,
stateParam,
theme.colorScheme,
])
const onSuccess = React.useCallback(
(code: string) => {
setCompleted(true)
submit(code)
},
[submit],
)
const onError = React.useCallback(() => {
uiDispatch({
type: 'set-error',
value: _(msg`Error receiving captcha response.`),
})
}, [_, uiDispatch])
return (
<View>
<StepHeader uiState={uiState} title={_(msg`Your user handle`)} />
<View style={s.pb10}>
<TextInput
testID="handleInput"
icon="at"
placeholder="e.g. alice"
value={uiState.handle}
editable
autoFocus
autoComplete="off"
autoCorrect={false}
onChange={value => uiDispatch({type: 'set-handle', value})}
// TODO: Add explicit text label
accessibilityLabel={_(msg`User handle`)}
accessibilityHint={_(msg`Input your user handle`)}
/>
<Text type="lg" style={[pal.text, s.pl5, s.pt10]}>
<Trans>Your full handle will be</Trans>{' '}
<Text type="lg-bold" style={pal.text}>
@{createFullHandle(uiState.handle, uiState.userDomain)}
</Text>
</Text>
<StepHeader uiState={uiState} title={_(msg`Complete the challenge`)} />
<View style={[styles.container, completed && styles.center]}>
{!completed ? (
<CaptchaWebView
url={url}
stateParam={stateParam}
uiState={uiState}
onSuccess={onSuccess}
onError={onError}
/>
) : (
<ActivityIndicator size="large" />
)}
</View>
{uiState.error ? (
<ErrorMessage message={uiState.error} style={styles.error} />
) : undefined}
@@ -58,5 +95,20 @@ export function Step3({
const styles = StyleSheet.create({
error: {
borderRadius: 6,
marginTop: 10,
},
// @ts-expect-error: Suppressing error due to incomplete `ViewStyle` type definition in react-native-web, missing `cursor` prop as discussed in https://github.com/necolas/react-native-web/issues/832.
touchable: {
...(isWeb && {cursor: 'pointer'}),
},
container: {
minHeight: 500,
width: '100%',
paddingBottom: 20,
overflow: 'hidden',
},
center: {
alignItems: 'center',
justifyContent: 'center',
},
})

Some files were not shown because too many files have changed in this diff Show More