Merge branch 'main' into hailey/onboarding-profile
# Conflicts: # package.json # src/screens/Onboarding/StepTopicalFeeds.tsx # yarn.lock
This commit is contained in:
+23
-15
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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%)`,
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
])}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 & {}>
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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,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',
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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)'},
|
||||
]
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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}` : ''),
|
||||
)}`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+591
-372
File diff suppressed because it is too large
Load Diff
+591
-372
File diff suppressed because it is too large
Load Diff
+589
-370
File diff suppressed because it is too large
Load Diff
+591
-372
File diff suppressed because it is too large
Load Diff
+591
-372
File diff suppressed because it is too large
Load Diff
+591
-372
File diff suppressed because it is too large
Load Diff
+591
-372
File diff suppressed because it is too large
Load Diff
+1730
-863
File diff suppressed because it is too large
Load Diff
+591
-372
File diff suppressed because it is too large
Load Diff
+591
-372
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+591
-372
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
// @ts-ignore Web-only. On RN, this is set by Metro.
|
||||
window.__BUNDLE_START_TIME__ = performance.now()
|
||||
+2
-1
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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: [],
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -53,5 +53,6 @@ export function embedViewRecordToPostView(
|
||||
record: v.value,
|
||||
indexedAt: v.indexedAt,
|
||||
labels: v.labels,
|
||||
embed: v.embeds?.[0],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
@@ -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
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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}} />
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user