Merge branch 'main' into web-layout
This commit is contained in:
+5
-2
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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]>
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -147,6 +147,7 @@ interface ScreenPropertiesMap {
|
||||
Settings: {}
|
||||
AppPasswords: {}
|
||||
Moderation: {}
|
||||
PreferencesExternalEmbeds: {}
|
||||
BlockedAccounts: {}
|
||||
MutedAccounts: {}
|
||||
SavedFeeds: {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -98,7 +98,7 @@ export class MergeFeedAPI implements FeedAPI {
|
||||
}
|
||||
|
||||
return {
|
||||
cursor: posts.length ? String(this.itemCursor) : undefined,
|
||||
cursor: String(this.itemCursor),
|
||||
feed: posts,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -32,6 +32,7 @@ export type CommonNavigatorParams = {
|
||||
SavedFeeds: undefined
|
||||
PreferencesHomeFeed: undefined
|
||||
PreferencesThreads: undefined
|
||||
PreferencesExternalEmbeds: undefined
|
||||
}
|
||||
|
||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||
|
||||
+301
-45
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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[] = [
|
||||
|
||||
+373
-249
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+372
-252
File diff suppressed because it is too large
Load Diff
+382
-279
File diff suppressed because it is too large
Load Diff
+484
-372
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+372
-252
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
+397
-273
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
@@ -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
|
||||
@@ -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[]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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({})
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -35,4 +35,5 @@ export interface CachedFeedPage {
|
||||
usableInFeed: boolean
|
||||
syncedAt: Date
|
||||
data: FeedPage | undefined
|
||||
unreadCount: number
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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']>(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>{' '}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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=""
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user