Merge branch 'main' into web-layout

This commit is contained in:
Dan Abramov
2024-01-11 22:47:17 +00:00
245 changed files with 21884 additions and 4478 deletions
+5 -2
View File
@@ -39,6 +39,8 @@ import {
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import * as persisted from '#/state/persisted'
import {Splash} from '#/Splash'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
SplashScreen.preventAutoHideAsync()
@@ -46,17 +48,18 @@ function InnerApp() {
const colorMode = useColorMode()
const {isInitialLoad, currentAccount} = useSession()
const {resumeSession} = useSessionApi()
const {_} = useLingui()
// init
useEffect(() => {
notifications.init(queryClient)
listenSessionDropped(() => {
Toast.show('Sorry! Your session expired. Please log in again.')
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
})
const account = persisted.get('session').currentAccount
resumeSession(account)
}, [resumeSession])
}, [resumeSession, _])
return (
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
+22 -17
View File
@@ -7,6 +7,7 @@ import {RootSiblingParent} from 'react-native-root-siblings'
import 'view/icons'
import {ThemeProvider as Alf} from '#/alf'
import {init as initPersistedState} from '#/state/persisted'
import {useColorMode} from 'state/shell'
import {Shell} from 'view/shell/index'
@@ -28,11 +29,13 @@ import {
} from 'state/session'
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import * as persisted from '#/state/persisted'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
function InnerApp() {
const {isInitialLoad, currentAccount} = useSession()
const {resumeSession} = useSessionApi()
const colorMode = useColorMode()
const theme = useColorModeTheme(colorMode)
// init
useEffect(() => {
@@ -44,23 +47,25 @@ function InnerApp() {
if (isInitialLoad) return null
return (
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<LoggedOutViewProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={colorMode}>
{/* All components should be within this provider */}
<RootSiblingParent>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</RootSiblingParent>
<ToastContainer />
</ThemeProvider>
</UnreadNotifsProvider>
</LoggedOutViewProvider>
</React.Fragment>
<Alf theme={theme}>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<LoggedOutViewProvider>
<UnreadNotifsProvider>
<ThemeProvider theme={colorMode}>
{/* All components should be within this provider */}
<RootSiblingParent>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</RootSiblingParent>
<ToastContainer />
</ThemeProvider>
</UnreadNotifsProvider>
</LoggedOutViewProvider>
</React.Fragment>
</Alf>
)
}
+59 -36
View File
@@ -26,7 +26,7 @@ import {BottomBar} from './view/shell/bottom-bar/BottomBar'
import {buildStateObject} from 'lib/routes/helpers'
import {State, RouteParams} from 'lib/routes/types'
import {colors} from 'lib/styles'
import {isNative} from 'platform/detection'
import {isAndroid, isNative} from 'platform/detection'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {router} from './routes'
import {usePalette} from 'lib/hooks/usePalette'
@@ -62,7 +62,7 @@ import {ProfileListScreen} from './view/screens/ProfileList'
import {PostThreadScreen} from './view/screens/PostThread'
import {PostLikedByScreen} from './view/screens/PostLikedBy'
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
import {DebugScreen} from './view/screens/Debug'
import {DebugScreen} from './view/screens/DebugNew'
import {LogScreen} from './view/screens/Log'
import {SupportScreen} from './view/screens/Support'
import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy'
@@ -75,7 +75,10 @@ import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
import {SavedFeeds} from 'view/screens/SavedFeeds'
import {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed'
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'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@@ -93,55 +96,56 @@ const Tab = createBottomTabNavigator<BottomTabNavigatorParams>()
* These "common screens" are reused across stacks.
*/
function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
const title = (page: string) => bskyTitle(page, unreadCountLabel)
const title = (page: MessageDescriptor) =>
bskyTitle(i18n._(page), unreadCountLabel)
return (
<>
<Stack.Screen
name="NotFound"
getComponent={() => NotFoundScreen}
options={{title: title('Not Found')}}
options={{title: title(msg`Not Found`)}}
/>
<Stack.Screen
name="Lists"
component={ListsScreen}
options={{title: title('Lists'), requireAuth: true}}
options={{title: title(msg`Lists`), requireAuth: true}}
/>
<Stack.Screen
name="Moderation"
getComponent={() => ModerationScreen}
options={{title: title('Moderation'), requireAuth: true}}
options={{title: title(msg`Moderation`), requireAuth: true}}
/>
<Stack.Screen
name="ModerationModlists"
getComponent={() => ModerationModlistsScreen}
options={{title: title('Moderation Lists'), requireAuth: true}}
options={{title: title(msg`Moderation Lists`), requireAuth: true}}
/>
<Stack.Screen
name="ModerationMutedAccounts"
getComponent={() => ModerationMutedAccounts}
options={{title: title('Muted Accounts'), requireAuth: true}}
options={{title: title(msg`Muted Accounts`), requireAuth: true}}
/>
<Stack.Screen
name="ModerationBlockedAccounts"
getComponent={() => ModerationBlockedAccounts}
options={{title: title('Blocked Accounts'), requireAuth: true}}
options={{title: title(msg`Blocked Accounts`), requireAuth: true}}
/>
<Stack.Screen
name="Settings"
getComponent={() => SettingsScreen}
options={{title: title('Settings'), requireAuth: true}}
options={{title: title(msg`Settings`), requireAuth: true}}
/>
<Stack.Screen
name="LanguageSettings"
getComponent={() => LanguageSettingsScreen}
options={{title: title('Language Settings'), requireAuth: true}}
options={{title: title(msg`Language Settings`), requireAuth: true}}
/>
<Stack.Screen
name="Profile"
getComponent={() => ProfileScreen}
options={({route}) => ({
title: title(`@${route.params.name}`),
title: title(msg`@${route.params.name}`),
animation: 'none',
})}
/>
@@ -149,100 +153,114 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
name="ProfileFollowers"
getComponent={() => ProfileFollowersScreen}
options={({route}) => ({
title: title(`People following @${route.params.name}`),
title: title(msg`People following @${route.params.name}`),
})}
/>
<Stack.Screen
name="ProfileFollows"
getComponent={() => ProfileFollowsScreen}
options={({route}) => ({
title: title(`People followed by @${route.params.name}`),
title: title(msg`People followed by @${route.params.name}`),
})}
/>
<Stack.Screen
name="ProfileList"
getComponent={() => ProfileListScreen}
options={{title: title('List'), requireAuth: true}}
options={{title: title(msg`List`), requireAuth: true}}
/>
<Stack.Screen
name="PostThread"
getComponent={() => PostThreadScreen}
options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
options={({route}) => ({
title: title(msg`Post by @${route.params.name}`),
})}
/>
<Stack.Screen
name="PostLikedBy"
getComponent={() => PostLikedByScreen}
options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
options={({route}) => ({
title: title(msg`Post by @${route.params.name}`),
})}
/>
<Stack.Screen
name="PostRepostedBy"
getComponent={() => PostRepostedByScreen}
options={({route}) => ({title: title(`Post by @${route.params.name}`)})}
options={({route}) => ({
title: title(msg`Post by @${route.params.name}`),
})}
/>
<Stack.Screen
name="ProfileFeed"
getComponent={() => ProfileFeedScreen}
options={{title: title('Feed'), requireAuth: true}}
options={{title: title(msg`Feed`), requireAuth: true}}
/>
<Stack.Screen
name="ProfileFeedLikedBy"
getComponent={() => ProfileFeedLikedByScreen}
options={{title: title('Liked by')}}
options={{title: title(msg`Liked by`)}}
/>
<Stack.Screen
name="Debug"
getComponent={() => DebugScreen}
options={{title: title('Debug'), requireAuth: true}}
options={{title: title(msg`Debug`), requireAuth: true}}
/>
<Stack.Screen
name="Log"
getComponent={() => LogScreen}
options={{title: title('Log'), requireAuth: true}}
options={{title: title(msg`Log`), requireAuth: true}}
/>
<Stack.Screen
name="Support"
getComponent={() => SupportScreen}
options={{title: title('Support')}}
options={{title: title(msg`Support`)}}
/>
<Stack.Screen
name="PrivacyPolicy"
getComponent={() => PrivacyPolicyScreen}
options={{title: title('Privacy Policy')}}
options={{title: title(msg`Privacy Policy`)}}
/>
<Stack.Screen
name="TermsOfService"
getComponent={() => TermsOfServiceScreen}
options={{title: title('Terms of Service')}}
options={{title: title(msg`Terms of Service`)}}
/>
<Stack.Screen
name="CommunityGuidelines"
getComponent={() => CommunityGuidelinesScreen}
options={{title: title('Community Guidelines')}}
options={{title: title(msg`Community Guidelines`)}}
/>
<Stack.Screen
name="CopyrightPolicy"
getComponent={() => CopyrightPolicyScreen}
options={{title: title('Copyright Policy')}}
options={{title: title(msg`Copyright Policy`)}}
/>
<Stack.Screen
name="AppPasswords"
getComponent={() => AppPasswords}
options={{title: title('App Passwords'), requireAuth: true}}
options={{title: title(msg`App Passwords`), requireAuth: true}}
/>
<Stack.Screen
name="SavedFeeds"
getComponent={() => SavedFeeds}
options={{title: title('Edit My Feeds'), requireAuth: true}}
options={{title: title(msg`Edit My Feeds`), requireAuth: true}}
/>
<Stack.Screen
name="PreferencesHomeFeed"
getComponent={() => PreferencesHomeFeed}
options={{title: title('Home Feed Preferences'), requireAuth: true}}
options={{title: title(msg`Home Feed Preferences`), requireAuth: true}}
/>
<Stack.Screen
name="PreferencesThreads"
getComponent={() => PreferencesThreads}
options={{title: title('Threads Preferences'), requireAuth: true}}
options={{title: title(msg`Threads Preferences`), requireAuth: true}}
/>
<Stack.Screen
name="PreferencesExternalEmbeds"
getComponent={() => PreferencesExternalEmbeds}
options={{
title: title(msg`External Media Preferences`),
requireAuth: true,
}}
/>
</>
)
@@ -287,6 +305,7 @@ function HomeTabNavigator() {
return (
<HomeTab.Navigator
screenOptions={{
animation: isAndroid ? 'none' : undefined,
gestureEnabled: true,
fullScreenGestureEnabled: true,
headerShown: false,
@@ -308,6 +327,7 @@ function SearchTabNavigator() {
return (
<SearchTab.Navigator
screenOptions={{
animation: isAndroid ? 'none' : undefined,
gestureEnabled: true,
fullScreenGestureEnabled: true,
headerShown: false,
@@ -325,6 +345,7 @@ function FeedsTabNavigator() {
return (
<FeedsTab.Navigator
screenOptions={{
animation: isAndroid ? 'none' : undefined,
gestureEnabled: true,
fullScreenGestureEnabled: true,
headerShown: false,
@@ -346,6 +367,7 @@ function NotificationsTabNavigator() {
return (
<NotificationsTab.Navigator
screenOptions={{
animation: isAndroid ? 'none' : undefined,
gestureEnabled: true,
fullScreenGestureEnabled: true,
headerShown: false,
@@ -367,6 +389,7 @@ function MyProfileTabNavigator() {
return (
<MyProfileTab.Navigator
screenOptions={{
animation: isAndroid ? 'none' : undefined,
gestureEnabled: true,
fullScreenGestureEnabled: true,
headerShown: false,
@@ -394,7 +417,7 @@ const FlatNavigator = () => {
const pal = usePalette('default')
const numUnread = useUnreadNotifications()
const screenListeners = useWebScrollRestoration()
const title = (page: string) => bskyTitle(page, numUnread)
const title = (page: MessageDescriptor) => bskyTitle(i18n._(page), numUnread)
return (
<Flat.Navigator
@@ -409,22 +432,22 @@ const FlatNavigator = () => {
<Flat.Screen
name="Home"
getComponent={() => HomeScreen}
options={{title: title('Home'), requireAuth: true}}
options={{title: title(msg`Home`), requireAuth: true}}
/>
<Flat.Screen
name="Search"
getComponent={() => SearchScreen}
options={{title: title('Search')}}
options={{title: title(msg`Search`)}}
/>
<Flat.Screen
name="Feeds"
getComponent={() => FeedsScreen}
options={{title: title('Feeds'), requireAuth: true}}
options={{title: title(msg`Feeds`), requireAuth: true}}
/>
<Flat.Screen
name="Notifications"
getComponent={() => NotificationsScreen}
options={{title: title('Notifications'), requireAuth: true}}
options={{title: title(msg`Notifications`), requireAuth: true}}
/>
{commonScreens(Flat as typeof HomeTab, numUnread)}
</Flat.Navigator>
+151 -65
View File
@@ -1,5 +1,11 @@
import React, {useCallback, useEffect} from 'react'
import {View, StyleSheet, Image as RNImage} from 'react-native'
import {
View,
StyleSheet,
Image as RNImage,
AccessibilityInfo,
useColorScheme,
} from 'react-native'
import * as SplashScreen from 'expo-splash-screen'
import {Image} from 'expo-image'
import Animated, {
@@ -14,9 +20,18 @@ import MaskedView from '@react-native-masked-view/masked-view'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import Svg, {Path, SvgProps} from 'react-native-svg'
import {isAndroid} from '#/platform/detection'
import {useColorMode} from 'state/shell'
import {colors} from '#/lib/styles'
// @ts-ignore
import splashImagePointer from '../assets/splash.png'
// @ts-ignore
import darkSplashImagePointer from '../assets/splash-dark.png'
const splashImageUri = RNImage.resolveAssetSource(splashImagePointer).uri
const darkSplashImageUri = RNImage.resolveAssetSource(
darkSplashImagePointer,
).uri
export const Logo = React.forwardRef(function LogoImpl(props: SvgProps, ref) {
const width = 1000
@@ -27,9 +42,9 @@ export const Logo = React.forwardRef(function LogoImpl(props: SvgProps, ref) {
// @ts-ignore it's fiiiiine
ref={ref}
viewBox="0 0 64 66"
style={{width, height}}>
style={[{width, height}, props.style]}>
<Path
fill="#fff"
fill={props.fill || '#fff'}
d="M13.873 3.77C21.21 9.243 29.103 20.342 32 26.3v15.732c0-.335-.13.043-.41.858-1.512 4.414-7.418 21.642-20.923 7.87-7.111-7.252-3.819-14.503 9.125-16.692-7.405 1.252-15.73-.817-18.014-8.93C1.12 22.804 0 8.431 0 6.488 0-3.237 8.579-.18 13.873 3.77ZM50.127 3.77C42.79 9.243 34.897 20.342 32 26.3v15.732c0-.335.13.043.41.858 1.512 4.414 7.418 21.642 20.923 7.87 7.111-7.252 3.819-14.503-9.125-16.692 7.405 1.252 15.73-.817 18.014-8.93C62.88 22.804 64 8.431 64 6.488 64-3.237 55.422-.18 50.127 3.77Z"
/>
</Svg>
@@ -40,8 +55,6 @@ type Props = {
isReady: boolean
}
SplashScreen.preventAutoHideAsync().catch(() => {})
const AnimatedLogo = Animated.createAnimatedComponent(Logo)
export function Splash(props: React.PropsWithChildren<Props>) {
@@ -52,9 +65,22 @@ export function Splash(props: React.PropsWithChildren<Props>) {
const outroAppOpacity = useSharedValue(0)
const [isAnimationComplete, setIsAnimationComplete] = React.useState(false)
const [isImageLoaded, setIsImageLoaded] = React.useState(false)
const isReady = props.isReady && isImageLoaded
const [isLayoutReady, setIsLayoutReady] = React.useState(false)
const [reduceMotion, setReduceMotion] = React.useState<boolean | undefined>(
false,
)
const isReady =
props.isReady &&
isImageLoaded &&
isLayoutReady &&
reduceMotion !== undefined
const logoAnimations = useAnimatedStyle(() => {
const colorMode = useColorMode()
const colorScheme = useColorScheme()
const themeName = colorMode === 'system' ? colorScheme : colorMode
const isDarkMode = themeName === 'dark'
const logoAnimation = useAnimatedStyle(() => {
return {
transform: [
{
@@ -64,7 +90,7 @@ export function Splash(props: React.PropsWithChildren<Props>) {
scale: interpolate(
outroLogo.value,
[0, 0.08, 1],
[1, 0.8, 400],
[1, 0.8, 500],
'clamp',
),
},
@@ -72,6 +98,27 @@ export function Splash(props: React.PropsWithChildren<Props>) {
opacity: interpolate(intro.value, [0, 1], [0, 1], 'clamp'),
}
})
const reducedLogoAnimation = useAnimatedStyle(() => {
return {
transform: [
{
scale: interpolate(intro.value, [0, 1], [0.8, 1], 'clamp'),
},
],
opacity: interpolate(intro.value, [0, 1], [0, 1], 'clamp'),
}
})
const logoWrapperAnimation = useAnimatedStyle(() => {
return {
opacity: interpolate(
outroAppOpacity.value,
[0, 0.1, 0.2, 1],
[1, 1, 0, 0],
'clamp',
),
}
})
const appAnimation = useAnimatedStyle(() => {
return {
@@ -82,7 +129,7 @@ export function Splash(props: React.PropsWithChildren<Props>) {
],
opacity: interpolate(
outroAppOpacity.value,
[0, 0.08, 0.15, 1],
[0, 0.1, 0.2, 1],
[0, 0, 1, 1],
'clamp',
),
@@ -90,82 +137,121 @@ export function Splash(props: React.PropsWithChildren<Props>) {
})
const onFinish = useCallback(() => setIsAnimationComplete(true), [])
const onLayout = useCallback(() => setIsLayoutReady(true), [])
const onLoadEnd = useCallback(() => setIsImageLoaded(true), [])
useEffect(() => {
if (isReady) {
// hide on mount
SplashScreen.hideAsync().catch(() => {})
intro.value = withTiming(
1,
{duration: 400, easing: Easing.out(Easing.cubic)},
async () => {
// set these values to check animation at specific point
// outroLogo.value = 0.1
// outroApp.value = 0.1
outroLogo.value = withTiming(
SplashScreen.hideAsync()
.then(() => {
intro.value = withTiming(
1,
{duration: 1200, easing: Easing.in(Easing.cubic)},
() => {
runOnJS(onFinish)()
{duration: 400, easing: Easing.out(Easing.cubic)},
async () => {
// set these values to check animation at specific point
// outroLogo.value = 0.1
// outroApp.value = 0.1
outroLogo.value = withTiming(
1,
{duration: 1200, easing: Easing.in(Easing.cubic)},
() => {
runOnJS(onFinish)()
},
)
outroApp.value = withTiming(1, {
duration: 1200,
easing: Easing.inOut(Easing.cubic),
})
outroAppOpacity.value = withTiming(1, {
duration: 1200,
easing: Easing.in(Easing.cubic),
})
},
)
outroApp.value = withTiming(1, {
duration: 1200,
easing: Easing.inOut(Easing.cubic),
})
outroAppOpacity.value = withTiming(1, {
duration: 1200,
easing: Easing.in(Easing.cubic),
})
},
)
})
.catch(() => {})
}
}, [onFinish, intro, outroLogo, outroApp, outroAppOpacity, isReady])
const onLoadEnd = useCallback(() => {
setIsImageLoaded(true)
}, [setIsImageLoaded])
useEffect(() => {
AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion)
}, [])
const logoAnimations =
reduceMotion === true ? reducedLogoAnimation : logoAnimation
return (
<View style={{flex: 1}}>
<View style={{flex: 1}} onLayout={onLayout}>
{!isAnimationComplete && (
<Image
accessibilityIgnoresInvertColors
onLoadEnd={onLoadEnd}
source={{uri: splashImageUri}}
source={{uri: isDarkMode ? darkSplashImageUri : splashImageUri}}
style={StyleSheet.absoluteFillObject}
/>
)}
<MaskedView
style={[StyleSheet.absoluteFillObject]}
maskElement={
<Animated.View
style={[
StyleSheet.absoluteFillObject,
{
// Transparent background because mask is based off alpha channel.
backgroundColor: 'transparent',
flex: 1,
justifyContent: 'center',
alignItems: 'center',
transform: [{translateY: -(insets.top / 2)}, {scale: 0.1}], // scale from 1000px to 100px
},
]}>
<AnimatedLogo style={[logoAnimations]} />
</Animated.View>
}>
{!isAnimationComplete && (
<View
style={[StyleSheet.absoluteFillObject, {backgroundColor: 'white'}]}
/>
)}
{isReady &&
(isAndroid || reduceMotion === true ? (
// Use a simple fade on older versions of android (work around a bug)
<>
<Animated.View style={[{flex: 1}, appAnimation]}>
{props.children}
</Animated.View>
<Animated.View style={[{flex: 1}, appAnimation]}>
{props.children}
</Animated.View>
</MaskedView>
{!isAnimationComplete && (
<Animated.View
style={[
StyleSheet.absoluteFillObject,
logoWrapperAnimation,
{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
transform: [{translateY: -(insets.top / 2)}, {scale: 0.1}], // scale from 1000px to 100px
},
]}>
<AnimatedLogo
fill={isDarkMode ? colors.blue3 : '#fff'}
style={[{opacity: 0}, logoAnimations]}
/>
</Animated.View>
)}
</>
) : (
<MaskedView
style={[StyleSheet.absoluteFillObject]}
maskElement={
<Animated.View
style={[
{
// Transparent background because mask is based off alpha channel.
backgroundColor: 'transparent',
flex: 1,
justifyContent: 'center',
alignItems: 'center',
transform: [{translateY: -(insets.top / 2)}, {scale: 0.1}], // scale from 1000px to 100px
},
]}>
<AnimatedLogo
fill={isDarkMode ? colors.blue3 : '#fff'}
style={[logoAnimations]}
/>
</Animated.View>
}>
{!isAnimationComplete && (
<View
style={[
StyleSheet.absoluteFillObject,
{backgroundColor: isDarkMode ? colors.blue3 : '#fff'},
]}
/>
)}
<Animated.View style={[{flex: 1}, appAnimation]}>
{props.children}
</Animated.View>
</MaskedView>
))}
</View>
)
}
+56
View File
@@ -0,0 +1,56 @@
# Application Layout Framework (ALF)
A set of UI primitives and components.
## Usage
Naming conventions follow Tailwind — delimited with a `_` instead of `-` to
enable object access — with a couple exceptions:
**Spacing**
Uses "t-shirt" sizes `xxs`, `xs`, `sm`, `md`, `lg`, `xl` and `xxl` instead of
increments of 4px. We only use a few common spacings, and otherwise typically
rely on many one-off values.
**Text Size**
Uses "t-shirt" sizes `xxs`, `xs`, `sm`, `md`, `lg`, `xl` and `xxl` to match our
type scale.
**Line Height**
The text size atoms also apply a line-height with the same value as the size,
for a 1:1 ratio. `tight` and `normal` are retained for use in the few places
where we need leading.
### Atoms
An (mostly-complete) set of style definitions that match Tailwind CSS selectors.
These are static and reused throughout the app.
```tsx
import { atoms } from '#/alf'
<View style={[atoms.flex_row]} />
```
### Theme
Any values that rely on the theme, namely colors.
```tsx
const t = useTheme()
<View style={[atoms.flex_row, t.atoms.bg]} />
```
### Breakpoints
```tsx
const b = useBreakpoints()
if (b.gtMobile) {
// render tablet or desktop UI
}
```
+514
View File
@@ -0,0 +1,514 @@
import * as tokens from '#/alf/tokens'
export const atoms = {
/*
* Positioning
*/
absolute: {
position: 'absolute',
},
relative: {
position: 'relative',
},
inset_0: {
top: 0,
left: 0,
right: 0,
bottom: 0,
},
z_10: {
zIndex: 10,
},
z_20: {
zIndex: 20,
},
z_30: {
zIndex: 30,
},
z_40: {
zIndex: 40,
},
z_50: {
zIndex: 50,
},
/*
* Width
*/
w_full: {
width: '100%',
},
h_full: {
height: '100%',
},
/*
* Border radius
*/
rounded_sm: {
borderRadius: tokens.borderRadius.sm,
},
rounded_md: {
borderRadius: tokens.borderRadius.md,
},
rounded_full: {
borderRadius: tokens.borderRadius.full,
},
/*
* Flex
*/
gap_xxs: {
gap: tokens.space.xxs,
},
gap_xs: {
gap: tokens.space.xs,
},
gap_sm: {
gap: tokens.space.sm,
},
gap_md: {
gap: tokens.space.md,
},
gap_lg: {
gap: tokens.space.lg,
},
gap_xl: {
gap: tokens.space.xl,
},
gap_xxl: {
gap: tokens.space.xxl,
},
flex: {
display: 'flex',
},
flex_row: {
flexDirection: 'row',
},
flex_wrap: {
flexWrap: 'wrap',
},
flex_1: {
flex: 1,
},
flex_grow: {
flexGrow: 1,
},
flex_shrink: {
flexShrink: 1,
},
justify_center: {
justifyContent: 'center',
},
justify_between: {
justifyContent: 'space-between',
},
justify_end: {
justifyContent: 'flex-end',
},
align_center: {
alignItems: 'center',
},
align_start: {
alignItems: 'flex-start',
},
align_end: {
alignItems: 'flex-end',
},
/*
* Text
*/
text_center: {
textAlign: 'center',
},
text_right: {
textAlign: 'right',
},
text_xxs: {
fontSize: tokens.fontSize.xxs,
lineHeight: tokens.fontSize.xxs,
},
text_xs: {
fontSize: tokens.fontSize.xs,
lineHeight: tokens.fontSize.xs,
},
text_sm: {
fontSize: tokens.fontSize.sm,
lineHeight: tokens.fontSize.sm,
},
text_md: {
fontSize: tokens.fontSize.md,
lineHeight: tokens.fontSize.md,
},
text_lg: {
fontSize: tokens.fontSize.lg,
lineHeight: tokens.fontSize.lg,
},
text_xl: {
fontSize: tokens.fontSize.xl,
lineHeight: tokens.fontSize.xl,
},
text_xxl: {
fontSize: tokens.fontSize.xxl,
lineHeight: tokens.fontSize.xxl,
},
leading_tight: {
lineHeight: 1.25,
},
leading_normal: {
lineHeight: 1.5,
},
font_normal: {
fontWeight: tokens.fontWeight.normal,
},
font_semibold: {
fontWeight: tokens.fontWeight.semibold,
},
font_bold: {
fontWeight: tokens.fontWeight.bold,
},
/*
* Border
*/
border: {
borderWidth: 1,
},
border_t: {
borderTopWidth: 1,
},
border_b: {
borderBottomWidth: 1,
},
/*
* Padding
*/
p_xxs: {
padding: tokens.space.xxs,
},
p_xs: {
padding: tokens.space.xs,
},
p_sm: {
padding: tokens.space.sm,
},
p_md: {
padding: tokens.space.md,
},
p_lg: {
padding: tokens.space.lg,
},
p_xl: {
padding: tokens.space.xl,
},
p_xxl: {
padding: tokens.space.xxl,
},
px_xxs: {
paddingLeft: tokens.space.xxs,
paddingRight: tokens.space.xxs,
},
px_xs: {
paddingLeft: tokens.space.xs,
paddingRight: tokens.space.xs,
},
px_sm: {
paddingLeft: tokens.space.sm,
paddingRight: tokens.space.sm,
},
px_md: {
paddingLeft: tokens.space.md,
paddingRight: tokens.space.md,
},
px_lg: {
paddingLeft: tokens.space.lg,
paddingRight: tokens.space.lg,
},
px_xl: {
paddingLeft: tokens.space.xl,
paddingRight: tokens.space.xl,
},
px_xxl: {
paddingLeft: tokens.space.xxl,
paddingRight: tokens.space.xxl,
},
py_xxs: {
paddingTop: tokens.space.xxs,
paddingBottom: tokens.space.xxs,
},
py_xs: {
paddingTop: tokens.space.xs,
paddingBottom: tokens.space.xs,
},
py_sm: {
paddingTop: tokens.space.sm,
paddingBottom: tokens.space.sm,
},
py_md: {
paddingTop: tokens.space.md,
paddingBottom: tokens.space.md,
},
py_lg: {
paddingTop: tokens.space.lg,
paddingBottom: tokens.space.lg,
},
py_xl: {
paddingTop: tokens.space.xl,
paddingBottom: tokens.space.xl,
},
py_xxl: {
paddingTop: tokens.space.xxl,
paddingBottom: tokens.space.xxl,
},
pt_xxs: {
paddingTop: tokens.space.xxs,
},
pt_xs: {
paddingTop: tokens.space.xs,
},
pt_sm: {
paddingTop: tokens.space.sm,
},
pt_md: {
paddingTop: tokens.space.md,
},
pt_lg: {
paddingTop: tokens.space.lg,
},
pt_xl: {
paddingTop: tokens.space.xl,
},
pt_xxl: {
paddingTop: tokens.space.xxl,
},
pb_xxs: {
paddingBottom: tokens.space.xxs,
},
pb_xs: {
paddingBottom: tokens.space.xs,
},
pb_sm: {
paddingBottom: tokens.space.sm,
},
pb_md: {
paddingBottom: tokens.space.md,
},
pb_lg: {
paddingBottom: tokens.space.lg,
},
pb_xl: {
paddingBottom: tokens.space.xl,
},
pb_xxl: {
paddingBottom: tokens.space.xxl,
},
pl_xxs: {
paddingLeft: tokens.space.xxs,
},
pl_xs: {
paddingLeft: tokens.space.xs,
},
pl_sm: {
paddingLeft: tokens.space.sm,
},
pl_md: {
paddingLeft: tokens.space.md,
},
pl_lg: {
paddingLeft: tokens.space.lg,
},
pl_xl: {
paddingLeft: tokens.space.xl,
},
pl_xxl: {
paddingLeft: tokens.space.xxl,
},
pr_xxs: {
paddingRight: tokens.space.xxs,
},
pr_xs: {
paddingRight: tokens.space.xs,
},
pr_sm: {
paddingRight: tokens.space.sm,
},
pr_md: {
paddingRight: tokens.space.md,
},
pr_lg: {
paddingRight: tokens.space.lg,
},
pr_xl: {
paddingRight: tokens.space.xl,
},
pr_xxl: {
paddingRight: tokens.space.xxl,
},
/*
* Margin
*/
m_xxs: {
margin: tokens.space.xxs,
},
m_xs: {
margin: tokens.space.xs,
},
m_sm: {
margin: tokens.space.sm,
},
m_md: {
margin: tokens.space.md,
},
m_lg: {
margin: tokens.space.lg,
},
m_xl: {
margin: tokens.space.xl,
},
m_xxl: {
margin: tokens.space.xxl,
},
mx_xxs: {
marginLeft: tokens.space.xxs,
marginRight: tokens.space.xxs,
},
mx_xs: {
marginLeft: tokens.space.xs,
marginRight: tokens.space.xs,
},
mx_sm: {
marginLeft: tokens.space.sm,
marginRight: tokens.space.sm,
},
mx_md: {
marginLeft: tokens.space.md,
marginRight: tokens.space.md,
},
mx_lg: {
marginLeft: tokens.space.lg,
marginRight: tokens.space.lg,
},
mx_xl: {
marginLeft: tokens.space.xl,
marginRight: tokens.space.xl,
},
mx_xxl: {
marginLeft: tokens.space.xxl,
marginRight: tokens.space.xxl,
},
my_xxs: {
marginTop: tokens.space.xxs,
marginBottom: tokens.space.xxs,
},
my_xs: {
marginTop: tokens.space.xs,
marginBottom: tokens.space.xs,
},
my_sm: {
marginTop: tokens.space.sm,
marginBottom: tokens.space.sm,
},
my_md: {
marginTop: tokens.space.md,
marginBottom: tokens.space.md,
},
my_lg: {
marginTop: tokens.space.lg,
marginBottom: tokens.space.lg,
},
my_xl: {
marginTop: tokens.space.xl,
marginBottom: tokens.space.xl,
},
my_xxl: {
marginTop: tokens.space.xxl,
marginBottom: tokens.space.xxl,
},
mt_xxs: {
marginTop: tokens.space.xxs,
},
mt_xs: {
marginTop: tokens.space.xs,
},
mt_sm: {
marginTop: tokens.space.sm,
},
mt_md: {
marginTop: tokens.space.md,
},
mt_lg: {
marginTop: tokens.space.lg,
},
mt_xl: {
marginTop: tokens.space.xl,
},
mt_xxl: {
marginTop: tokens.space.xxl,
},
mb_xxs: {
marginBottom: tokens.space.xxs,
},
mb_xs: {
marginBottom: tokens.space.xs,
},
mb_sm: {
marginBottom: tokens.space.sm,
},
mb_md: {
marginBottom: tokens.space.md,
},
mb_lg: {
marginBottom: tokens.space.lg,
},
mb_xl: {
marginBottom: tokens.space.xl,
},
mb_xxl: {
marginBottom: tokens.space.xxl,
},
ml_xxs: {
marginLeft: tokens.space.xxs,
},
ml_xs: {
marginLeft: tokens.space.xs,
},
ml_sm: {
marginLeft: tokens.space.sm,
},
ml_md: {
marginLeft: tokens.space.md,
},
ml_lg: {
marginLeft: tokens.space.lg,
},
ml_xl: {
marginLeft: tokens.space.xl,
},
ml_xxl: {
marginLeft: tokens.space.xxl,
},
mr_xxs: {
marginRight: tokens.space.xxs,
},
mr_xs: {
marginRight: tokens.space.xs,
},
mr_sm: {
marginRight: tokens.space.sm,
},
mr_md: {
marginRight: tokens.space.md,
},
mr_lg: {
marginRight: tokens.space.lg,
},
mr_xl: {
marginRight: tokens.space.xl,
},
mr_xxl: {
marginRight: tokens.space.xxl,
},
} as const
+92
View File
@@ -0,0 +1,92 @@
import React from 'react'
import {Dimensions} from 'react-native'
import * as themes from '#/alf/themes'
export * as tokens from '#/alf/tokens'
export {atoms} from '#/alf/atoms'
export * from '#/alf/util/platform'
type BreakpointName = keyof typeof breakpoints
/*
* Breakpoints
*/
const breakpoints: {
[key: string]: number
} = {
gtMobile: 800,
gtTablet: 1200,
}
function getActiveBreakpoints({width}: {width: number}) {
const active: (keyof typeof breakpoints)[] = Object.keys(breakpoints).filter(
breakpoint => width >= breakpoints[breakpoint],
)
return {
active: active[active.length - 1],
gtMobile: active.includes('gtMobile'),
gtTablet: active.includes('gtTablet'),
}
}
/*
* Context
*/
export const Context = React.createContext<{
themeName: themes.ThemeName
theme: themes.Theme
breakpoints: {
active: BreakpointName | undefined
gtMobile: boolean
gtTablet: boolean
}
}>({
themeName: 'light',
theme: themes.light,
breakpoints: {
active: undefined,
gtMobile: false,
gtTablet: false,
},
})
export function ThemeProvider({
children,
theme: themeName,
}: React.PropsWithChildren<{theme: themes.ThemeName}>) {
const theme = themes[themeName]
const [breakpoints, setBreakpoints] = React.useState(() =>
getActiveBreakpoints({width: Dimensions.get('window').width}),
)
React.useEffect(() => {
const listener = Dimensions.addEventListener('change', ({window}) => {
const bp = getActiveBreakpoints({width: window.width})
if (bp.active !== breakpoints.active) setBreakpoints(bp)
})
return listener.remove
}, [breakpoints, setBreakpoints])
return (
<Context.Provider
value={React.useMemo(
() => ({
themeName: themeName,
theme: theme,
breakpoints,
}),
[theme, themeName, breakpoints],
)}>
{children}
</Context.Provider>
)
}
export function useTheme() {
return React.useContext(Context).theme
}
export function useBreakpoints() {
return React.useContext(Context).breakpoints
}
+108
View File
@@ -0,0 +1,108 @@
import * as tokens from '#/alf/tokens'
import type {Mutable} from '#/alf/types'
export type ThemeName = 'light' | 'dark'
export type ReadonlyTheme = typeof light
export type Theme = Mutable<ReadonlyTheme>
export type Palette = {
primary: string
positive: string
negative: string
}
export const lightPalette: Palette = {
primary: tokens.color.blue_500,
positive: tokens.color.green_500,
negative: tokens.color.red_500,
} as const
export const darkPalette: Palette = {
primary: tokens.color.blue_500,
positive: tokens.color.green_400,
negative: tokens.color.red_400,
} as const
export const light = {
palette: lightPalette,
atoms: {
text: {
color: tokens.color.gray_1000,
},
text_contrast_700: {
color: tokens.color.gray_700,
},
text_contrast_500: {
color: tokens.color.gray_500,
},
text_inverted: {
color: tokens.color.white,
},
bg: {
backgroundColor: tokens.color.white,
},
bg_contrast_100: {
backgroundColor: tokens.color.gray_100,
},
bg_contrast_200: {
backgroundColor: tokens.color.gray_200,
},
bg_contrast_300: {
backgroundColor: tokens.color.gray_300,
},
bg_positive: {
backgroundColor: tokens.color.green_500,
},
bg_negative: {
backgroundColor: tokens.color.red_400,
},
border: {
borderColor: tokens.color.gray_200,
},
border_contrast_500: {
borderColor: tokens.color.gray_500,
},
},
}
export const dark: Theme = {
palette: darkPalette,
atoms: {
text: {
color: tokens.color.white,
},
text_contrast_700: {
color: tokens.color.gray_300,
},
text_contrast_500: {
color: tokens.color.gray_500,
},
text_inverted: {
color: tokens.color.gray_1000,
},
bg: {
backgroundColor: tokens.color.gray_1000,
},
bg_contrast_100: {
backgroundColor: tokens.color.gray_900,
},
bg_contrast_200: {
backgroundColor: tokens.color.gray_800,
},
bg_contrast_300: {
backgroundColor: tokens.color.gray_700,
},
bg_positive: {
backgroundColor: tokens.color.green_400,
},
bg_negative: {
backgroundColor: tokens.color.red_400,
},
border: {
borderColor: tokens.color.gray_800,
},
border_contrast_500: {
borderColor: tokens.color.gray_500,
},
},
}
+100
View File
@@ -0,0 +1,100 @@
const BLUE_HUE = 211
const GRAYSCALE_SATURATION = 22
export const color = {
white: '#FFFFFF',
gray_0: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 100%)`,
gray_100: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 95%)`,
gray_200: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 85%)`,
gray_300: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 75%)`,
gray_400: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 65%)`,
gray_500: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 55%)`,
gray_600: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 45%)`,
gray_700: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 35%)`,
gray_800: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 25%)`,
gray_900: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 15%)`,
gray_1000: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 5%)`,
blue_0: `hsl(${BLUE_HUE}, 99%, 100%)`,
blue_100: `hsl(${BLUE_HUE}, 99%, 93%)`,
blue_200: `hsl(${BLUE_HUE}, 99%, 83%)`,
blue_300: `hsl(${BLUE_HUE}, 99%, 73%)`,
blue_400: `hsl(${BLUE_HUE}, 99%, 63%)`,
blue_500: `hsl(${BLUE_HUE}, 99%, 53%)`,
blue_600: `hsl(${BLUE_HUE}, 99%, 43%)`,
blue_700: `hsl(${BLUE_HUE}, 99%, 33%)`,
blue_800: `hsl(${BLUE_HUE}, 99%, 23%)`,
blue_900: `hsl(${BLUE_HUE}, 99%, 13%)`,
blue_1000: `hsl(${BLUE_HUE}, 99%, 8%)`,
green_0: `hsl(130, 60%, 100%)`,
green_100: `hsl(130, 60%, 95%)`,
green_200: `hsl(130, 60%, 85%)`,
green_300: `hsl(130, 60%, 75%)`,
green_400: `hsl(130, 60%, 65%)`,
green_500: `hsl(130, 60%, 55%)`,
green_600: `hsl(130, 60%, 45%)`,
green_700: `hsl(130, 60%, 35%)`,
green_800: `hsl(130, 60%, 25%)`,
green_900: `hsl(130, 60%, 15%)`,
green_1000: `hsl(130, 60%, 5%)`,
red_0: `hsl(349, 96%, 100%)`,
red_100: `hsl(349, 96%, 95%)`,
red_200: `hsl(349, 96%, 85%)`,
red_300: `hsl(349, 96%, 75%)`,
red_400: `hsl(349, 96%, 65%)`,
red_500: `hsl(349, 96%, 55%)`,
red_600: `hsl(349, 96%, 45%)`,
red_700: `hsl(349, 96%, 35%)`,
red_800: `hsl(349, 96%, 25%)`,
red_900: `hsl(349, 96%, 15%)`,
red_1000: `hsl(349, 96%, 5%)`,
} as const
export const space = {
xxs: 2,
xs: 4,
sm: 8,
md: 12,
lg: 18,
xl: 24,
xxl: 32,
} as const
export const fontSize = {
xxs: 10,
xs: 12,
sm: 14,
md: 16,
lg: 18,
xl: 22,
xxl: 26,
} as const
// TODO test
export const lineHeight = {
none: 1,
normal: 1.5,
relaxed: 1.625,
} as const
export const borderRadius = {
sm: 8,
md: 12,
full: 999,
} as const
export const fontWeight = {
normal: '400',
semibold: '600',
bold: '900',
} as const
export type Color = keyof typeof color
export type Space = keyof typeof space
export type FontSize = keyof typeof fontSize
export type LineHeight = keyof typeof lineHeight
export type BorderRadius = keyof typeof borderRadius
export type FontWeight = keyof typeof fontWeight
+16
View File
@@ -0,0 +1,16 @@
type LiteralToCommon<T extends PropertyKey> = T extends number
? number
: T extends string
? string
: T extends symbol
? symbol
: never
/**
* @see https://stackoverflow.com/questions/68249999/use-as-const-in-typescript-without-adding-readonly-modifiers
*/
export type Mutable<T> = {
-readonly [K in keyof T]: T[K] extends PropertyKey
? LiteralToCommon<T[K]>
: Mutable<T[K]>
}
+25
View File
@@ -0,0 +1,25 @@
import {Platform} from 'react-native'
export function web(value: any) {
return Platform.select({
web: value,
})
}
export function ios(value: any) {
return Platform.select({
ios: value,
})
}
export function android(value: any) {
return Platform.select({
android: value,
})
}
export function native(value: any) {
return Platform.select({
native: value,
})
}
+10
View File
@@ -0,0 +1,10 @@
import {useColorScheme} from 'react-native'
import * as persisted from '#/state/persisted'
export function useColorModeTheme(
theme: persisted.Schema['colorMode'],
): 'light' | 'dark' {
const colorScheme = useColorScheme()
return (theme === 'system' ? colorScheme : theme) || 'light'
}
+5 -18
View File
@@ -1,28 +1,17 @@
import React, {createContext, useContext, useMemo} from 'react'
import {ScrollHandler} from 'react-native-reanimated'
import {NativeScrollEvent} from 'react-native'
import {ScrollHandlers} from 'react-native-reanimated'
type ScrollHandlers = {
onBeginDrag: undefined | ScrollHandler
onEndDrag: undefined | ScrollHandler<any>
onScroll: undefined | ScrollHandler<any>
onScrollEndWeb:
| undefined
| ((e: Pick<NativeScrollEvent, 'contentOffset'>) => void) // Web-only.
}
const ScrollContext = createContext<ScrollHandlers>({
const ScrollContext = createContext<ScrollHandlers<any>>({
onBeginDrag: undefined,
onEndDrag: undefined,
onScroll: undefined,
onScrollEndWeb: undefined,
})
export function useScrollHandlers(): ScrollHandlers {
export function useScrollHandlers(): ScrollHandlers<any> {
return useContext(ScrollContext)
}
type ProviderProps = {children: React.ReactNode} & Partial<ScrollHandlers>
type ProviderProps = {children: React.ReactNode} & ScrollHandlers<any>
// Note: this completely *overrides* the parent handlers.
// It's up to you to compose them with the parent ones via useScrollHandlers() if needed.
@@ -31,16 +20,14 @@ export function ScrollProvider({
onBeginDrag,
onEndDrag,
onScroll,
onScrollEndWeb,
}: ProviderProps) {
const handlers = useMemo(
() => ({
onBeginDrag,
onEndDrag,
onScroll,
onScrollEndWeb,
}),
[onBeginDrag, onEndDrag, onScroll, onScrollEndWeb],
[onBeginDrag, onEndDrag, onScroll],
)
return (
<ScrollContext.Provider value={handlers}>{children}</ScrollContext.Provider>
+2 -30
View File
@@ -1,9 +1,7 @@
import {isWeb} from 'platform/detection'
import React, {ReactNode, createContext, useContext} from 'react'
import {
AppState,
TextStyle,
useColorScheme as useColorScheme_BUGGY,
useColorScheme,
ViewStyle,
ColorSchemeName,
} from 'react-native'
@@ -97,37 +95,11 @@ function getTheme(theme: ColorSchemeName) {
return theme === 'dark' ? darkTheme : defaultTheme
}
/**
* With RN iOS, we can only "trust" the color scheme reported while the app is
* active. This is a workaround until the bug is fixed upstream.
*
* @see https://github.com/bluesky-social/social-app/pull/1417#issuecomment-1719868504
* @see https://github.com/facebook/react-native/pull/39439
*/
function useColorScheme_FIXED() {
const colorScheme = useColorScheme_BUGGY()
const [currentColorScheme, setCurrentColorScheme] =
React.useState<ColorSchemeName>(colorScheme)
React.useEffect(() => {
// we don't need to be updating state on web
if (isWeb) return
const subscription = AppState.addEventListener('change', state => {
const isActive = state === 'active'
if (!isActive) return
setCurrentColorScheme(colorScheme)
})
return () => subscription.remove()
}, [colorScheme])
return isWeb ? colorScheme : currentColorScheme
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({
theme,
children,
}) => {
const colorScheme = useColorScheme_FIXED()
const colorScheme = useColorScheme()
const themeValue = getTheme(theme === 'system' ? colorScheme : theme)
return (
+1
View File
@@ -147,6 +147,7 @@ interface ScreenPropertiesMap {
Settings: {}
AppPasswords: {}
Moderation: {}
PreferencesExternalEmbeds: {}
BlockedAccounts: {}
MutedAccounts: {}
SavedFeeds: {}
+10 -9
View File
@@ -117,11 +117,7 @@ export class FeedViewPostsSlice {
}
export class NoopFeedTuner {
private keyCounter = 0
reset() {
this.keyCounter = 0
}
reset() {}
tune(
feed: FeedViewPost[],
_opts?: {dryRun: boolean; maintainOrder: boolean},
@@ -131,13 +127,13 @@ export class NoopFeedTuner {
}
export class FeedTuner {
private keyCounter = 0
seenKeys: Set<string> = new Set()
seenUris: Set<string> = new Set()
constructor(public tunerFns: FeedTunerFn[]) {}
reset() {
this.keyCounter = 0
this.seenKeys.clear()
this.seenUris.clear()
}
@@ -218,11 +214,16 @@ export class FeedTuner {
}
if (!dryRun) {
for (const slice of slices) {
slices = slices.filter(slice => {
if (this.seenKeys.has(slice._reactKey)) {
return false
}
for (const item of slice.items) {
this.seenUris.add(item.post.uri)
}
}
this.seenKeys.add(slice._reactKey)
return true
})
}
return slices
+1 -1
View File
@@ -98,7 +98,7 @@ export class MergeFeedAPI implements FeedAPI {
}
return {
cursor: posts.length ? String(this.itemCursor) : undefined,
cursor: String(this.itemCursor),
feed: posts,
}
}
-69
View File
@@ -1,69 +0,0 @@
/**
* This is a temporary off-spec search endpoint
* TODO removeme when we land this in proto!
*/
import {AppBskyFeedPost} from '@atproto/api'
const PROFILES_ENDPOINT = 'https://search.bsky.social/search/profiles'
const POSTS_ENDPOINT = 'https://search.bsky.social/search/posts'
export interface ProfileSearchItem {
$type: string
avatar: {
cid: string
mimeType: string
}
banner: {
cid: string
mimeType: string
}
description: string | undefined
displayName: string | undefined
did: string
}
export interface PostSearchItem {
tid: string
cid: string
user: {
did: string
handle: string
}
post: AppBskyFeedPost.Record
}
export async function searchProfiles(
query: string,
): Promise<ProfileSearchItem[]> {
return await doFetch<ProfileSearchItem[]>(PROFILES_ENDPOINT, query)
}
export async function searchPosts(query: string): Promise<PostSearchItem[]> {
return await doFetch<PostSearchItem[]>(POSTS_ENDPOINT, query)
}
async function doFetch<T>(endpoint: string, query: string): Promise<T> {
const controller = new AbortController()
const to = setTimeout(() => controller.abort(), 15e3)
const uri = new URL(endpoint)
uri.searchParams.set('q', query)
const res = await fetch(String(uri), {
method: 'get',
headers: {
accept: 'application/json',
},
signal: controller.signal,
})
const resHeaders: Record<string, string> = {}
res.headers.forEach((value: string, key: string) => {
resHeaders[key] = value
})
let resBody = await res.json()
clearTimeout(to)
return resBody as unknown as T
}
+5 -4
View File
@@ -41,7 +41,7 @@ export function IS_LOCAL_DEV(url: string) {
}
export function IS_STAGING(url: string) {
return !IS_LOCAL_DEV(url) && !IS_PROD(url)
return url.startsWith('https://staging.bsky.dev')
}
export function IS_PROD(url: string) {
@@ -51,7 +51,8 @@ export function IS_PROD(url: string) {
// -prf
return (
url.startsWith('https://bsky.social') ||
url.startsWith('https://api.bsky.app')
url.startsWith('https://api.bsky.app') ||
/bsky\.network\/?$/.test(url)
)
}
@@ -116,8 +117,8 @@ export async function DEFAULT_FEEDS(
} else {
// production
return {
pinned: [],
saved: [],
pinned: [PROD_DEFAULT_FEED('whats-hot')],
saved: [PROD_DEFAULT_FEED('whats-hot')],
}
}
}
+16
View File
@@ -0,0 +1,16 @@
export function usePhotoLibraryPermission() {
const requestPhotoAccessIfNeeded = async () => {
// On the, we use <input type="file"> to produce a filepicker
// This does not need any permission granting.
return true
}
return {requestPhotoAccessIfNeeded}
}
export function useCameraPermission() {
const requestCameraAccessIfNeeded = async () => {
return false
}
return {requestCameraAccessIfNeeded}
}
+8
View File
@@ -2,6 +2,7 @@ import {BskyAgent} from '@atproto/api'
import {isBskyAppUrl} from '../strings/url-helpers'
import {extractBskyMeta} from './bsky'
import {LINK_META_PROXY} from 'lib/constants'
import {getGiphyMetaUri} from 'lib/strings/embed-player'
export enum LikelyType {
HTML,
@@ -34,6 +35,13 @@ export async function getLinkMeta(
let urlp
try {
urlp = new URL(url)
// Get Giphy meta uri if this is any form of giphy link
const giphyMetaUri = getGiphyMetaUri(urlp)
if (giphyMetaUri) {
url = giphyMetaUri
urlp = new URL(url)
}
} catch (e) {
return {
error: 'Invalid URL',
+4 -6
View File
@@ -117,9 +117,6 @@ function createResizedImage(
return reject(new Error('Failed to resize image'))
}
canvas.width = width
canvas.height = height
let scale = 1
if (mode === 'cover') {
scale = img.width < img.height ? width / img.width : height / img.height
@@ -128,10 +125,11 @@ function createResizedImage(
}
let w = img.width * scale
let h = img.height * scale
let x = (width - w) / 2
let y = (height - h) / 2
ctx.drawImage(img, x, y, w, h)
canvas.width = w
canvas.height = h
ctx.drawImage(img, 0, 0, w, h)
resolve(canvas.toDataURL('image/jpeg', quality))
})
img.src = dataUri
+6 -1
View File
@@ -4,6 +4,7 @@ import {
MediaTypeOptions,
} from 'expo-image-picker'
import {getDataUriSize} from './util'
import * as Toast from 'view/com/util/Toast'
export async function openPicker(opts?: ImagePickerOptions) {
const response = await launchImageLibraryAsync({
@@ -13,7 +14,11 @@ export async function openPicker(opts?: ImagePickerOptions) {
...opts,
})
return (response.assets ?? []).map(image => ({
if (response.assets && response.assets.length > 4) {
Toast.show('You may only select up to 4 images')
}
return (response.assets ?? []).slice(0, 4).map(image => ({
mime: 'image/jpeg',
height: image.height,
width: image.width,
+58
View File
@@ -0,0 +1,58 @@
import {
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
moderatePost,
} from '@atproto/api'
type ModeratePost = typeof moderatePost
type Options = Parameters<ModeratePost>[1] & {
hiddenPosts?: string[]
}
export function moderatePost_wrapped(
subject: Parameters<ModeratePost>[0],
opts: Options,
) {
const {hiddenPosts = [], ...options} = opts
const moderations = moderatePost(subject, options)
if (hiddenPosts.includes(subject.uri)) {
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: 'post-hidden',
source: {type: 'user'},
priority: 1,
}
}
}
if (subject.embed) {
let embedHidden = false
if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
embedHidden = hiddenPosts.includes(subject.embed.record.uri)
}
if (
AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
) {
embedHidden = hiddenPosts.includes(subject.embed.record.record.uri)
}
if (embedHidden) {
moderations.embed.filter = true
moderations.embed.blur = true
if (!moderations.embed.cause) {
moderations.embed.cause = {
// @ts-ignore Temporary extension to the moderation system -prf
type: 'post-hidden',
source: {type: 'user'},
priority: 1,
}
}
}
}
return moderations
}
+7
View File
@@ -60,6 +60,13 @@ export function describeModerationCause(
}
}
}
// @ts-ignore Temporary extension to the moderation system -prf
if (cause.type === 'post-hidden') {
return {
name: 'Post Hidden by You',
description: 'You have hidden this post',
}
}
return cause.labelDef.strings[context].en
}
+30 -2
View File
@@ -1,11 +1,39 @@
import {QueryClient} from '@tanstack/react-query'
import {AppState, AppStateStatus} from 'react-native'
import {QueryClient, focusManager} from '@tanstack/react-query'
import {isNative} from '#/platform/detection'
focusManager.setEventListener(onFocus => {
if (isNative) {
const subscription = AppState.addEventListener(
'change',
(status: AppStateStatus) => {
focusManager.setFocused(status === 'active')
},
)
return () => subscription.remove()
} else if (typeof window !== 'undefined' && window.addEventListener) {
// these handlers are a bit redundant but focus catches when the browser window
// is blurred/focused while visibilitychange seems to only handle when the
// window minimizes (both of them catch tab changes)
// there's no harm to redundant fires because refetchOnWindowFocus is only
// used with queries that employ stale data times
const handler = () => onFocus()
window.addEventListener('focus', handler, false)
window.addEventListener('visibilitychange', handler, false)
return () => {
window.removeEventListener('visibilitychange', handler)
window.removeEventListener('focus', handler)
}
}
})
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// NOTE
// refetchOnWindowFocus breaks some UIs (like feeds)
// so we NEVER want to enable this
// so we only selectively want to enable this
// -prf
refetchOnWindowFocus: false,
// Structural sharing between responses makes it impossible to rely on
+1
View File
@@ -32,6 +32,7 @@ export type CommonNavigatorParams = {
SavedFeeds: undefined
PreferencesHomeFeed: undefined
PreferencesThreads: undefined
PreferencesExternalEmbeds: undefined
}
export type BottomTabNavigatorParams = CommonNavigatorParams & {
+301 -45
View File
@@ -1,15 +1,59 @@
export type EmbedPlayerParams =
| {type: 'youtube_video'; videoId: string; playerUri: string}
| {type: 'twitch_live'; channelId: string; playerUri: string}
| {type: 'spotify_album'; albumId: string; playerUri: string}
| {
type: 'spotify_playlist'
playlistId: string
playerUri: string
}
| {type: 'spotify_song'; songId: string; playerUri: string}
| {type: 'soundcloud_track'; user: string; track: string; playerUri: string}
| {type: 'soundcloud_set'; user: string; set: string; playerUri: string}
import {Dimensions, Platform} from 'react-native'
const {height: SCREEN_HEIGHT} = Dimensions.get('window')
export const embedPlayerSources = [
'youtube',
'youtubeShorts',
'twitch',
'spotify',
'soundcloud',
'appleMusic',
'vimeo',
'giphy',
'tenor',
] as const
export type EmbedPlayerSource = (typeof embedPlayerSources)[number]
export type EmbedPlayerType =
| 'youtube_video'
| 'youtube_short'
| 'twitch_video'
| 'spotify_album'
| 'spotify_playlist'
| 'spotify_song'
| 'soundcloud_track'
| 'soundcloud_set'
| 'apple_music_playlist'
| 'apple_music_album'
| 'apple_music_song'
| 'vimeo_video'
| 'giphy_gif'
| 'tenor_gif'
export const externalEmbedLabels: Record<EmbedPlayerSource, string> = {
youtube: 'YouTube',
youtubeShorts: 'YouTube Shorts',
vimeo: 'Vimeo',
twitch: 'Twitch',
giphy: 'GIPHY',
tenor: 'Tenor',
spotify: 'Spotify',
appleMusic: 'Apple Music',
soundcloud: 'SoundCloud',
}
export interface EmbedPlayerParams {
type: EmbedPlayerType
playerUri: string
isGif?: boolean
source: EmbedPlayerSource
metaUri?: string
hideDetails?: boolean
}
const giphyRegex = /media(?:[0-4]\.giphy\.com|\.giphy\.com)/i
const gifFilenameRegex = /^(\S+)\.(webp|gif|mp4)$/i
export function parseEmbedPlayerFromUrl(
url: string,
@@ -27,60 +71,88 @@ export function parseEmbedPlayerFromUrl(
if (videoId) {
return {
type: 'youtube_video',
videoId,
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1`,
source: 'youtube',
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1&playsinline=1`,
}
}
}
if (urlp.hostname === 'www.youtube.com' || urlp.hostname === 'youtube.com') {
if (
urlp.hostname === 'www.youtube.com' ||
urlp.hostname === 'youtube.com' ||
urlp.hostname === 'm.youtube.com'
) {
const [_, page, shortVideoId] = urlp.pathname.split('/')
const videoId =
page === 'shorts' ? shortVideoId : (urlp.searchParams.get('v') as string)
if (videoId) {
return {
type: 'youtube_video',
videoId,
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1`,
type: page === 'shorts' ? 'youtube_short' : 'youtube_video',
source: page === 'shorts' ? 'youtubeShorts' : 'youtube',
hideDetails: page === 'shorts' ? true : undefined,
playerUri: `https://www.youtube.com/embed/${videoId}?autoplay=1&playsinline=1`,
}
}
}
// twitch
if (urlp.hostname === 'twitch.tv' || urlp.hostname === 'www.twitch.tv') {
const parts = urlp.pathname.split('/')
if (parts.length === 2 && parts[1]) {
if (
urlp.hostname === 'twitch.tv' ||
urlp.hostname === 'www.twitch.tv' ||
urlp.hostname === 'm.twitch.tv'
) {
const parent =
Platform.OS === 'web' ? window.location.hostname : 'localhost'
const [_, channelOrVideo, clipOrId, id] = urlp.pathname.split('/')
if (channelOrVideo === 'videos') {
return {
type: 'twitch_live',
channelId: parts[1],
playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=${parts[1]}&parent=localhost`,
type: 'twitch_video',
source: 'twitch',
playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&video=${clipOrId}&parent=${parent}`,
}
} else if (clipOrId === 'clip') {
return {
type: 'twitch_video',
source: 'twitch',
playerUri: `https://clips.twitch.tv/embed?volume=0.5&autoplay=true&clip=${id}&parent=${parent}`,
}
} else if (channelOrVideo) {
return {
type: 'twitch_video',
source: 'twitch',
playerUri: `https://player.twitch.tv/?volume=0.5&!muted&autoplay&channel=${channelOrVideo}&parent=${parent}`,
}
}
}
// spotify
if (urlp.hostname === 'open.spotify.com') {
const [_, type, id] = urlp.pathname.split('/')
if (type && id) {
if (type === 'playlist') {
const [_, typeOrLocale, idOrType, id] = urlp.pathname.split('/')
if (idOrType) {
if (typeOrLocale === 'playlist' || idOrType === 'playlist') {
return {
type: 'spotify_playlist',
playlistId: id,
playerUri: `https://open.spotify.com/embed/playlist/${id}`,
source: 'spotify',
playerUri: `https://open.spotify.com/embed/playlist/${
id ?? idOrType
}`,
}
}
if (type === 'album') {
if (typeOrLocale === 'album' || idOrType === 'album') {
return {
type: 'spotify_album',
albumId: id,
playerUri: `https://open.spotify.com/embed/album/${id}`,
source: 'spotify',
playerUri: `https://open.spotify.com/embed/album/${id ?? idOrType}`,
}
}
if (type === 'track') {
if (typeOrLocale === 'track' || idOrType === 'track') {
return {
type: 'spotify_song',
songId: id,
playerUri: `https://open.spotify.com/embed/track/${id}`,
source: 'spotify',
playerUri: `https://open.spotify.com/embed/track/${id ?? idOrType}`,
}
}
}
@@ -97,20 +169,173 @@ export function parseEmbedPlayerFromUrl(
if (trackOrSets === 'sets' && set) {
return {
type: 'soundcloud_set',
user,
set: set,
source: 'soundcloud',
playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`,
}
}
return {
type: 'soundcloud_track',
user,
track: trackOrSets,
source: 'soundcloud',
playerUri: `https://w.soundcloud.com/player/?url=${url}&auto_play=true&visual=false&hide_related=true`,
}
}
}
if (
urlp.hostname === 'music.apple.com' ||
urlp.hostname === 'music.apple.com'
) {
// This should always have: locale, type (playlist or album), name, and id. We won't use spread since we want
// to check if the length is correct
const pathParams = urlp.pathname.split('/')
const type = pathParams[2]
const songId = urlp.searchParams.get('i')
if (pathParams.length === 5 && (type === 'playlist' || type === 'album')) {
// We want to append the songId to the end of the url if it exists
const embedUri = `https://embed.music.apple.com${urlp.pathname}${
urlp.search ? '?i=' + songId : ''
}`
if (type === 'playlist') {
return {
type: 'apple_music_playlist',
source: 'appleMusic',
playerUri: embedUri,
}
} else if (type === 'album') {
if (songId) {
return {
type: 'apple_music_song',
source: 'appleMusic',
playerUri: embedUri,
}
} else {
return {
type: 'apple_music_album',
source: 'appleMusic',
playerUri: embedUri,
}
}
}
}
}
if (urlp.hostname === 'vimeo.com' || urlp.hostname === 'www.vimeo.com') {
const [_, videoId] = urlp.pathname.split('/')
if (videoId) {
return {
type: 'vimeo_video',
source: 'vimeo',
playerUri: `https://player.vimeo.com/video/${videoId}?autoplay=1`,
}
}
}
if (urlp.hostname === 'giphy.com' || urlp.hostname === 'www.giphy.com') {
const [_, gifs, nameAndId] = urlp.pathname.split('/')
/*
* nameAndId is a string that consists of the name (dash separated) and the id of the gif (the last part of the name)
* We want to get the id of the gif, then direct to media.giphy.com/media/{id}/giphy.webp so we can
* use it in an <Image> component
*/
if (gifs === 'gifs' && nameAndId) {
const gifId = nameAndId.split('-').pop()
if (gifId) {
return {
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: `https://giphy.com/gifs/${gifId}`,
playerUri: `https://i.giphy.com/media/${gifId}/giphy.webp`,
}
}
}
}
// There are five possible hostnames that also can be giphy urls: media.giphy.com and media0-4.giphy.com
// These can include (presumably) a tracking id in the path name, so we have to check for that as well
if (giphyRegex.test(urlp.hostname)) {
// We can link directly to the gif, if its a proper link
const [_, media, trackingOrId, idOrFilename, filename] =
urlp.pathname.split('/')
if (media === 'media') {
if (idOrFilename && gifFilenameRegex.test(idOrFilename)) {
return {
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: `https://giphy.com/gifs/${trackingOrId}`,
playerUri: `https://i.giphy.com/media/${trackingOrId}/giphy.webp`,
}
} else if (filename && gifFilenameRegex.test(filename)) {
return {
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: `https://giphy.com/gifs/${idOrFilename}`,
playerUri: `https://i.giphy.com/media/${idOrFilename}/giphy.webp`,
}
}
}
}
// Finally, we should see if it is a link to i.giphy.com. These links don't necessarily end in .gif but can also
// be .webp
if (urlp.hostname === 'i.giphy.com' || urlp.hostname === 'www.i.giphy.com') {
const [_, mediaOrFilename, filename] = urlp.pathname.split('/')
if (mediaOrFilename === 'media' && filename) {
const gifId = filename.split('.')[0]
return {
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: `https://giphy.com/gifs/${gifId}`,
playerUri: `https://i.giphy.com/media/${gifId}/giphy.webp`,
}
} else if (mediaOrFilename) {
const gifId = mediaOrFilename.split('.')[0]
return {
type: 'giphy_gif',
source: 'giphy',
isGif: true,
hideDetails: true,
metaUri: `https://giphy.com/gifs/${gifId}`,
playerUri: `https://i.giphy.com/media/${
mediaOrFilename.split('.')[0]
}/giphy.webp`,
}
}
}
if (urlp.hostname === 'tenor.com' || urlp.hostname === 'www.tenor.com') {
const [_, pathOrIntl, pathOrFilename, intlFilename] =
urlp.pathname.split('/')
const isIntl = pathOrFilename === 'view'
const filename = isIntl ? intlFilename : pathOrFilename
if ((pathOrIntl === 'view' || pathOrFilename === 'view') && filename) {
const includesExt = filename.split('.').pop() === 'gif'
return {
type: 'tenor_gif',
source: 'tenor',
isGif: true,
hideDetails: true,
playerUri: `${url}${!includesExt ? '.gif' : ''}`,
}
}
}
}
export function getPlayerHeight({
@@ -126,22 +351,53 @@ export function getPlayerHeight({
switch (type) {
case 'youtube_video':
case 'twitch_live':
case 'twitch_video':
case 'vimeo_video':
return (width / 16) * 9
case 'youtube_short':
if (SCREEN_HEIGHT < 600) {
return ((width / 9) * 16) / 1.75
} else {
return ((width / 9) * 16) / 1.5
}
case 'spotify_album':
return 380
case 'apple_music_album':
case 'apple_music_playlist':
case 'spotify_playlist':
return 360
case 'soundcloud_set':
return 380
case 'spotify_song':
if (width <= 300) {
return 180
return 155
}
return 232
case 'soundcloud_track':
return 165
case 'soundcloud_set':
return 360
case 'apple_music_song':
return 150
default:
return width
}
}
export function getGifDims(
originalHeight: number,
originalWidth: number,
viewWidth: number,
) {
const scaledHeight = (originalHeight / originalWidth) * viewWidth
return {
height: scaledHeight > 250 ? 250 : scaledHeight,
width: (250 / scaledHeight) * viewWidth,
}
}
export function getGiphyMetaUri(url: URL) {
if (giphyRegex.test(url.hostname) || url.hostname === 'i.giphy.com') {
const params = parseEmbedPlayerFromUrl(url.toString())
if (params && params.type === 'giphy_gif') {
return params.metaUri
}
}
}
+29
View File
@@ -0,0 +1,29 @@
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
import {linkRequiresWarning} from './url-helpers'
export function richTextToString(rt: RichText): string {
const {text, facets} = rt
if (!facets?.length) {
return text
}
let result = ''
for (const segment of rt.segments()) {
const link = segment.link
if (link && AppBskyRichtextFacet.validateLink(link).success) {
const href = link.uri
const text = segment.text
const requiresWarning = linkRequiresWarning(href, text)
result += !requiresWarning ? href : `[${text}](${href})`
} else {
result += segment.text
}
}
return result
}
+1
View File
@@ -167,6 +167,7 @@ export const s = StyleSheet.create({
flexGrow1: {flexGrow: 1},
alignCenter: {alignItems: 'center'},
alignBaseline: {alignItems: 'baseline'},
justifyCenter: {justifyContent: 'center'},
// position
absolute: {position: 'absolute'},
+2
View File
@@ -25,6 +25,7 @@ export const defaultTheme: Theme = {
postCtrl: '#71768A',
brandText: '#0066FF',
emptyStateIcon: '#B6B6C9',
borderLinkHover: '#cac1c1',
},
primary: {
background: colors.blue3,
@@ -310,6 +311,7 @@ export const darkTheme: Theme = {
postCtrl: '#707489',
brandText: '#0085ff',
emptyStateIcon: colors.gray4,
borderLinkHover: colors.gray5,
},
primary: {
...defaultTheme.palette.primary,
+2
View File
@@ -5,7 +5,9 @@ import {AppLanguage} from '#/locale/languages'
test('sanitizeAppLanguageSetting', () => {
expect(sanitizeAppLanguageSetting('en')).toBe(AppLanguage.en)
expect(sanitizeAppLanguageSetting('pt-BR')).toBe(AppLanguage.pt_BR)
expect(sanitizeAppLanguageSetting('hi')).toBe(AppLanguage.hi)
expect(sanitizeAppLanguageSetting('id')).toBe(AppLanguage.id)
expect(sanitizeAppLanguageSetting('foo')).toBe(AppLanguage.en)
expect(sanitizeAppLanguageSetting('en,foo')).toBe(AppLanguage.en)
expect(sanitizeAppLanguageSetting('foo,en')).toBe(AppLanguage.en)
+17 -9
View File
@@ -110,20 +110,28 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
switch (lang) {
case 'en':
return AppLanguage.en
case 'hi':
return AppLanguage.hi
case 'ja':
return AppLanguage.ja
case 'fr':
return AppLanguage.fr
case 'de':
return AppLanguage.de
// DISABLED until this translation is fixed -prf
// case 'de':
// return AppLanguage.de
case 'es':
return AppLanguage.es
case 'fr':
return AppLanguage.fr
case 'hi':
return AppLanguage.hi
case 'id':
return AppLanguage.id
case 'ja':
return AppLanguage.ja
case 'ko':
return AppLanguage.ko
case 'pt-BR':
return AppLanguage.pt_BR
case 'uk':
return AppLanguage.uk
default:
continue
}
}
return AppLanguage.en
}
+35 -13
View File
@@ -3,11 +3,16 @@ import {i18n} from '@lingui/core'
import {useLanguagePrefs} from '#/state/preferences'
import {messages as messagesEn} from '#/locale/locales/en/messages'
// DISABLED until this translation is fixed -prf
// import {messages as messagesDe} from '#/locale/locales/de/messages'
import {messages as messagesId} from '#/locale/locales/id/messages'
import {messages as messagesEs} from '#/locale/locales/es/messages'
import {messages as messagesFr} from '#/locale/locales/fr/messages'
import {messages as messagesHi} from '#/locale/locales/hi/messages'
import {messages as messagesJa} from '#/locale/locales/ja/messages'
import {messages as messagesFr} from '#/locale/locales/fr/messages'
import {messages as messagesDe} from '#/locale/locales/de/messages'
import {messages as messagesEs} from '#/locale/locales/de/messages'
import {messages as messagesKo} from '#/locale/locales/ko/messages'
import {messages as messagesPt_BR} from '#/locale/locales/pt-BR/messages'
import {messages as messagesUk} from '#/locale/locales/uk/messages'
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {AppLanguage} from '#/locale/languages'
@@ -17,24 +22,41 @@ import {AppLanguage} from '#/locale/languages'
*/
export async function dynamicActivate(locale: AppLanguage) {
switch (locale) {
case AppLanguage.hi: {
i18n.loadAndActivate({locale, messages: messagesHi})
break
}
case AppLanguage.ja: {
i18n.loadAndActivate({locale, messages: messagesJa})
// DISABLED until this translation is fixed -prf
// case AppLanguage.de: {
// i18n.loadAndActivate({locale, messages: messagesDe})
// break
// }
case AppLanguage.es: {
i18n.loadAndActivate({locale, messages: messagesEs})
break
}
case AppLanguage.fr: {
i18n.loadAndActivate({locale, messages: messagesFr})
break
}
case AppLanguage.de: {
i18n.loadAndActivate({locale, messages: messagesDe})
case AppLanguage.hi: {
i18n.loadAndActivate({locale, messages: messagesHi})
break
}
case AppLanguage.es: {
i18n.loadAndActivate({locale, messages: messagesEs})
case AppLanguage.id: {
i18n.loadAndActivate({locale, messages: messagesId})
break
}
case AppLanguage.ja: {
i18n.loadAndActivate({locale, messages: messagesJa})
break
}
case AppLanguage.ko: {
i18n.loadAndActivate({locale, messages: messagesKo})
break
}
case AppLanguage.pt_BR: {
i18n.loadAndActivate({locale, messages: messagesPt_BR})
break
}
case AppLanguage.uk: {
i18n.loadAndActivate({locale, messages: messagesUk})
break
}
default: {
+27 -10
View File
@@ -12,24 +12,41 @@ export async function dynamicActivate(locale: AppLanguage) {
let mod: any
switch (locale) {
case AppLanguage.hi: {
mod = await import(`./locales/hi/messages`)
break
}
case AppLanguage.ja: {
mod = await import(`./locales/ja/messages`)
// DISABLED until this translation is fixed -prf
// case AppLanguage.de: {
// mod = await import(`./locales/de/messages`)
// break
// }
case AppLanguage.es: {
mod = await import(`./locales/es/messages`)
break
}
case AppLanguage.fr: {
mod = await import(`./locales/fr/messages`)
break
}
case AppLanguage.de: {
mod = await import(`./locales/de/messages`)
case AppLanguage.hi: {
mod = await import(`./locales/hi/messages`)
break
}
case AppLanguage.es: {
mod = await import(`./locales/es/messages`)
case AppLanguage.id: {
mod = await import(`./locales/id/messages`)
break
}
case AppLanguage.ja: {
mod = await import(`./locales/ja/messages`)
break
}
case AppLanguage.ko: {
mod = await import(`./locales/ko/messages`)
break
}
case AppLanguage.pt_BR: {
mod = await import(`./locales/pt-BR/messages`)
break
}
case AppLanguage.uk: {
mod = await import(`./locales/uk/messages`)
break
}
default: {
+18 -8
View File
@@ -6,11 +6,16 @@ interface Language {
export enum AppLanguage {
en = 'en',
hi = 'hi',
ja = 'ja',
fr = 'fr',
de = 'de',
// DISABLED until this translation is fixed -prf
// de = 'de',
es = 'es',
fr = 'fr',
hi = 'hi',
id = 'id',
ja = 'ja',
ko = 'ko',
pt_BR = 'pt-BR',
uk = 'uk',
}
interface AppLanguageConfig {
@@ -20,11 +25,16 @@ interface AppLanguageConfig {
export const APP_LANGUAGES: AppLanguageConfig[] = [
{code2: AppLanguage.en, name: 'English'},
{code2: AppLanguage.hi, name: 'हिंदी'},
{code2: AppLanguage.ja, name: '日本語'},
{code2: AppLanguage.fr, name: 'Français'},
{code2: AppLanguage.de, name: 'Deutsch'},
// DISABLED until this translation is fixed -prf
// {code2: AppLanguage.de, name: 'Deutsch'},
{code2: AppLanguage.es, name: 'Español'},
{code2: AppLanguage.fr, name: 'Français'},
{code2: AppLanguage.hi, name: 'हिंदी'},
{code2: AppLanguage.id, name: 'Bahasa Indonesia'},
{code2: AppLanguage.ja, name: '日本語'},
{code2: AppLanguage.ko, name: '한국어'},
{code2: AppLanguage.pt_BR, name: 'Português (BR)'},
{code2: AppLanguage.uk, name: 'Українська'},
]
export const LANGUAGES: Language[] = [
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
// HACK
// expo-modules-core tries to require('crypto') in uuid.web.js
// and while it tries to detect web crypto before doing so, our
// build fails when it tries to do this require. We use a babel
// and tsconfig alias to direct it here
// -prf
export default crypto
+4 -2
View File
@@ -14,5 +14,7 @@ export const isMobileWeb =
global.window.matchMedia(isMobileWebMediaQuery)?.matches
export const deviceLocales = dedupArray(
getLocales?.().map?.(locale => locale.languageCode),
)
getLocales?.()
.map?.(locale => locale.languageCode)
.filter(code => typeof code === 'string'),
) as string[]
+1
View File
@@ -26,6 +26,7 @@ export const router = new Router({
AppPasswords: '/settings/app-passwords',
PreferencesHomeFeed: '/settings/home-feed',
PreferencesThreads: '/settings/threads',
PreferencesExternalEmbeds: '/settings/external-embeds',
SavedFeeds: '/settings/saved-feeds',
Support: '/support',
PrivacyPolicy: '/support/privacy',
+8
View File
@@ -6,6 +6,7 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
import {ImageModel} from '#/state/models/media/image'
import {GalleryModel} from '#/state/models/media/gallery'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {EmbedPlayerSource} from '#/lib/strings/embed-player.ts'
import {ThreadgateSetting} from '../queries/threadgate'
export interface ConfirmModal {
@@ -180,6 +181,12 @@ export interface LinkWarningModal {
href: string
}
export interface EmbedConsentModal {
name: 'embed-consent'
source: EmbedPlayerSource
onAccept: () => void
}
export type Modal =
// Account
| AddAppPasswordModal
@@ -223,6 +230,7 @@ export type Modal =
// Generic
| ConfirmModal
| LinkWarningModal
| EmbedConsentModal
const ModalContext = React.createContext<{
isModalActive: boolean
+2
View File
@@ -108,6 +108,8 @@ export function transform(legacy: Partial<LegacySchema>): Schema {
onboarding: {
step: legacy.onboarding?.step || defaults.onboarding.step,
},
hiddenPosts: defaults.hiddenPosts,
externalEmbeds: defaults.externalEmbeds,
}
}
+18
View File
@@ -1,6 +1,8 @@
import {z} from 'zod'
import {deviceLocales} from '#/platform/detection'
const externalEmbedOptions = ['show', 'hide'] as const
// only data needed for rendering account page
const accountSchema = z.object({
service: z.string(),
@@ -30,6 +32,19 @@ export const schema = z.object({
appLanguage: z.string(),
}),
requireAltTextEnabled: z.boolean(), // should move to server
externalEmbeds: z
.object({
giphy: z.enum(externalEmbedOptions).optional(),
tenor: z.enum(externalEmbedOptions).optional(),
youtube: z.enum(externalEmbedOptions).optional(),
youtubeShorts: z.enum(externalEmbedOptions).optional(),
twitch: z.enum(externalEmbedOptions).optional(),
vimeo: z.enum(externalEmbedOptions).optional(),
spotify: z.enum(externalEmbedOptions).optional(),
appleMusic: z.enum(externalEmbedOptions).optional(),
soundcloud: z.enum(externalEmbedOptions).optional(),
})
.optional(),
mutedThreads: z.array(z.string()), // should move to server
invites: z.object({
copiedInvites: z.array(z.string()),
@@ -37,6 +52,7 @@ export const schema = z.object({
onboarding: z.object({
step: z.string(),
}),
hiddenPosts: z.array(z.string()).optional(), // should move to server
})
export type Schema = z.infer<typeof schema>
@@ -59,6 +75,7 @@ export const defaults: Schema = {
appLanguage: deviceLocales[0] || 'en',
},
requireAltTextEnabled: false,
externalEmbeds: {},
mutedThreads: [],
invites: {
copiedInvites: [],
@@ -66,4 +83,5 @@ export const defaults: Schema = {
onboarding: {
step: 'Home',
},
hiddenPosts: [],
}
@@ -0,0 +1,54 @@
import React from 'react'
import * as persisted from '#/state/persisted'
import {EmbedPlayerSource} from 'lib/strings/embed-player'
type StateContext = persisted.Schema['externalEmbeds']
type SetContext = (source: EmbedPlayerSource, value: 'show' | 'hide') => void
const stateContext = React.createContext<StateContext>(
persisted.defaults.externalEmbeds,
)
const setContext = React.createContext<SetContext>({} as SetContext)
export function Provider({children}: React.PropsWithChildren<{}>) {
const [state, setState] = React.useState(persisted.get('externalEmbeds'))
const setStateWrapped = React.useCallback(
(source: EmbedPlayerSource, value: 'show' | 'hide') => {
setState(prev => {
persisted.write('externalEmbeds', {
...prev,
[source]: value,
})
return {
...prev,
[source]: value,
}
})
},
[setState],
)
React.useEffect(() => {
return persisted.onUpdate(() => {
setState(persisted.get('externalEmbeds'))
})
}, [setStateWrapped])
return (
<stateContext.Provider value={state}>
<setContext.Provider value={setStateWrapped}>
{children}
</setContext.Provider>
</stateContext.Provider>
)
}
export function useExternalEmbedsPrefs() {
return React.useContext(stateContext)
}
export function useSetExternalEmbedPref() {
return React.useContext(setContext)
}
+64
View File
@@ -0,0 +1,64 @@
import React from 'react'
import * as persisted from '#/state/persisted'
type SetStateCb = (
s: persisted.Schema['hiddenPosts'],
) => persisted.Schema['hiddenPosts']
type StateContext = persisted.Schema['hiddenPosts']
type ApiContext = {
hidePost: ({uri}: {uri: string}) => void
unhidePost: ({uri}: {uri: string}) => void
}
const stateContext = React.createContext<StateContext>(
persisted.defaults.hiddenPosts,
)
const apiContext = React.createContext<ApiContext>({
hidePost: () => {},
unhidePost: () => {},
})
export function Provider({children}: React.PropsWithChildren<{}>) {
const [state, setState] = React.useState(persisted.get('hiddenPosts'))
const setStateWrapped = React.useCallback(
(fn: SetStateCb) => {
const s = fn(persisted.get('hiddenPosts'))
setState(s)
persisted.write('hiddenPosts', s)
},
[setState],
)
const api = React.useMemo(
() => ({
hidePost: ({uri}: {uri: string}) => {
setStateWrapped(s => [...(s || []), uri])
},
unhidePost: ({uri}: {uri: string}) => {
setStateWrapped(s => (s || []).filter(u => u !== uri))
},
}),
[setStateWrapped],
)
React.useEffect(() => {
return persisted.onUpdate(() => {
setState(persisted.get('hiddenPosts'))
})
}, [setStateWrapped])
return (
<stateContext.Provider value={state}>
<apiContext.Provider value={api}>{children}</apiContext.Provider>
</stateContext.Provider>
)
}
export function useHiddenPosts() {
return React.useContext(stateContext)
}
export function useHiddenPostsApi() {
return React.useContext(apiContext)
}
+12 -1
View File
@@ -1,17 +1,28 @@
import React from 'react'
import {Provider as LanguagesProvider} from './languages'
import {Provider as AltTextRequiredProvider} from '../preferences/alt-text-required'
import {Provider as HiddenPostsProvider} from '../preferences/hidden-posts'
import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs'
export {useLanguagePrefs, useLanguagePrefsApi} from './languages'
export {
useRequireAltTextEnabled,
useSetRequireAltTextEnabled,
} from './alt-text-required'
export {
useExternalEmbedsPrefs,
useSetExternalEmbedPref,
} from './external-embeds-prefs'
export * from './hidden-posts'
export function Provider({children}: React.PropsWithChildren<{}>) {
return (
<LanguagesProvider>
<AltTextRequiredProvider>{children}</AltTextRequiredProvider>
<AltTextRequiredProvider>
<ExternalEmbedsProvider>
<HiddenPostsProvider>{children}</HiddenPostsProvider>
</ExternalEmbedsProvider>
</AltTextRequiredProvider>
</LanguagesProvider>
)
}
+3 -1
View File
@@ -24,6 +24,8 @@ export function useActorAutocompleteQuery(prefix: string) {
const {data: follows, isFetching} = useMyFollowsQuery()
const moderationOpts = useModerationOpts()
prefix = prefix.toLowerCase()
return useQuery<AppBskyActorDefs.ProfileViewBasic[]>({
staleTime: STALE.MINUTES.ONE,
queryKey: RQKEY(prefix || ''),
@@ -112,7 +114,7 @@ function computeSuggestions(
}
return items.filter(profile => {
const mod = moderateProfile(profile, moderationOpts)
return !mod.account.filter
return !mod.account.filter && mod.account.cause?.type !== 'muted'
})
}
-1
View File
@@ -9,7 +9,6 @@ export const RQKEY = () => ['app-passwords']
export function useAppPasswordsQuery() {
return useQuery({
staleTime: STALE.MINUTES.FIVE,
refetchInterval: STALE.MINUTES.ONE,
queryKey: RQKEY(),
queryFn: async () => {
const res = await getAgent().com.atproto.server.listAppPasswords({})
+4 -1
View File
@@ -218,11 +218,13 @@ const FOLLOWING_FEED_STUB: FeedSourceInfo = {
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()
const hasPinnedCustom = React.useMemo<boolean>(() => {
@@ -284,10 +286,11 @@ export function usePinnedFeedsInfos(): {
) as FeedSourceInfo[]
setTabs([FOLLOWING_FEED_STUB].concat(views))
setLoading(false)
}
fetchFeedInfo()
}, [queryClient, setTabs, preferences?.feeds?.pinned])
return {feeds: tabs, hasPinnedCustom}
return {feeds: tabs, hasPinnedCustom, isLoading}
}
-1
View File
@@ -16,7 +16,6 @@ export type InviteCodesQueryResponse = Exclude<
export function useInviteCodesQuery() {
return useQuery({
staleTime: STALE.MINUTES.FIVE,
refetchInterval: STALE.MINUTES.FIVE,
queryKey: ['inviteCodes'],
queryFn: async () => {
const res = await getAgent()
+1
View File
@@ -35,4 +35,5 @@ export interface CachedFeedPage {
usableInFeed: boolean
syncedAt: Date
data: FeedPage | undefined
unreadCount: number
}
+35 -3
View File
@@ -15,6 +15,7 @@ import {useMutedThreads} from '#/state/muted-threads'
import {RQKEY as RQKEY_NOTIFS} from './feed'
import {logger} from '#/logger'
import {truncateAndInvalidate} from '../util'
import {AppState} from 'react-native'
const UPDATE_INTERVAL = 30 * 1e3 // 30sec
@@ -24,7 +25,10 @@ type StateContext = string
interface ApiContext {
markAllRead: () => Promise<void>
checkUnread: (opts?: {invalidate?: boolean}) => Promise<void>
checkUnread: (opts?: {
invalidate?: boolean
isPoll?: boolean
}) => Promise<void>
getCachedUnreadPage: () => FeedPage | undefined
}
@@ -49,6 +53,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
usableInFeed: false,
syncedAt: new Date(),
data: undefined,
unreadCount: 0,
})
// periodic sync
@@ -57,7 +62,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
return
}
checkUnreadRef.current() // fire on init
const interval = setInterval(checkUnreadRef.current, UPDATE_INTERVAL)
const interval = setInterval(
() => checkUnreadRef.current?.({isPoll: true}),
UPDATE_INTERVAL,
)
return () => clearInterval(interval)
}, [hasSession])
@@ -68,6 +76,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
usableInFeed: false,
syncedAt: new Date(),
data: undefined,
unreadCount:
data.event === '30+'
? 30
: data.event === ''
? 0
: parseInt(data.event, 10) || 1,
}
setNumUnread(data.event)
}
@@ -89,11 +103,28 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
// update & broadcast
setNumUnread('')
broadcast.postMessage({event: ''})
if (isNative) {
Notifications.setBadgeCountAsync(0)
}
},
async checkUnread({invalidate}: {invalidate?: boolean} = {}) {
async checkUnread({
invalidate,
isPoll,
}: {invalidate?: boolean; isPoll?: boolean} = {}) {
try {
if (!getAgent().session) return
if (AppState.currentState !== 'active') {
return
}
// reduce polling if unread count is set
if (isPoll && cacheRef.current?.unreadCount !== 0) {
// if hit 30+ then don't poll, otherwise reduce polling by 50%
if (cacheRef.current?.unreadCount >= 30 || Math.random() >= 0.5) {
return
}
}
// count
const page = await fetchPage({
@@ -126,6 +157,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
usableInFeed: !!invalidate, // will be used immediately
data: page,
syncedAt: !lastIndexed || now > lastIndexed ? now : lastIndexed,
unreadCount,
}
// update & broadcast
+4 -2
View File
@@ -2,12 +2,12 @@ import {
AppBskyNotificationListNotifications,
ModerationOpts,
moderateProfile,
moderatePost,
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyFeedRepost,
AppBskyFeedLike,
} from '@atproto/api'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import chunk from 'lodash.chunk'
import {QueryClient} from '@tanstack/react-query'
import {getAgent} from '../../session'
@@ -156,7 +156,7 @@ async function fetchSubjects(
): Promise<Map<string, AppBskyFeedDefs.PostView>> {
const uris = new Set<string>()
for (const notif of groupedNotifs) {
if (notif.subjectUri) {
if (notif.subjectUri && !notif.subjectUri.includes('feed.generator')) {
uris.add(notif.subjectUri)
}
}
@@ -216,6 +216,8 @@ function getSubjectUri(
? notif.record.subject?.uri
: undefined
}
} else if (type === 'feedgen-like') {
return notif.reasonSubject
}
}
+6 -6
View File
@@ -1,10 +1,6 @@
import React, {useCallback, useEffect, useRef} from 'react'
import {
AppBskyFeedDefs,
AppBskyFeedPost,
moderatePost,
PostModeration,
} from '@atproto/api'
import {AppState} from 'react-native'
import {AppBskyFeedDefs, AppBskyFeedPost, PostModeration} from '@atproto/api'
import {
useInfiniteQuery,
InfiniteData,
@@ -12,6 +8,7 @@ import {
QueryClient,
useQueryClient,
} from '@tanstack/react-query'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {useFeedTuners} from '../preferences/feed-tuners'
import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip'
import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
@@ -316,6 +313,9 @@ export async function pollLatest(page: FeedPage | undefined) {
if (!page) {
return false
}
if (AppState.currentState !== 'active') {
return
}
logger.debug('usePostFeedQuery: pollLatest')
const post = await page.api.peekLatest()
+10 -3
View File
@@ -19,6 +19,7 @@ import {
} from '#/state/queries/preferences/const'
import {getModerationOpts} from '#/state/queries/preferences/moderation'
import {STALE} from '#/state/queries'
import {useHiddenPosts} from '#/state/preferences/hidden-posts'
export * from '#/state/queries/preferences/types'
export * from '#/state/queries/preferences/moderation'
@@ -30,7 +31,7 @@ export function usePreferencesQuery() {
return useQuery({
staleTime: STALE.SECONDS.FIFTEEN,
structuralSharing: true,
refetchInterval: STALE.SECONDS.FIFTEEN,
refetchOnWindowFocus: true,
queryKey: preferencesQueryKey,
queryFn: async () => {
const agent = getAgent()
@@ -94,15 +95,21 @@ export function usePreferencesQuery() {
export function useModerationOpts() {
const {currentAccount} = useSession()
const prefs = usePreferencesQuery()
const hiddenPosts = useHiddenPosts()
const opts = useMemo(() => {
if (!prefs.data) {
return
}
return getModerationOpts({
const moderationOpts = getModerationOpts({
userDid: currentAccount?.did || '',
preferences: prefs.data,
})
}, [currentAccount?.did, prefs.data])
return {
...moderationOpts,
hiddenPosts,
}
}, [currentAccount?.did, prefs.data, hiddenPosts])
return opts
}
+1 -1
View File
@@ -35,7 +35,7 @@ export function useProfileQuery({did}: {did: string | undefined}) {
// if you remove it, the UI infinite-loops
// -prf
staleTime: isCurrentAccount ? STALE.SECONDS.THIRTY : STALE.MINUTES.FIVE,
refetchInterval: STALE.MINUTES.FIVE,
refetchOnWindowFocus: true,
queryKey: RQKEY(did || ''),
queryFn: async () => {
const res = await getAgent().getProfile({actor: did || ''})
+49 -31
View File
@@ -102,10 +102,21 @@ function createPersistSessionHandler(
expired: boolean
refreshedAccount: SessionAccount
}) => void,
{
networkErrorCallback,
}: {
networkErrorCallback?: () => void
} = {},
): AtpPersistSessionHandler {
return function persistSession(event, session) {
const expired = event === 'expired' || event === 'create-failed'
if (event === 'network-error') {
logger.warn(`session: persistSessionHandler received network-error event`)
networkErrorCallback?.()
return
}
const refreshedAccount: SessionAccount = {
service: account.service,
did: session?.did || account.did,
@@ -125,9 +136,11 @@ function createPersistSessionHandler(
event,
did: refreshedAccount.did,
handle: refreshedAccount.handle,
service: refreshedAccount.service,
})
if (expired) {
logger.warn(`session: expired`)
emitSessionDropped()
}
@@ -179,16 +192,26 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
[setStateAndPersist],
)
const clearCurrentAccount = React.useCallback(() => {
logger.debug(
`session: clear current account`,
{},
logger.DebugContext.session,
)
__globalAgent = PUBLIC_BSKY_AGENT
queryClient.clear()
setStateAndPersist(s => ({
...s,
currentAccount: undefined,
}))
}, [setStateAndPersist, queryClient])
const createAccount = React.useCallback<ApiContext['createAccount']>(
async ({service, email, password, handle, inviteCode}: any) => {
logger.debug(
`session: creating account`,
{
service,
handle,
},
logger.DebugContext.session,
)
logger.info(`session: creating account`, {
service,
handle,
})
track('Try Create Account')
const agent = new BskyAgent({service})
@@ -215,9 +238,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}
agent.setPersistSessionHandler(
createPersistSessionHandler(account, ({expired, refreshedAccount}) => {
upsertAccount(refreshedAccount, expired)
}),
createPersistSessionHandler(
account,
({expired, refreshedAccount}) => {
upsertAccount(refreshedAccount, expired)
},
{networkErrorCallback: clearCurrentAccount},
),
)
__globalAgent = agent
@@ -234,7 +261,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
track('Create Account')
},
[upsertAccount, queryClient],
[upsertAccount, queryClient, clearCurrentAccount],
)
const login = React.useCallback<ApiContext['login']>(
@@ -267,9 +294,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}
agent.setPersistSessionHandler(
createPersistSessionHandler(account, ({expired, refreshedAccount}) => {
upsertAccount(refreshedAccount, expired)
}),
createPersistSessionHandler(
account,
({expired, refreshedAccount}) => {
upsertAccount(refreshedAccount, expired)
},
{networkErrorCallback: clearCurrentAccount},
),
)
__globalAgent = agent
@@ -287,23 +318,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
track('Sign In', {resumedSession: false})
},
[upsertAccount, queryClient],
[upsertAccount, queryClient, clearCurrentAccount],
)
const clearCurrentAccount = React.useCallback(() => {
logger.debug(
`session: clear current account`,
{},
logger.DebugContext.session,
)
__globalAgent = PUBLIC_BSKY_AGENT
queryClient.clear()
setStateAndPersist(s => ({
...s,
currentAccount: undefined,
}))
}, [setStateAndPersist, queryClient])
const logout = React.useCallback<ApiContext['logout']>(async () => {
clearCurrentAccount()
logger.debug(`session: logout`, {}, logger.DebugContext.session)
@@ -337,6 +354,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
({expired, refreshedAccount}) => {
upsertAccount(refreshedAccount, expired)
},
{networkErrorCallback: clearCurrentAccount},
),
})
@@ -437,7 +455,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}
}
},
[upsertAccount, queryClient],
[upsertAccount, queryClient, clearCurrentAccount],
)
const resumeSession = React.useCallback<ApiContext['resumeSession']>(
+2
View File
@@ -11,6 +11,7 @@ export interface ComposerOptsPostRef {
displayName?: string
avatar?: string
}
embed?: AppBskyEmbedRecord.ViewRecord['embed']
}
export interface ComposerOptsQuote {
uri: string
@@ -30,6 +31,7 @@ export interface ComposerOpts {
onPost?: () => void
quote?: ComposerOptsQuote
mention?: string // handle of user to mention
openPicker?: (pos: DOMRect | undefined) => void
}
type StateContext = ComposerOpts | undefined
+204
View File
@@ -0,0 +1,204 @@
import React from 'react'
import {Pressable, Text, PressableProps, TextProps} from 'react-native'
import * as tokens from '#/alf/tokens'
import {atoms} from '#/alf'
export type ButtonType =
| 'primary'
| 'secondary'
| 'tertiary'
| 'positive'
| 'negative'
export type ButtonSize = 'small' | 'large'
export type VariantProps = {
type?: ButtonType
size?: ButtonSize
}
type ButtonState = {
pressed: boolean
hovered: boolean
focused: boolean
}
export type ButtonProps = Omit<PressableProps, 'children'> &
VariantProps & {
children:
| ((props: {
state: ButtonState
type?: ButtonType
size?: ButtonSize
}) => React.ReactNode)
| React.ReactNode
| string
}
export type ButtonTextProps = TextProps & VariantProps
export function Button({children, style, type, size, ...rest}: ButtonProps) {
const {baseStyles, hoverStyles} = React.useMemo(() => {
const baseStyles = []
const hoverStyles = []
switch (type) {
case 'primary':
baseStyles.push({
backgroundColor: tokens.color.blue_500,
})
break
case 'secondary':
baseStyles.push({
backgroundColor: tokens.color.gray_200,
})
hoverStyles.push({
backgroundColor: tokens.color.gray_100,
})
break
default:
}
switch (size) {
case 'large':
baseStyles.push(
atoms.py_md,
atoms.px_xl,
atoms.rounded_md,
atoms.gap_sm,
)
break
case 'small':
baseStyles.push(
atoms.py_sm,
atoms.px_md,
atoms.rounded_sm,
atoms.gap_xs,
)
break
default:
}
return {
baseStyles,
hoverStyles,
}
}, [type, size])
const [state, setState] = React.useState({
pressed: false,
hovered: false,
focused: false,
})
const onPressIn = React.useCallback(() => {
setState(s => ({
...s,
pressed: true,
}))
}, [setState])
const onPressOut = React.useCallback(() => {
setState(s => ({
...s,
pressed: false,
}))
}, [setState])
const onHoverIn = React.useCallback(() => {
setState(s => ({
...s,
hovered: true,
}))
}, [setState])
const onHoverOut = React.useCallback(() => {
setState(s => ({
...s,
hovered: false,
}))
}, [setState])
const onFocus = React.useCallback(() => {
setState(s => ({
...s,
focused: true,
}))
}, [setState])
const onBlur = React.useCallback(() => {
setState(s => ({
...s,
focused: false,
}))
}, [setState])
return (
<Pressable
{...rest}
style={state => [
atoms.flex_row,
atoms.align_center,
...baseStyles,
...(state.hovered ? hoverStyles : []),
typeof style === 'function' ? style(state) : style,
]}
onPressIn={onPressIn}
onPressOut={onPressOut}
onHoverIn={onHoverIn}
onHoverOut={onHoverOut}
onFocus={onFocus}
onBlur={onBlur}>
{typeof children === 'string' ? (
<ButtonText type={type} size={size}>
{children}
</ButtonText>
) : typeof children === 'function' ? (
children({state, type, size})
) : (
children
)}
</Pressable>
)
}
export function ButtonText({
children,
style,
type,
size,
...rest
}: ButtonTextProps) {
const textStyles = React.useMemo(() => {
const base = []
switch (type) {
case 'primary':
base.push({color: tokens.color.white})
break
case 'secondary':
base.push({
color: tokens.color.gray_700,
})
break
default:
}
switch (size) {
case 'small':
base.push(atoms.text_sm, {paddingBottom: 1})
break
case 'large':
base.push(atoms.text_md, {paddingBottom: 1})
break
default:
}
return base
}, [type, size])
return (
<Text
{...rest}
style={[
atoms.flex_1,
atoms.font_semibold,
atoms.text_center,
...textStyles,
style,
]}>
{children}
</Text>
)
}
+104
View File
@@ -0,0 +1,104 @@
import React from 'react'
import {Text as RNText, TextProps} from 'react-native'
import {useTheme, atoms, web} from '#/alf'
export function Text({style, ...rest}: TextProps) {
const t = useTheme()
return <RNText style={[atoms.text_sm, t.atoms.text, style]} {...rest} />
}
export function H1({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'heading',
'aria-level': 1,
}) || {}
return (
<RNText
{...attr}
{...rest}
style={[atoms.text_xl, atoms.font_bold, t.atoms.text, style]}
/>
)
}
export function H2({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'heading',
'aria-level': 2,
}) || {}
return (
<RNText
{...attr}
{...rest}
style={[atoms.text_lg, atoms.font_bold, t.atoms.text, style]}
/>
)
}
export function H3({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'heading',
'aria-level': 3,
}) || {}
return (
<RNText
{...attr}
{...rest}
style={[atoms.text_md, atoms.font_bold, t.atoms.text, style]}
/>
)
}
export function H4({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'heading',
'aria-level': 4,
}) || {}
return (
<RNText
{...attr}
{...rest}
style={[atoms.text_sm, atoms.font_bold, t.atoms.text, style]}
/>
)
}
export function H5({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'heading',
'aria-level': 5,
}) || {}
return (
<RNText
{...attr}
{...rest}
style={[atoms.text_xs, atoms.font_bold, t.atoms.text, style]}
/>
)
}
export function H6({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'heading',
'aria-level': 6,
}) || {}
return (
<RNText
{...attr}
{...rest}
style={[atoms.text_xxs, atoms.font_bold, t.atoms.text, style]}
/>
)
}
+2 -2
View File
@@ -2,7 +2,7 @@ import React from 'react'
import {View, Pressable} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {Trans, msg} from '@lingui/macro'
import {useNavigation} from '@react-navigation/native'
import {isIOS, isNative} from 'platform/detection'
@@ -119,7 +119,7 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
}}
onPress={onPressSearch}>
<Text type="lg-bold" style={[pal.text]}>
Search{' '}
<Trans>Search</Trans>{' '}
</Text>
<FontAwesomeIcon
icon="search"
+1 -1
View File
@@ -74,7 +74,7 @@ export const SplashScreen = ({
// TODO: web accessibility
accessibilityRole="button">
<Text style={[s.white, styles.btnLabel]}>
Create a new account
<Trans>Create a new account</Trans>
</Text>
</TouchableOpacity>
<TouchableOpacity
+54 -55
View File
@@ -1,7 +1,6 @@
import React from 'react'
import {
ActivityIndicator,
KeyboardAvoidingView,
ScrollView,
StyleSheet,
TouchableOpacity,
@@ -28,6 +27,7 @@ import {IS_PROD} from '#/lib/constants'
import {Step1} from './Step1'
import {Step2} from './Step2'
import {Step3} from './Step3'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
const {screen} = useAnalytics()
@@ -38,6 +38,7 @@ export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
const {createAccount} = useSessionApi()
const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
const {isTabletOrDesktop} = useWebMediaQueries()
React.useEffect(() => {
screen('CreateAccount')
@@ -120,64 +121,62 @@ export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
title={_(msg`Create Account`)}
description={_(msg`We're so excited to have you join us!`)}>
<ScrollView testID="createAccount" style={pal.view}>
<KeyboardAvoidingView behavior="padding">
<View style={styles.stepContainer}>
{uiState.step === 1 && (
<Step1 uiState={uiState} uiDispatch={uiDispatch} />
)}
{uiState.step === 2 && (
<Step2 uiState={uiState} uiDispatch={uiDispatch} />
)}
{uiState.step === 3 && (
<Step3 uiState={uiState} uiDispatch={uiDispatch} />
)}
</View>
<View style={[s.flexRow, s.pl20, s.pr20]}>
<View style={styles.stepContainer}>
{uiState.step === 1 && (
<Step1 uiState={uiState} uiDispatch={uiDispatch} />
)}
{uiState.step === 2 && (
<Step2 uiState={uiState} uiDispatch={uiDispatch} />
)}
{uiState.step === 3 && (
<Step3 uiState={uiState} uiDispatch={uiDispatch} />
)}
</View>
<View style={[s.flexRow, s.pl20, s.pr20]}>
<TouchableOpacity
onPress={onPressBackInner}
testID="backBtn"
accessibilityRole="button">
<Text type="xl" style={pal.link}>
<Trans>Back</Trans>
</Text>
</TouchableOpacity>
<View style={s.flex1} />
{uiState.canNext ? (
<TouchableOpacity
onPress={onPressBackInner}
testID="backBtn"
testID="nextBtn"
onPress={onPressNext}
accessibilityRole="button">
<Text type="xl" style={pal.link}>
<Trans>Back</Trans>
{uiState.isProcessing ? (
<ActivityIndicator />
) : (
<Text type="xl-bold" style={[pal.link, s.pr5]}>
<Trans>Next</Trans>
</Text>
)}
</TouchableOpacity>
) : serviceInfoError ? (
<TouchableOpacity
testID="retryConnectBtn"
onPress={() => refetchServiceInfo()}
accessibilityRole="button"
accessibilityLabel={_(msg`Retry`)}
accessibilityHint=""
accessibilityLiveRegion="polite">
<Text type="xl-bold" style={[pal.link, s.pr5]}>
<Trans>Retry</Trans>
</Text>
</TouchableOpacity>
<View style={s.flex1} />
{uiState.canNext ? (
<TouchableOpacity
testID="nextBtn"
onPress={onPressNext}
accessibilityRole="button">
{uiState.isProcessing ? (
<ActivityIndicator />
) : (
<Text type="xl-bold" style={[pal.link, s.pr5]}>
<Trans>Next</Trans>
</Text>
)}
</TouchableOpacity>
) : serviceInfoError ? (
<TouchableOpacity
testID="retryConnectBtn"
onPress={() => refetchServiceInfo()}
accessibilityRole="button"
accessibilityLabel={_(msg`Retry`)}
accessibilityHint=""
accessibilityLiveRegion="polite">
<Text type="xl-bold" style={[pal.link, s.pr5]}>
<Trans>Retry</Trans>
</Text>
</TouchableOpacity>
) : serviceInfoIsFetching ? (
<>
<ActivityIndicator color="#fff" />
<Text type="xl" style={[pal.text, s.pr5]}>
<Trans>Connecting...</Trans>
</Text>
</>
) : undefined}
</View>
<View style={s.footerSpacer} />
</KeyboardAvoidingView>
) : serviceInfoIsFetching ? (
<>
<ActivityIndicator color="#fff" />
<Text type="xl" style={[pal.text, s.pr5]}>
<Trans>Connecting...</Trans>
</Text>
</>
) : undefined}
</View>
<View style={{height: isTabletOrDesktop ? 50 : 400}} />
</ScrollView>
</LoggedOutLayout>
)
+3 -2
View File
@@ -77,7 +77,7 @@ export function Step1({
value={uiState.serviceUrl}
editable
onChange={onChangeServiceUrl}
accessibilityHint="Input hosting provider address"
accessibilityHint={_(msg`Input hosting provider address`)}
accessibilityLabel={_(msg`Hosting provider address`)}
accessibilityLabelledBy="addressProvider"
/>
@@ -125,6 +125,7 @@ function Option({
}>) {
const theme = useTheme()
const pal = usePalette('default')
const {_} = useLingui()
const circleFillStyle = React.useMemo(
() => ({
backgroundColor: theme.palette.primary.background,
@@ -139,7 +140,7 @@ function Option({
testID={testID}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint={`Sets hosting provider to ${label}`}>
accessibilityHint={_(msg`Sets hosting provider to ${label}`)}>
<View style={styles.optionHeading}>
<View style={[styles.circle, pal.border]}>
{isSelected ? (
+32 -7
View File
@@ -13,6 +13,17 @@ 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'
function sanitizeDate(date: Date): Date {
if (!date || date.toString() === 'Invalid Date') {
logger.error(`Create account: handled invalid date for birthDate`, {
hasDate: !!date,
})
return new Date()
}
return date
}
/** STEP 2: Your account
* @field Invite code or waitlist
@@ -38,6 +49,10 @@ export function Step2({
openModal({name: 'waitlist'})
}, [openModal])
const birthDate = React.useMemo(() => {
return sanitizeDate(uiState.birthDate)
}, [uiState.birthDate])
return (
<View>
<StepHeader step="2" title={_(msg`Your account`)} />
@@ -45,7 +60,7 @@ export function Step2({
{uiState.isInviteCodeRequired && (
<View style={s.pb20}>
<Text type="md-medium" style={[pal.text, s.mb2]}>
Invite code
<Trans>Invite code</Trans>
</Text>
<TextInput
testID="inviteCodeInput"
@@ -55,14 +70,17 @@ export function Step2({
editable
onChange={value => uiDispatch({type: 'set-invite-code', value})}
accessibilityLabel={_(msg`Invite code`)}
accessibilityHint="Input invite code to proceed"
accessibilityHint={_(msg`Input invite code to proceed`)}
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
/>
</View>
)}
{!uiState.inviteCode && uiState.isInviteCodeRequired ? (
<Text style={[s.alignBaseline, pal.text]}>
Don't have an invite code?{' '}
<Trans>Don't have an invite code?</Trans>{' '}
<TouchableWithoutFeedback
onPress={onPressWaitlist}
accessibilityLabel={_(msg`Join the waitlist.`)}
@@ -88,8 +106,11 @@ export function Step2({
editable
onChange={value => uiDispatch({type: 'set-email', value})}
accessibilityLabel={_(msg`Email`)}
accessibilityHint="Input email for Bluesky waitlist"
accessibilityHint={_(msg`Input email for Bluesky waitlist`)}
accessibilityLabelledBy="email"
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
/>
</View>
@@ -109,8 +130,11 @@ export function Step2({
secureTextEntry
onChange={value => uiDispatch({type: 'set-password', value})}
accessibilityLabel={_(msg`Password`)}
accessibilityHint="Set password"
accessibilityHint={_(msg`Set password`)}
accessibilityLabelledBy="password"
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
/>
</View>
@@ -122,14 +146,15 @@ export function Step2({
<Trans>Your birth date</Trans>
</Text>
<DateInput
handleAsUTC
testID="birthdayInput"
value={uiState.birthDate}
value={birthDate}
onChange={value => uiDispatch({type: 'set-birth-date', value})}
buttonType="default-light"
buttonStyle={[pal.border, styles.dateInputButton]}
buttonLabelType="lg"
accessibilityLabel={_(msg`Birthday`)}
accessibilityHint="Enter your birth date"
accessibilityHint={_(msg`Enter your birth date`)}
accessibilityLabelledBy="birthDate"
/>
</View>
+1 -1
View File
@@ -36,7 +36,7 @@ export function Step3({
onChange={value => uiDispatch({type: 'set-handle', value})}
// TODO: Add explicit text label
accessibilityLabel={_(msg`User handle`)}
accessibilityHint="Input your 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>{' '}
+6 -1
View File
@@ -2,13 +2,18 @@ import React from 'react'
import {StyleSheet, View} from 'react-native'
import {Text} from 'view/com/util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {Trans} from '@lingui/macro'
export function StepHeader({step, title}: {step: string; title: string}) {
const pal = usePalette('default')
return (
<View style={styles.container}>
<Text type="lg" style={[pal.textLight]}>
{step === '3' ? 'Last step!' : <>Step {step} of 3</>}
{step === '3' ? (
<Trans>Last step!</Trans>
) : (
<Trans>Step {step} of 3</Trans>
)}
</Text>
<Text style={[pal.text]} type="title-xl">
{title}
+7 -1
View File
@@ -136,7 +136,13 @@ export async function submit({
msg`Invite code not accepted. Check that you input it correctly and try again.`,
)
}
logger.error('Failed to create account', {error: e})
if ([400, 429].includes(e.status)) {
logger.warn('Failed to create account', {error: e})
} else {
logger.error(`Failed to create account (${e.status} status)`, {error: e})
}
uiDispatch({type: 'set-processing', value: false})
uiDispatch({type: 'set-error', value: cleanError(errMsg)})
throw e
@@ -42,7 +42,7 @@ function AccountItem({
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={_(msg`Sign in as ${account.handle}`)}
accessibilityHint="Double tap to sign in">
accessibilityHint={_(msg`Double tap to sign in`)}>
<View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}>
<View style={s.p10}>
<UserAvatar avatar={profile?.avatar} size={30} />
@@ -95,19 +95,19 @@ export const ChooseAccountForm = ({
if (account.accessJwt) {
if (account.did === currentAccount?.did) {
setShowLoggedOut(false)
Toast.show(`Already signed in as @${account.handle}`)
Toast.show(_(msg`Already signed in as @${account.handle}`))
} else {
await initSession(account)
track('Sign In', {resumedSession: true})
setTimeout(() => {
Toast.show(`Signed in as @${account.handle}`)
Toast.show(_(msg`Signed in as @${account.handle}`))
}, 100)
}
} else {
onSelectAccount(account)
}
},
[currentAccount, track, initSession, onSelectAccount, setShowLoggedOut],
[currentAccount, track, initSession, onSelectAccount, setShowLoggedOut, _],
)
return (
+10 -6
View File
@@ -67,7 +67,7 @@ export const ForgotPasswordForm = ({
const onPressNext = async () => {
if (!EmailValidator.validate(email)) {
return setError('Your email appears to be invalid.')
return setError(_(msg`Your email appears to be invalid.`))
}
setError('')
@@ -83,7 +83,9 @@ export const ForgotPasswordForm = ({
setIsProcessing(false)
if (isNetworkError(e)) {
setError(
'Unable to contact your service. Please check your Internet connection.',
_(
msg`Unable to contact your service. Please check your Internet connection.`,
),
)
} else {
setError(cleanError(errMsg))
@@ -112,7 +114,9 @@ export const ForgotPasswordForm = ({
onPress={onPressSelectService}
accessibilityRole="button"
accessibilityLabel={_(msg`Hosting provider`)}
accessibilityHint="Sets hosting provider for password reset">
accessibilityHint={_(
msg`Sets hosting provider for password reset`,
)}>
<FontAwesomeIcon
icon="globe"
style={[pal.textLight, styles.groupContentIcon]}
@@ -136,7 +140,7 @@ export const ForgotPasswordForm = ({
<TextInput
testID="forgotPasswordEmail"
style={[pal.text, styles.textInput]}
placeholder="Email address"
placeholder={_(msg`Email address`)}
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
autoFocus
@@ -146,7 +150,7 @@ export const ForgotPasswordForm = ({
onChangeText={setEmail}
editable={!isProcessing}
accessibilityLabel={_(msg`Email`)}
accessibilityHint="Sets email for password reset"
accessibilityHint={_(msg`Sets email for password reset`)}
/>
</View>
</View>
@@ -179,7 +183,7 @@ export const ForgotPasswordForm = ({
onPress={onPressNext}
accessibilityRole="button"
accessibilityLabel={_(msg`Go to next`)}
accessibilityHint="Navigates to the next screen">
accessibilityHint={_(msg`Navigates to the next screen`)}>
<Text type="xl-bold" style={[pal.link, s.pr5]}>
<Trans>Next</Trans>
</Text>
+15 -8
View File
@@ -107,17 +107,21 @@ export const LoginForm = ({
})
} catch (e: any) {
const errMsg = e.toString()
logger.warn('Failed to login', {error: e})
setIsProcessing(false)
if (errMsg.includes('Authentication Required')) {
logger.info('Failed to login due to invalid credentials', {
error: errMsg,
})
setError(_(msg`Invalid username or password`))
} else if (isNetworkError(e)) {
logger.warn('Failed to login due to network error', {error: errMsg})
setError(
_(
msg`Unable to contact your service. Please check your Internet connection.`,
),
)
} else {
logger.warn('Failed to login', {error: errMsg})
setError(cleanError(errMsg))
}
}
@@ -141,7 +145,7 @@ export const LoginForm = ({
onPress={onPressSelectService}
accessibilityRole="button"
accessibilityLabel={_(msg`Select service`)}
accessibilityHint="Sets server for the Bluesky client">
accessibilityHint={_(msg`Sets server for the Bluesky client`)}>
<Text type="xl" style={[pal.text, styles.textBtnLabel]}>
{toNiceDomain(serviceUrl)}
</Text>
@@ -174,6 +178,7 @@ export const LoginForm = ({
autoCorrect={false}
autoComplete="username"
returnKeyType="next"
textContentType="username"
onSubmitEditing={() => {
passwordInputRef.current?.focus()
}}
@@ -185,7 +190,9 @@ export const LoginForm = ({
}
editable={!isProcessing}
accessibilityLabel={_(msg`Username or email address`)}
accessibilityHint="Input the username or email address you used at signup"
accessibilityHint={_(
msg`Input the username or email address you used at signup`,
)}
/>
</View>
<View style={[pal.borderDark, styles.groupContent]}>
@@ -216,8 +223,8 @@ export const LoginForm = ({
accessibilityLabel={_(msg`Password`)}
accessibilityHint={
identifier === ''
? 'Input your password'
: `Input the password tied to ${identifier}`
? _(msg`Input your password`)
: _(msg`Input the password tied to ${identifier}`)
}
/>
<TouchableOpacity
@@ -226,7 +233,7 @@ export const LoginForm = ({
onPress={onPressForgotPassword}
accessibilityRole="button"
accessibilityLabel={_(msg`Forgot password`)}
accessibilityHint="Opens password reset form">
accessibilityHint={_(msg`Opens password reset form`)}>
<Text style={pal.link}>
<Trans>Forgot</Trans>
</Text>
@@ -256,7 +263,7 @@ export const LoginForm = ({
onPress={onPressRetryConnect}
accessibilityRole="button"
accessibilityLabel={_(msg`Retry`)}
accessibilityHint="Retries login">
accessibilityHint={_(msg`Retries login`)}>
<Text type="xl-bold" style={[pal.link, s.pr5]}>
<Trans>Retry</Trans>
</Text>
@@ -276,7 +283,7 @@ export const LoginForm = ({
onPress={onPressNext}
accessibilityRole="button"
accessibilityLabel={_(msg`Go to next`)}
accessibilityHint="Navigates to the next screen">
accessibilityHint={_(msg`Navigates to the next screen`)}>
<Text type="xl-bold" style={[pal.link, s.pr5]}>
<Trans>Next</Trans>
</Text>
@@ -36,7 +36,7 @@ export const PasswordUpdatedForm = ({
onPress={onPressNext}
accessibilityRole="button"
accessibilityLabel={_(msg`Close alert`)}
accessibilityHint="Closes password update alert">
accessibilityHint={_(msg`Closes password update alert`)}>
<Text type="xl-bold" style={[pal.link, s.pr5]}>
<Trans>Okay</Trans>
</Text>
@@ -95,7 +95,7 @@ export const SetNewPasswordForm = ({
<TextInput
testID="resetCodeInput"
style={[pal.text, styles.textInput]}
placeholder="Reset code"
placeholder={_(msg`Reset code`)}
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
autoCorrect={false}
@@ -106,7 +106,9 @@ export const SetNewPasswordForm = ({
editable={!isProcessing}
accessible={true}
accessibilityLabel={_(msg`Reset code`)}
accessibilityHint="Input code sent to your email for password reset"
accessibilityHint={_(
msg`Input code sent to your email for password reset`,
)}
/>
</View>
<View style={[pal.borderDark, styles.groupContent]}>
@@ -117,7 +119,7 @@ export const SetNewPasswordForm = ({
<TextInput
testID="newPasswordInput"
style={[pal.text, styles.textInput]}
placeholder="New password"
placeholder={_(msg`New password`)}
placeholderTextColor={pal.colors.textLight}
autoCapitalize="none"
autoCorrect={false}
@@ -128,7 +130,7 @@ export const SetNewPasswordForm = ({
editable={!isProcessing}
accessible={true}
accessibilityLabel={_(msg`Password`)}
accessibilityHint="Input new password"
accessibilityHint={_(msg`Input new password`)}
/>
</View>
</View>
@@ -161,7 +163,7 @@ export const SetNewPasswordForm = ({
onPress={onPressNext}
accessibilityRole="button"
accessibilityLabel={_(msg`Go to next`)}
accessibilityHint="Navigates to the next screen">
accessibilityHint={_(msg`Navigates to the next screen`)}>
<Text type="xl-bold" style={[pal.link, s.pr5]}>
<Trans>Next</Trans>
</Text>
@@ -18,6 +18,8 @@ import {
} from '#/state/queries/preferences'
import {logger} from '#/logger'
import {useAnalytics} from '#/lib/analytics/analytics'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
export function RecommendedFeedsItem({
item,
@@ -26,6 +28,7 @@ export function RecommendedFeedsItem({
}) {
const {isMobile} = useWebMediaQueries()
const pal = usePalette('default')
const {_} = useLingui()
const {data: preferences} = usePreferencesQuery()
const {
mutateAsync: pinFeed,
@@ -51,7 +54,7 @@ export function RecommendedFeedsItem({
await removeFeed({uri: item.uri})
resetRemoveFeed()
} catch (e) {
Toast.show('There was an issue contacting your server')
Toast.show(_(msg`There was an issue contacting your server`))
logger.error('Failed to unsave feed', {error: e})
}
} else {
@@ -60,7 +63,7 @@ export function RecommendedFeedsItem({
resetPinFeed()
track('Onboarding:CustomFeedAdded')
} catch (e) {
Toast.show('There was an issue contacting your server')
Toast.show(_(msg`There was an issue contacting your server`))
logger.error('Failed to pin feed', {error: e})
}
}
@@ -94,7 +97,7 @@ export function RecommendedFeedsItem({
</Text>
<Text style={[pal.textLight, {marginBottom: 8}]} numberOfLines={1}>
by {sanitizeHandle(item.creator.handle, '@')}
<Trans>by {sanitizeHandle(item.creator.handle, '@')}</Trans>
</Text>
{item.description ? (
@@ -133,7 +136,7 @@ export function RecommendedFeedsItem({
color={pal.colors.textInverted}
/>
<Text type="lg-medium" style={pal.textInverted}>
Added
<Trans>Added</Trans>
</Text>
</>
) : (
@@ -144,7 +147,7 @@ export function RecommendedFeedsItem({
color={pal.colors.textInverted}
/>
<Text type="lg-medium" style={pal.textInverted}>
Add
<Trans>Add</Trans>
</Text>
</>
)}
@@ -83,7 +83,7 @@ export function RecommendedFollows({next}: Props) {
<Text
type="2xl-medium"
style={{color: '#fff', position: 'relative', top: -1}}>
<Trans>Done</Trans>
<Trans context="action">Done</Trans>
</Text>
<FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
</View>
@@ -7,6 +7,7 @@ import {usePalette} from 'lib/hooks/usePalette'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {TitleColumnLayout} from 'view/com/util/layouts/TitleColumnLayout'
import {Button} from 'view/com/util/forms/Button'
import {Trans} from '@lingui/macro'
type Props = {
next: () => void
@@ -17,7 +18,7 @@ export function WelcomeDesktop({next}: Props) {
const pal = usePalette('default')
const horizontal = useMediaQuery({minWidth: 1300})
const title = (
<>
<Trans>
<Text
style={[
pal.textLight,
@@ -40,7 +41,7 @@ export function WelcomeDesktop({next}: Props) {
]}>
Bluesky
</Text>
</>
</Trans>
)
return (
<TitleColumnLayout
@@ -52,10 +53,12 @@ export function WelcomeDesktop({next}: Props) {
<FontAwesomeIcon icon={'globe'} size={36} color={pal.colors.link} />
<View style={[styles.rowText]}>
<Text type="xl-bold" style={[pal.text]}>
Bluesky is public.
<Trans>Bluesky is public.</Trans>
</Text>
<Text type="xl" style={[pal.text, s.pt2]}>
Your posts, likes, and blocks are public. Mutes are private.
<Trans>
Your posts, likes, and blocks are public. Mutes are private.
</Trans>
</Text>
</View>
</View>
@@ -63,10 +66,10 @@ export function WelcomeDesktop({next}: Props) {
<FontAwesomeIcon icon={'at'} size={36} color={pal.colors.link} />
<View style={[styles.rowText]}>
<Text type="xl-bold" style={[pal.text]}>
Bluesky is open.
<Trans>Bluesky is open.</Trans>
</Text>
<Text type="xl" style={[pal.text, s.pt2]}>
Never lose access to your followers and data.
<Trans>Never lose access to your followers and data.</Trans>
</Text>
</View>
</View>
@@ -74,10 +77,13 @@ export function WelcomeDesktop({next}: Props) {
<FontAwesomeIcon icon={'gear'} size={36} color={pal.colors.link} />
<View style={[styles.rowText]}>
<Text type="xl-bold" style={[pal.text]}>
Bluesky is flexible.
<Trans>Bluesky is flexible.</Trans>
</Text>
<Text type="xl" style={[pal.text, s.pt2]}>
Choose the algorithms that power your experience with custom feeds.
<Trans>
Choose the algorithms that power your experience with custom
feeds.
</Trans>
</Text>
</View>
</View>
@@ -94,7 +100,7 @@ export function WelcomeDesktop({next}: Props) {
<Text
type="2xl-medium"
style={{color: '#fff', position: 'relative', top: -1}}>
Next
<Trans context="action">Next</Trans>
</Text>
<FontAwesomeIcon icon="angle-right" color="#fff" size={14} />
</View>
+51 -41
View File
@@ -6,6 +6,7 @@ import {
Keyboard,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
TouchableOpacity,
@@ -28,8 +29,6 @@ import {UserAvatar} from '../util/UserAvatar'
import * as apilib from 'lib/api/index'
import {ComposerOpts} from 'state/shell/composer'
import {s, colors, gradients} from 'lib/styles'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {cleanError} from 'lib/strings/errors'
import {shortenLinks} from 'lib/strings/rich-text-manip'
import {toShortUrl} from 'lib/strings/url-helpers'
@@ -46,7 +45,6 @@ import {Gallery} from './photos/Gallery'
import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
import {LabelsBtn} from './labels/LabelsBtn'
import {SelectLangBtn} from './select-language/SelectLangBtn'
import {EmojiPickerButton} from './text-input/web/EmojiPicker.web'
import {insertMentionAt} from 'lib/strings/mention-manip'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -63,6 +61,7 @@ import {useComposerControls} from '#/state/shell/composer'
import {emitPostCreated} from '#/state/events'
import {ThreadgateSetting} from '#/state/queries/threadgate'
import {logger} from '#/logger'
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
type Props = ComposerOpts
export const ComposePost = observer(function ComposePost({
@@ -70,6 +69,7 @@ export const ComposePost = observer(function ComposePost({
onPost,
quote: initQuote,
mention: initMention,
openPicker,
}: Props) {
const {currentAccount} = useSession()
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
@@ -207,7 +207,11 @@ export const ComposePost = observer(function ComposePost({
setError('')
if (richtext.text.trim().length === 0 && gallery.isEmpty && !extLink) {
setError('Did you want to say anything?')
setError(_(msg`Did you want to say anything?`))
return
}
if (extLink?.isLoading) {
setError(_(msg`Please wait for your link card to finish loading`))
return
}
@@ -256,7 +260,11 @@ export const ComposePost = observer(function ComposePost({
setLangPrefs.savePostLanguageToHistory()
onPost?.()
onClose()
Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`)
Toast.show(
replyTo
? _(msg`Your reply has been published`)
: _(msg`Your post has been published`),
)
}
const canPost = useMemo(
@@ -265,11 +273,17 @@ export const ComposePost = observer(function ComposePost({
(!requireAltTextEnabled || !gallery.needsAltText),
[graphemeLength, requireAltTextEnabled, gallery.needsAltText],
)
const selectTextInputPlaceholder = replyTo ? 'Write your reply' : `What's up?`
const selectTextInputPlaceholder = replyTo
? _(msg`Write your reply`)
: _(msg`What's up?`)
const canSelectImages = useMemo(() => gallery.size < 4, [gallery.size])
const hasMedia = gallery.size > 0 || Boolean(extLink)
const onEmojiButtonPress = useCallback(() => {
openPicker?.(textInput.current?.getCursorPosition())
}, [openPicker])
return (
<KeyboardAvoidingView
testID="composePostView"
@@ -283,7 +297,9 @@ export const ComposePost = observer(function ComposePost({
onAccessibilityEscape={onPressCancel}
accessibilityRole="button"
accessibilityLabel={_(msg`Cancel`)}
accessibilityHint="Closes post composer and discards post draft">
accessibilityHint={_(
msg`Closes post composer and discards post draft`,
)}>
<Text style={[pal.link, s.f18]}>
<Trans>Cancel</Trans>
</Text>
@@ -315,7 +331,7 @@ export const ComposePost = observer(function ComposePost({
onPress={onPressPublish}
accessibilityRole="button"
accessibilityLabel={
replyTo ? 'Publish reply' : 'Publish post'
replyTo ? _(msg`Publish reply`) : _(msg`Publish post`)
}
accessibilityHint="">
<LinearGradient
@@ -327,14 +343,18 @@ export const ComposePost = observer(function ComposePost({
end={{x: 1, y: 1}}
style={styles.postBtn}>
<Text style={[s.white, s.f16, s.bold]}>
{replyTo ? 'Reply' : 'Post'}
{replyTo ? (
<Trans context="action">Reply</Trans>
) : (
<Trans context="action">Post</Trans>
)}
</Text>
</LinearGradient>
</TouchableOpacity>
) : (
<View style={[styles.postBtn, pal.btn]}>
<Text style={[pal.textLight, s.f16, s.bold]}>
<Trans>Post</Trans>
<Trans context="action">Post</Trans>
</Text>
</View>
)}
@@ -370,22 +390,7 @@ export const ComposePost = observer(function ComposePost({
<ScrollView
style={styles.scrollView}
keyboardShouldPersistTaps="always">
{replyTo ? (
<View style={[pal.border, styles.replyToLayout]}>
<UserAvatar avatar={replyTo.author.avatar} size={50} />
<View style={styles.replyToPost}>
<Text type="xl-medium" style={[pal.text]}>
{sanitizeDisplayName(
replyTo.author.displayName ||
sanitizeHandle(replyTo.author.handle),
)}
</Text>
<Text type="post-text" style={pal.text} numberOfLines={6}>
{replyTo.text}
</Text>
</View>
</View>
) : undefined}
{replyTo ? <ComposerReplyTo replyTo={replyTo} /> : undefined}
<View
style={[
@@ -407,7 +412,9 @@ export const ComposePost = observer(function ComposePost({
onError={setError}
accessible={true}
accessibilityLabel={_(msg`Write post`)}
accessibilityHint={`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`}
accessibilityHint={_(
msg`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`,
)}
/>
</View>
@@ -436,9 +443,11 @@ export const ComposePost = observer(function ComposePost({
onPress={() => onPressAddLinkCard(url)}
accessibilityRole="button"
accessibilityLabel={_(msg`Add link card`)}
accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}>
accessibilityHint={_(
msg`Creates a card with a thumbnail. The card links to ${url}`,
)}>
<Text style={pal.text}>
<Trans>Add link card:</Trans>
<Trans>Add link card:</Trans>{' '}
<Text style={[pal.link, s.ml5]}>{toShortUrl(url)}</Text>
</Text>
</TouchableOpacity>
@@ -452,7 +461,19 @@ export const ComposePost = observer(function ComposePost({
<OpenCameraBtn gallery={gallery} />
</>
) : null}
{!isMobile ? <EmojiPickerButton /> : null}
{!isMobile ? (
<Pressable
onPress={onEmojiButtonPress}
accessibilityRole="button"
accessibilityLabel={_(msg`Open emoji picker`)}
accessibilityHint={_(msg`Open emoji picker`)}>
<FontAwesomeIcon
icon={['far', 'face-smile']}
color={pal.colors.link}
size={22}
/>
</Pressable>
) : null}
<View style={s.flex1} />
<SelectLangBtn />
<CharProgress count={graphemeLength} />
@@ -528,17 +549,6 @@ const styles = StyleSheet.create({
textInputLayoutMobile: {
flex: 1,
},
replyToLayout: {
flexDirection: 'row',
borderTopWidth: 1,
paddingTop: 16,
paddingBottom: 16,
},
replyToPost: {
flex: 1,
paddingLeft: 13,
paddingRight: 8,
},
addExtLinkBtn: {
borderWidth: 1,
borderRadius: 24,
+254
View File
@@ -0,0 +1,254 @@
import React from 'react'
import {LayoutAnimation, Pressable, StyleSheet, View} from 'react-native'
import {Image} from 'expo-image'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
import {
AppBskyEmbedImages,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyFeedPost,
} from '@atproto/api'
import {ComposerOptsPostRef} from 'state/shell/composer'
import {usePalette} from 'lib/hooks/usePalette'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {Text} from 'view/com/util/text/Text'
import QuoteEmbed from 'view/com/util/post-embeds/QuoteEmbed'
export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
const pal = usePalette('default')
const {_} = useLingui()
const {embed} = replyTo
const [showFull, setShowFull] = React.useState(false)
const onPress = React.useCallback(() => {
setShowFull(prev => !prev)
LayoutAnimation.configureNext({
duration: 350,
update: {type: 'spring', springDamping: 0.7},
})
}, [])
const quote = React.useMemo(() => {
if (
AppBskyEmbedRecord.isView(embed) &&
AppBskyEmbedRecord.isViewRecord(embed.record) &&
AppBskyFeedPost.isRecord(embed.record.value)
) {
// Not going to include the images right now
return {
author: embed.record.author,
cid: embed.record.cid,
uri: embed.record.uri,
indexedAt: embed.record.indexedAt,
text: embed.record.value.text,
}
} else if (
AppBskyEmbedRecordWithMedia.isView(embed) &&
AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
AppBskyFeedPost.isRecord(embed.record.record.value)
) {
return {
author: embed.record.record.author,
cid: embed.record.record.cid,
uri: embed.record.record.uri,
indexedAt: embed.record.record.indexedAt,
text: embed.record.record.value.text,
}
}
}, [embed])
const images = React.useMemo(() => {
if (AppBskyEmbedImages.isView(embed)) {
return embed.images
} else if (
AppBskyEmbedRecordWithMedia.isView(embed) &&
AppBskyEmbedImages.isView(embed.media)
) {
return embed.media.images
}
}, [embed])
return (
<Pressable
style={[pal.border, styles.replyToLayout]}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={_(
msg`Expand or collapse the full post you are replying to`,
)}
accessibilityHint={_(
msg`Expand or collapse the full post you are replying to`,
)}>
<UserAvatar avatar={replyTo.author.avatar} size={50} />
<View style={styles.replyToPost}>
<Text type="xl-medium" style={[pal.text]}>
{sanitizeDisplayName(
replyTo.author.displayName || sanitizeHandle(replyTo.author.handle),
)}
</Text>
<View style={styles.replyToBody}>
<View style={styles.replyToText}>
<Text
type="post-text"
style={pal.text}
numberOfLines={!showFull ? 6 : undefined}>
{replyTo.text}
</Text>
</View>
{images && (
<ComposerReplyToImages images={images} showFull={showFull} />
)}
</View>
{showFull && quote && <QuoteEmbed quote={quote} />}
</View>
</Pressable>
)
}
function ComposerReplyToImages({
images,
}: {
images: AppBskyEmbedImages.ViewImage[]
showFull: boolean
}) {
return (
<View
style={{
width: 65,
flexDirection: 'column',
alignItems: 'center',
}}>
<View style={styles.imagesContainer}>
{(images.length === 1 && (
<Image
source={{uri: images[0].thumb}}
style={styles.singleImage}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
/>
)) ||
(images.length === 2 && (
<View style={[styles.imagesInner, styles.imagesRow]}>
<Image
source={{uri: images[0].thumb}}
style={styles.doubleImageTall}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
/>
<Image
source={{uri: images[1].thumb}}
style={styles.doubleImageTall}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
/>
</View>
)) ||
(images.length === 3 && (
<View style={[styles.imagesInner, styles.imagesRow]}>
<Image
source={{uri: images[0].thumb}}
style={styles.doubleImageTall}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
/>
<View style={styles.imagesInner}>
<Image
source={{uri: images[1].thumb}}
style={styles.doubleImage}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
/>
<Image
source={{uri: images[2].thumb}}
style={styles.doubleImage}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
/>
</View>
</View>
)) ||
(images.length === 4 && (
<View style={styles.imagesInner}>
<View style={[styles.imagesInner, styles.imagesRow]}>
<Image
source={{uri: images[0].thumb}}
style={styles.doubleImage}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
/>
<Image
source={{uri: images[1].thumb}}
style={styles.doubleImage}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
/>
</View>
<View style={[styles.imagesInner, styles.imagesRow]}>
<Image
source={{uri: images[2].thumb}}
style={styles.doubleImage}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
/>
<Image
source={{uri: images[3].thumb}}
style={styles.doubleImage}
cachePolicy="memory-disk"
accessibilityIgnoresInvertColors
/>
</View>
</View>
))}
</View>
</View>
)
}
const styles = StyleSheet.create({
replyToLayout: {
flexDirection: 'row',
borderTopWidth: 1,
paddingTop: 16,
paddingBottom: 16,
},
replyToPost: {
flex: 1,
paddingLeft: 13,
paddingRight: 8,
},
replyToBody: {
flexDirection: 'row',
gap: 10,
},
replyToText: {
flex: 1,
flexGrow: 1,
},
imagesContainer: {
borderRadius: 6,
overflow: 'hidden',
marginTop: 2,
},
imagesInner: {
gap: 2,
},
imagesRow: {
flexDirection: 'row',
},
singleImage: {
width: 65,
height: 65,
},
doubleImageTall: {
width: 32.5,
height: 65,
},
doubleImage: {
width: 32.5,
height: 32.5,
},
})
+1 -1
View File
@@ -68,7 +68,7 @@ export const ExternalEmbed = ({
onPress={onRemove}
accessibilityRole="button"
accessibilityLabel={_(msg`Remove image preview`)}
accessibilityHint={`Removes default thumbnail from ${link.uri}`}
accessibilityHint={_(msg`Removes default thumbnail from ${link.uri}`)}
onAccessibilityEscape={onRemove}>
<FontAwesomeIcon size={18} icon="xmark" style={s.white} />
</TouchableOpacity>
+1 -1
View File
@@ -22,7 +22,7 @@ export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) {
onPress={() => onPressCompose()}
accessibilityRole="button"
accessibilityLabel={_(msg`Compose reply`)}
accessibilityHint="Opens composer">
accessibilityHint={_(msg`Opens composer`)}>
<UserAvatar avatar={profile?.avatar} size={38} />
<Text
type="xl"
@@ -58,7 +58,7 @@ export function OpenCameraBtn({gallery}: Props) {
hitSlop={HITSLOP_10}
accessibilityRole="button"
accessibilityLabel={_(msg`Camera`)}
accessibilityHint="Opens camera on device">
accessibilityHint={_(msg`Opens camera on device`)}>
<FontAwesomeIcon
icon="camera"
style={pal.link as FontAwesomeIconStyle}
@@ -41,7 +41,7 @@ export function SelectPhotoBtn({gallery}: Props) {
hitSlop={HITSLOP_10}
accessibilityRole="button"
accessibilityLabel={_(msg`Gallery`)}
accessibilityHint="Opens device photo gallery">
accessibilityHint={_(msg`Opens device photo gallery`)}>
<FontAwesomeIcon
icon={['far', 'image']}
style={pal.link as FontAwesomeIconStyle}
@@ -32,6 +32,7 @@ import {POST_IMG_MAX} from 'lib/constants'
export interface TextInputRef {
focus: () => void
blur: () => void
getCursorPosition: () => DOMRect | undefined
}
interface TextInputProps extends ComponentProps<typeof RNTextInput> {
@@ -74,6 +75,7 @@ export const TextInput = forwardRef(function TextInputImpl(
blur: () => {
textInput.current?.blur()
},
getCursorPosition: () => undefined, // Not implemented on native
}))
const onChangeText = useCallback(
@@ -22,6 +22,7 @@ import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
export interface TextInputRef {
focus: () => void
blur: () => void
getCursorPosition: () => DOMRect | undefined
}
interface TextInputProps {
@@ -169,6 +170,10 @@ export const TextInput = React.forwardRef(function TextInputImpl(
React.useImperativeHandle(ref, () => ({
focus: () => {}, // TODO
blur: () => {}, // TODO
getCursorPosition: () => {
const pos = editor?.state.selection.$anchor.pos
return pos ? editor?.view.coordsAtPos(pos) : undefined
},
}))
return (
@@ -17,6 +17,7 @@ import {usePalette} from 'lib/hooks/usePalette'
import {Text} from 'view/com/util/text/Text'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {useGrapheme} from '../hooks/useGrapheme'
import {Trans} from '@lingui/macro'
interface MentionListRef {
onKeyDown: (props: SuggestionKeyDownProps) => boolean
@@ -187,7 +188,7 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
})
) : (
<Text type="sm" style={[pal.text, styles.noResult]}>
No result
<Trans>No result</Trans>
</Text>
)}
</View>
@@ -1,11 +1,17 @@
import React from 'react'
import Picker from '@emoji-mart/react'
import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import {
StyleSheet,
TouchableWithoutFeedback,
useWindowDimensions,
View,
} from 'react-native'
import {textInputWebEmitter} from '../TextInput.web'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {usePalette} from 'lib/hooks/usePalette'
import {useMediaQuery} from 'react-responsive'
const HEIGHT_OFFSET = 40
const WIDTH_OFFSET = 100
const PICKER_HEIGHT = 435 + HEIGHT_OFFSET
const PICKER_WIDTH = 350 + WIDTH_OFFSET
export type Emoji = {
aliases?: string[]
@@ -18,59 +24,87 @@ export type Emoji = {
unified: string
}
export function EmojiPickerButton() {
const pal = usePalette('default')
const [open, setOpen] = React.useState(false)
const onOpenChange = (o: boolean) => {
setOpen(o)
}
const close = () => {
setOpen(false)
}
return (
<DropdownMenu.Root open={open} onOpenChange={onOpenChange}>
<DropdownMenu.Trigger style={styles.trigger as React.CSSProperties}>
<FontAwesomeIcon
icon={['far', 'face-smile']}
color={pal.colors.link}
size={22}
/>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<EmojiPicker close={close} />
</DropdownMenu.Portal>
</DropdownMenu.Root>
)
export interface EmojiPickerState {
isOpen: boolean
pos: {top: number; left: number; right: number; bottom: number}
}
export function EmojiPicker({close}: {close: () => void}) {
interface IProps {
state: EmojiPickerState
close: () => void
}
export function EmojiPicker({state, close}: IProps) {
const {height, width} = useWindowDimensions()
const isShiftDown = React.useRef(false)
const position = React.useMemo(() => {
const fitsBelow = state.pos.top + PICKER_HEIGHT < height
const fitsAbove = PICKER_HEIGHT < state.pos.top
const placeOnLeft = PICKER_WIDTH < state.pos.left
const screenYMiddle = height / 2 - PICKER_HEIGHT / 2
if (fitsBelow) {
return {
top: state.pos.top + HEIGHT_OFFSET,
}
} else if (fitsAbove) {
return {
bottom: height - state.pos.bottom + HEIGHT_OFFSET,
}
} else {
return {
top: screenYMiddle,
left: placeOnLeft ? state.pos.left - PICKER_WIDTH : undefined,
right: !placeOnLeft
? width - state.pos.right - PICKER_WIDTH
: undefined,
}
}
}, [state.pos, height, width])
React.useEffect(() => {
if (!state.isOpen) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
isShiftDown.current = true
}
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
isShiftDown.current = false
}
}
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('keyup', onKeyUp, true)
return () => {
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('keyup', onKeyUp, true)
}
}, [state.isOpen])
const onInsert = (emoji: Emoji) => {
textInputWebEmitter.emit('emoji-inserted', emoji)
close()
if (!isShiftDown.current) {
close()
}
}
const reducedPadding = useMediaQuery({query: '(max-height: 750px)'})
const noPadding = useMediaQuery({query: '(max-height: 550px)'})
const noPicker = useMediaQuery({query: '(max-height: 350px)'})
if (!state.isOpen) return null
return (
// eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors
<TouchableWithoutFeedback onPress={close} accessibilityViewIsModal>
<TouchableWithoutFeedback
accessibilityRole="button"
onPress={close}
accessibilityViewIsModal>
<View style={styles.mask}>
{/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors */}
<TouchableWithoutFeedback
onPress={e => {
e.stopPropagation() // prevent event from bubbling up to the mask
}}>
<View
style={[
styles.picker,
{
paddingTop: noPadding ? 0 : reducedPadding ? 150 : 325,
display: noPicker ? 'none' : 'flex',
},
]}>
<TouchableWithoutFeedback onPress={e => e.stopPropagation()}>
<View style={[{position: 'absolute'}, position]}>
<Picker
data={async () => {
return (await import('./EmojiPickerData.json')).default
@@ -94,15 +128,7 @@ const styles = StyleSheet.create({
right: 0,
width: '100%',
height: '100%',
},
trigger: {
backgroundColor: 'transparent',
// @ts-ignore web only
border: 'none',
paddingTop: 4,
paddingLeft: 12,
paddingRight: 12,
cursor: 'pointer',
alignItems: 'center',
},
picker: {
marginHorizontal: 'auto',
+2 -1
View File
@@ -174,6 +174,7 @@ export function FeedPage({
feed={feed}
feedParams={feedParams}
pollInterval={POLL_FREQ}
disablePoll={hasNew}
scrollElRef={scrollElRef}
onScrolledDownChange={setIsScrolledDown}
onHasNew={setHasNew}
@@ -197,7 +198,7 @@ export function FeedPage({
onPress={onPressCompose}
icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />}
accessibilityRole="button"
accessibilityLabel={_(msg`New post`)}
accessibilityLabel={_(msg({message: `New post`, context: 'action'}))}
accessibilityHint=""
/>
)}
+21 -14
View File
@@ -14,7 +14,7 @@ import * as Toast from 'view/com/util/Toast'
import {sanitizeHandle} from 'lib/strings/handles'
import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
import {msg} from '@lingui/macro'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
usePinFeedMutation,
@@ -108,9 +108,9 @@ export function FeedSourceCardLoaded({
try {
await removeFeed({uri: feed.uri})
// await item.unsave()
Toast.show('Removed from my feeds')
Toast.show(_(msg`Removed from my feeds`))
} catch (e) {
Toast.show('There was an issue contacting your server')
Toast.show(_(msg`There was an issue contacting your server`))
logger.error('Failed to unsave feed', {error: e})
}
},
@@ -122,9 +122,9 @@ export function FeedSourceCardLoaded({
} else {
await saveFeed({uri: feed.uri})
}
Toast.show('Added to my feeds')
Toast.show(_(msg`Added to my feeds`))
} catch (e) {
Toast.show('There was an issue contacting your server')
Toast.show(_(msg`There was an issue contacting your server`))
logger.error('Failed to save feed', {error: e})
}
}
@@ -164,7 +164,7 @@ export function FeedSourceCardLoaded({
testID={`feed-${feedUri}-toggleSave`}
disabled={isRemovePending}
accessibilityRole="button"
accessibilityLabel={'Remove from my feeds'}
accessibilityLabel={_(msg`Remove from my feeds`)}
accessibilityHint=""
onPress={() => {
openModal({
@@ -175,9 +175,11 @@ export function FeedSourceCardLoaded({
try {
await removeFeed({uri: feedUri})
// await item.unsave()
Toast.show('Removed from my feeds')
Toast.show(_(msg`Removed from my feeds`))
} catch (e) {
Toast.show('There was an issue contacting your server')
Toast.show(
_(msg`There was an issue contacting your server`),
)
logger.error('Failed to unsave feed', {error: e})
}
},
@@ -223,19 +225,22 @@ export function FeedSourceCardLoaded({
{feed.displayName}
</Text>
<Text style={[pal.textLight]} numberOfLines={3}>
{feed.type === 'feed' ? 'Feed' : 'List'} by{' '}
{sanitizeHandle(feed.creatorHandle, '@')}
{feed.type === 'feed' ? (
<Trans>Feed by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
) : (
<Trans>List by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
)}
</Text>
</View>
{showSaveBtn && feed.type === 'feed' && (
<View>
<View style={[s.justifyCenter]}>
<Pressable
testID={`feed-${feed.displayName}-toggleSave`}
disabled={isSavePending || isPinPending || isRemovePending}
accessibilityRole="button"
accessibilityLabel={
isSaved ? 'Remove from my feeds' : 'Add to my feeds'
isSaved ? _(msg`Remove from my feeds`) : _(msg`Add to my feeds`)
}
accessibilityHint=""
onPress={onToggleSaved}
@@ -269,8 +274,10 @@ export function FeedSourceCardLoaded({
{showLikes && feed.type === 'feed' ? (
<Text type="sm-medium" style={[pal.text, pal.textLight]}>
Liked by {feed.likeCount || 0}{' '}
{pluralize(feed.likeCount || 0, 'user')}
<Trans>
Liked by {feed.likeCount || 0}{' '}
{pluralize(feed.likeCount || 0, 'user')}
</Trans>
</Text>
) : null}
</Pressable>

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