Merge branch 'dx-atproto-modules' into hailey/oauth
# Conflicts: # metro.config.js # src/screens/Login/LoginForm.tsx
This commit is contained in:
+2
-4
@@ -16,10 +16,9 @@ import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||
import {readLastActiveAccount} from '#/state/session/util/readLastActiveAccount'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
import {useOTAUpdates} from 'lib/hooks/useOTAUpdates'
|
||||
import {useNotificationsListener} from 'lib/notifications/notifications'
|
||||
import {QueryProvider} from 'lib/react-query'
|
||||
import {s} from 'lib/styles'
|
||||
@@ -58,7 +57,6 @@ function InnerApp() {
|
||||
const {_} = useLingui()
|
||||
|
||||
useIntentHandler()
|
||||
useOTAUpdates()
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
@@ -66,7 +64,7 @@ function InnerApp() {
|
||||
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
|
||||
})
|
||||
|
||||
const account = persisted.get('session').currentAccount
|
||||
const account = readLastActiveAccount()
|
||||
resumeSession(account)
|
||||
}, [resumeSession, _])
|
||||
|
||||
|
||||
+2
-2
@@ -7,8 +7,8 @@ import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||
import {readLastActiveAccount} from '#/state/session/util/readLastActiveAccount'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
import {QueryProvider} from 'lib/react-query'
|
||||
import {ThemeProvider} from 'lib/ThemeContext'
|
||||
@@ -42,7 +42,7 @@ function InnerApp() {
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
const account = persisted.get('session').currentAccount
|
||||
const account = readLastActiveAccount()
|
||||
resumeSession(account)
|
||||
}, [resumeSession])
|
||||
|
||||
|
||||
+64
-13
@@ -25,6 +25,7 @@ import {
|
||||
FeedsTabNavigatorParams,
|
||||
FlatNavigatorParams,
|
||||
HomeTabNavigatorParams,
|
||||
MessagesTabNavigatorParams,
|
||||
MyProfileTabNavigatorParams,
|
||||
NotificationsTabNavigatorParams,
|
||||
SearchTabNavigatorParams,
|
||||
@@ -46,6 +47,9 @@ import {init as initAnalytics} from './lib/analytics/analytics'
|
||||
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
|
||||
import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
|
||||
import {router} from './routes'
|
||||
import {MessagesConversationScreen} from './screens/Messages/Conversation'
|
||||
import {MessagesListScreen} from './screens/Messages/List'
|
||||
import {MessagesSettingsScreen} from './screens/Messages/Settings'
|
||||
import {useModalControls} from './state/modals'
|
||||
import {useUnreadNotifications} from './state/queries/notifications/unread'
|
||||
import {useSession} from './state/session'
|
||||
@@ -53,6 +57,7 @@ import {
|
||||
setEmailConfirmationRequested,
|
||||
shouldRequestEmailConfirmation,
|
||||
} from './state/shell/reminders'
|
||||
import {AccessibilitySettingsScreen} from './view/screens/AccessibilitySettings'
|
||||
import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
|
||||
import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy'
|
||||
import {DebugModScreen} from './view/screens/DebugMod'
|
||||
@@ -91,6 +96,8 @@ const NotificationsTab =
|
||||
createNativeStackNavigatorWithAuth<NotificationsTabNavigatorParams>()
|
||||
const MyProfileTab =
|
||||
createNativeStackNavigatorWithAuth<MyProfileTabNavigatorParams>()
|
||||
const MessagesTab =
|
||||
createNativeStackNavigatorWithAuth<MessagesTabNavigatorParams>()
|
||||
const Flat = createNativeStackNavigatorWithAuth<FlatNavigatorParams>()
|
||||
const Tab = createBottomTabNavigator<BottomTabNavigatorParams>()
|
||||
|
||||
@@ -193,7 +200,7 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
<Stack.Screen
|
||||
name="ProfileFeed"
|
||||
getComponent={() => ProfileFeedScreen}
|
||||
options={{title: title(msg`Feed`), requireAuth: true}}
|
||||
options={{title: title(msg`Feed`)}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ProfileFeedLikedBy"
|
||||
@@ -276,11 +283,29 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="AccessibilitySettings"
|
||||
getComponent={() => AccessibilitySettingsScreen}
|
||||
options={{
|
||||
title: title(msg`Accessibility Settings`),
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Hashtag"
|
||||
getComponent={() => HashtagScreen}
|
||||
options={{title: title(msg`Hashtag`)}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="MessagesConversation"
|
||||
getComponent={() => MessagesConversationScreen}
|
||||
options={{title: title(msg`Chat`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="MessagesSettings"
|
||||
getComponent={() => MessagesSettingsScreen}
|
||||
options={{title: title(msg`Messaging settings`), requireAuth: true}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -314,6 +339,10 @@ function TabsNavigator() {
|
||||
name="MyProfileTab"
|
||||
getComponent={() => MyProfileTabNavigator}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="MessagesTab"
|
||||
getComponent={() => MessagesTabNavigator}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
)
|
||||
}
|
||||
@@ -331,11 +360,7 @@ function HomeTabNavigator() {
|
||||
animationDuration: 250,
|
||||
contentStyle: pal.view,
|
||||
}}>
|
||||
<HomeTab.Screen
|
||||
name="Home"
|
||||
getComponent={() => HomeScreen}
|
||||
options={{requireAuth: true}}
|
||||
/>
|
||||
<HomeTab.Screen name="Home" getComponent={() => HomeScreen} />
|
||||
{commonScreens(HomeTab)}
|
||||
</HomeTab.Navigator>
|
||||
)
|
||||
@@ -371,11 +396,7 @@ function FeedsTabNavigator() {
|
||||
animationDuration: 250,
|
||||
contentStyle: pal.view,
|
||||
}}>
|
||||
<FeedsTab.Screen
|
||||
name="Feeds"
|
||||
getComponent={() => FeedsScreen}
|
||||
options={{requireAuth: true}}
|
||||
/>
|
||||
<FeedsTab.Screen name="Feeds" getComponent={() => FeedsScreen} />
|
||||
{commonScreens(FeedsTab as typeof HomeTab)}
|
||||
</FeedsTab.Navigator>
|
||||
)
|
||||
@@ -428,6 +449,28 @@ function MyProfileTabNavigator() {
|
||||
)
|
||||
}
|
||||
|
||||
function MessagesTabNavigator() {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<MessagesTab.Navigator
|
||||
screenOptions={{
|
||||
animation: isAndroid ? 'none' : undefined,
|
||||
gestureEnabled: true,
|
||||
fullScreenGestureEnabled: true,
|
||||
headerShown: false,
|
||||
animationDuration: 250,
|
||||
contentStyle: pal.view,
|
||||
}}>
|
||||
<MessagesTab.Screen
|
||||
name="MessagesList"
|
||||
getComponent={() => MessagesListScreen}
|
||||
options={{requireAuth: true}}
|
||||
/>
|
||||
{commonScreens(MessagesTab as typeof HomeTab)}
|
||||
</MessagesTab.Navigator>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The FlatNavigator is used by Web to represent the routes
|
||||
* in a single ("flat") stack.
|
||||
@@ -451,7 +494,7 @@ const FlatNavigator = () => {
|
||||
<Flat.Screen
|
||||
name="Home"
|
||||
getComponent={() => HomeScreen}
|
||||
options={{title: title(msg`Home`), requireAuth: true}}
|
||||
options={{title: title(msg`Home`)}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="Search"
|
||||
@@ -461,13 +504,18 @@ const FlatNavigator = () => {
|
||||
<Flat.Screen
|
||||
name="Feeds"
|
||||
getComponent={() => FeedsScreen}
|
||||
options={{title: title(msg`Feeds`), requireAuth: true}}
|
||||
options={{title: title(msg`Feeds`)}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="Notifications"
|
||||
getComponent={() => NotificationsScreen}
|
||||
options={{title: title(msg`Notifications`), requireAuth: true}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="MessagesList"
|
||||
getComponent={() => MessagesListScreen}
|
||||
options={{title: title(msg`Messages`), requireAuth: true}}
|
||||
/>
|
||||
{commonScreens(Flat as typeof HomeTab, numUnread)}
|
||||
</Flat.Navigator>
|
||||
)
|
||||
@@ -521,6 +569,9 @@ const LINKING = {
|
||||
if (name === 'Home') {
|
||||
return buildStateObject('HomeTab', 'Home', params)
|
||||
}
|
||||
if (name === 'Messages') {
|
||||
return buildStateObject('MessagesTab', 'MessagesList', params)
|
||||
}
|
||||
// if the path is something else, like a post, profile, or even settings, we need to initialize the home tab as pre-existing state otherwise the back button will not work
|
||||
return buildStateObject('HomeTab', name, params, [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import RNPickerSelect, {PickerSelectProps} from 'react-native-picker-select'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
|
||||
import {APP_LANGUAGES} from '#/locale/languages'
|
||||
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
|
||||
import {resetPostsFeedQueries} from '#/state/queries/post-feed'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron'
|
||||
|
||||
export function AppLanguageDropdown() {
|
||||
const t = useTheme()
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const setLangPrefs = useLanguagePrefsApi()
|
||||
const sanitizedLang = sanitizeAppLanguageSetting(langPrefs.appLanguage)
|
||||
|
||||
const onChangeAppLanguage = React.useCallback(
|
||||
(value: Parameters<PickerSelectProps['onValueChange']>[0]) => {
|
||||
if (!value) return
|
||||
if (sanitizedLang !== value) {
|
||||
setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
|
||||
}
|
||||
setLangPrefs.setPrimaryLanguage(value)
|
||||
setLangPrefs.setContentLanguage(value)
|
||||
|
||||
// reset feeds to refetch content
|
||||
resetPostsFeedQueries(queryClient)
|
||||
},
|
||||
[sanitizedLang, setLangPrefs, queryClient],
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={a.relative}>
|
||||
<RNPickerSelect
|
||||
placeholder={{}}
|
||||
value={sanitizedLang}
|
||||
onValueChange={onChangeAppLanguage}
|
||||
items={APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({
|
||||
label: l.name,
|
||||
value: l.code2,
|
||||
key: l.code2,
|
||||
}))}
|
||||
useNativeAndroidPickerStyle={false}
|
||||
style={{
|
||||
inputAndroid: {
|
||||
color: t.atoms.text_contrast_medium.color,
|
||||
fontSize: 16,
|
||||
paddingRight: 12 + 4,
|
||||
},
|
||||
inputIOS: {
|
||||
color: t.atoms.text.color,
|
||||
fontSize: 16,
|
||||
paddingRight: 12 + 4,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
{left: 'auto'},
|
||||
{pointerEvents: 'none'},
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
]}>
|
||||
<ChevronDown fill={t.atoms.text.color} size="xs" />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
|
||||
import {APP_LANGUAGES} from '#/locale/languages'
|
||||
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
|
||||
import {resetPostsFeedQueries} from '#/state/queries/post-feed'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function AppLanguageDropdown() {
|
||||
const t = useTheme()
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const setLangPrefs = useLanguagePrefsApi()
|
||||
|
||||
const sanitizedLang = sanitizeAppLanguageSetting(langPrefs.appLanguage)
|
||||
|
||||
const onChangeAppLanguage = React.useCallback(
|
||||
(ev: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = ev.target.value
|
||||
|
||||
if (!value) return
|
||||
if (sanitizedLang !== value) {
|
||||
setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
|
||||
}
|
||||
setLangPrefs.setPrimaryLanguage(value)
|
||||
setLangPrefs.setContentLanguage(value)
|
||||
|
||||
// reset feeds to refetch content
|
||||
resetPostsFeedQueries(queryClient)
|
||||
},
|
||||
[sanitizedLang, setLangPrefs, queryClient],
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
// We don't have hitSlop here to increase the tap region,
|
||||
// alternative is negative margins.
|
||||
{height: 32, marginVertical: -((32 - 14) / 2)},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.gap_sm,
|
||||
a.align_center,
|
||||
a.flex_shrink,
|
||||
a.h_full,
|
||||
t.atoms.bg,
|
||||
]}>
|
||||
<Text aria-hidden={true} style={t.atoms.text_contrast_medium}>
|
||||
{APP_LANGUAGES.find(l => l.code2 === sanitizedLang)?.name}
|
||||
</Text>
|
||||
<ChevronDown fill={t.atoms.text.color} size="xs" style={a.flex_0} />
|
||||
</View>
|
||||
|
||||
<select
|
||||
value={sanitizedLang}
|
||||
onChange={onChangeAppLanguage}
|
||||
style={{
|
||||
fontSize: a.text_sm.fontSize,
|
||||
letterSpacing: a.text_sm.letterSpacing,
|
||||
cursor: 'pointer',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
opacity: 0,
|
||||
color: t.atoms.text.color,
|
||||
background: t.atoms.bg.backgroundColor,
|
||||
padding: 4,
|
||||
maxWidth: '100%',
|
||||
}}>
|
||||
{APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => (
|
||||
<option key={l.code2} value={l.code2}>
|
||||
{l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import Animated, {useAnimatedStyle} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import BottomSheet, {
|
||||
BottomSheetBackdropProps,
|
||||
BottomSheetFlatList,
|
||||
BottomSheetFlatListMethods,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetScrollViewMethods,
|
||||
BottomSheetTextInput,
|
||||
@@ -11,10 +13,10 @@ import BottomSheet, {
|
||||
useBottomSheet,
|
||||
WINDOW_HEIGHT,
|
||||
} from '@discord/bottom-sheet/src'
|
||||
import {BottomSheetFlatListProps} from '@discord/bottom-sheet/src/components/bottomSheetScrollable/types'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {atoms as a, flatten, useTheme} from '#/alf'
|
||||
import {Context} from '#/components/Dialog/context'
|
||||
import {
|
||||
@@ -150,6 +152,12 @@ export function Outer({
|
||||
[open, close],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
setDialogIsOpen(control.id, false)
|
||||
}
|
||||
}, [control.id, setDialogIsOpen])
|
||||
|
||||
const context = React.useMemo(() => ({close}), [close])
|
||||
|
||||
return (
|
||||
@@ -238,7 +246,7 @@ export const ScrollableInner = React.forwardRef<
|
||||
},
|
||||
flatten(style),
|
||||
]}
|
||||
contentContainerStyle={isNative ? a.pb_4xl : undefined}
|
||||
contentContainerStyle={a.pb_4xl}
|
||||
ref={ref}>
|
||||
{children}
|
||||
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
|
||||
@@ -246,6 +254,34 @@ export const ScrollableInner = React.forwardRef<
|
||||
)
|
||||
})
|
||||
|
||||
export const InnerFlatList = React.forwardRef<
|
||||
BottomSheetFlatListMethods,
|
||||
BottomSheetFlatListProps<any>
|
||||
>(function InnerFlatList({style, contentContainerStyle, ...props}, ref) {
|
||||
const insets = useSafeAreaInsets()
|
||||
return (
|
||||
<BottomSheetFlatList
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerStyle={[a.pb_4xl, flatten(contentContainerStyle)]}
|
||||
ListFooterComponent={
|
||||
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
|
||||
}
|
||||
ref={ref}
|
||||
{...props}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.p_xl,
|
||||
a.pt_0,
|
||||
a.h_full,
|
||||
{
|
||||
marginTop: 40,
|
||||
},
|
||||
flatten(style),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export function Handle() {
|
||||
const t = useTheme()
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import React, {useImperativeHandle} from 'react'
|
||||
import {TouchableWithoutFeedback, View} from 'react-native'
|
||||
import {
|
||||
FlatList,
|
||||
FlatListProps,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import Animated, {FadeIn, FadeInDown} from 'react-native-reanimated'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -192,6 +197,29 @@ export function Inner({
|
||||
|
||||
export const ScrollableInner = Inner
|
||||
|
||||
export const InnerFlatList = React.forwardRef<
|
||||
FlatList,
|
||||
FlatListProps<any> & {label: string}
|
||||
>(function InnerFlatList({label, style, ...props}, ref) {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
return (
|
||||
<Inner
|
||||
label={label}
|
||||
// @ts-ignore web only -sfn
|
||||
style={{
|
||||
paddingHorizontal: 0,
|
||||
maxHeight: 'calc(-36px + 100vh)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<FlatList
|
||||
ref={ref}
|
||||
style={[gtMobile ? a.px_2xl : a.px_xl, flatten(style)]}
|
||||
{...props}
|
||||
/>
|
||||
</Inner>
|
||||
)
|
||||
})
|
||||
|
||||
export function Handle() {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -16,10 +16,14 @@ export function Error({
|
||||
title,
|
||||
message,
|
||||
onRetry,
|
||||
onGoBack: onGoBackProp,
|
||||
sideBorders = true,
|
||||
}: {
|
||||
title?: string
|
||||
message?: string
|
||||
onRetry?: () => unknown
|
||||
onGoBack?: () => unknown
|
||||
sideBorders?: boolean
|
||||
}) {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {_} = useLingui()
|
||||
@@ -28,6 +32,10 @@ export function Error({
|
||||
|
||||
const canGoBack = navigation.canGoBack()
|
||||
const onGoBack = React.useCallback(() => {
|
||||
if (onGoBackProp) {
|
||||
onGoBackProp()
|
||||
return
|
||||
}
|
||||
if (canGoBack) {
|
||||
navigation.goBack()
|
||||
} else {
|
||||
@@ -41,18 +49,19 @@ export function Error({
|
||||
navigation.dispatch(StackActions.popToTop())
|
||||
}
|
||||
}
|
||||
}, [navigation, canGoBack])
|
||||
}, [navigation, canGoBack, onGoBackProp])
|
||||
|
||||
return (
|
||||
<CenteredView
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.align_center,
|
||||
!gtMobile ? a.justify_between : a.gap_5xl,
|
||||
a.gap_5xl,
|
||||
!gtMobile && a.justify_between,
|
||||
t.atoms.border_contrast_low,
|
||||
{paddingTop: 175, paddingBottom: 110},
|
||||
]}
|
||||
sideBorders>
|
||||
sideBorders={sideBorders}>
|
||||
<View style={[a.w_full, a.align_center, a.gap_lg]}>
|
||||
<Text style={[a.font_bold, a.text_3xl]}>{title}</Text>
|
||||
<Text
|
||||
|
||||
@@ -3,10 +3,12 @@ import {GestureResponderEvent} from 'react-native'
|
||||
import {sanitizeUrl} from '@braintree/sanitize-url'
|
||||
import {StackActions, useLinkProps} from '@react-navigation/native'
|
||||
|
||||
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
|
||||
import {AllNavigatorParams} from '#/lib/routes/types'
|
||||
import {shareUrl} from '#/lib/sharing'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
isBskyDownloadUrl,
|
||||
isExternalUrl,
|
||||
linkRequiresWarning,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
@@ -125,7 +127,9 @@ export function useLink({
|
||||
(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey)
|
||||
const shouldOpenInNewTab = isMetaKey || isMiddleClick
|
||||
|
||||
if (
|
||||
if (isBskyDownloadUrl(href)) {
|
||||
shareUrl(BSKY_DOWNLOAD_URL)
|
||||
} else if (
|
||||
shouldOpenInNewTab ||
|
||||
href.startsWith('http') ||
|
||||
href.startsWith('mailto')
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import React, {memo} from 'react'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {atoms as a, flatten, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Error} from '#/components/Error'
|
||||
import {Loader} from '#/components/Loader'
|
||||
@@ -16,11 +16,13 @@ export function ListFooter({
|
||||
error,
|
||||
onRetry,
|
||||
height,
|
||||
style,
|
||||
}: {
|
||||
isFetchingNextPage?: boolean
|
||||
error?: string
|
||||
onRetry?: () => Promise<unknown>
|
||||
height?: number
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
@@ -33,6 +35,7 @@ export function ListFooter({
|
||||
a.pb_lg,
|
||||
t.atoms.border_contrast_low,
|
||||
{height: height ?? 180, paddingTop: 30},
|
||||
flatten(style),
|
||||
]}>
|
||||
{isFetchingNextPage ? (
|
||||
<Loader size="xl" />
|
||||
@@ -120,7 +123,7 @@ export function ListHeaderDesktop({
|
||||
)
|
||||
}
|
||||
|
||||
export function ListMaybePlaceholder({
|
||||
let ListMaybePlaceholder = ({
|
||||
isLoading,
|
||||
noEmpty,
|
||||
isError,
|
||||
@@ -130,6 +133,8 @@ export function ListMaybePlaceholder({
|
||||
errorMessage,
|
||||
emptyType = 'page',
|
||||
onRetry,
|
||||
onGoBack,
|
||||
sideBorders,
|
||||
}: {
|
||||
isLoading: boolean
|
||||
noEmpty?: boolean
|
||||
@@ -140,7 +145,9 @@ export function ListMaybePlaceholder({
|
||||
errorMessage?: string
|
||||
emptyType?: 'page' | 'results'
|
||||
onRetry?: () => Promise<unknown>
|
||||
}) {
|
||||
onGoBack?: () => void
|
||||
sideBorders?: boolean
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtMobile, gtTablet} = useBreakpoints()
|
||||
@@ -155,7 +162,7 @@ export function ListMaybePlaceholder({
|
||||
t.atoms.border_contrast_low,
|
||||
{paddingTop: 175, paddingBottom: 110},
|
||||
]}
|
||||
sideBorders={gtMobile}
|
||||
sideBorders={sideBorders ?? gtMobile}
|
||||
topBorder={!gtTablet}>
|
||||
<View style={[a.w_full, a.align_center, {top: 100}]}>
|
||||
<Loader size="xl" />
|
||||
@@ -170,6 +177,8 @@ export function ListMaybePlaceholder({
|
||||
title={errorTitle ?? _(msg`Oops!`)}
|
||||
message={errorMessage ?? _(`Something went wrong!`)}
|
||||
onRetry={onRetry}
|
||||
onGoBack={onGoBack}
|
||||
sideBorders={sideBorders}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -188,9 +197,13 @@ export function ListMaybePlaceholder({
|
||||
_(msg`We're sorry! We can't find the page you were looking for.`)
|
||||
}
|
||||
onRetry={onRetry}
|
||||
onGoBack={onGoBack}
|
||||
sideBorders={sideBorders}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
ListMaybePlaceholder = memo(ListMaybePlaceholder)
|
||||
export {ListMaybePlaceholder}
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
|
||||
import React from 'react'
|
||||
import {View, Pressable, ViewStyle, StyleProp} from 'react-native'
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
|
||||
import {atoms as a, flatten, useTheme, web} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {atoms as a, useTheme, flatten, web} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
import {Context} from '#/components/Menu/context'
|
||||
import {
|
||||
ContextType,
|
||||
TriggerProps,
|
||||
ItemProps,
|
||||
GroupProps,
|
||||
ItemTextProps,
|
||||
ItemIconProps,
|
||||
ItemProps,
|
||||
ItemTextProps,
|
||||
RadixPassThroughTriggerProps,
|
||||
TriggerProps,
|
||||
} from '#/components/Menu/types'
|
||||
import {Context} from '#/components/Menu/context'
|
||||
import {Portal} from '#/components/Portal'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function useMenuControl(): Dialog.DialogControlProps {
|
||||
const id = React.useId()
|
||||
@@ -135,10 +134,22 @@ export function Trigger({children, label}: TriggerProps) {
|
||||
},
|
||||
props: {
|
||||
...props,
|
||||
// disable on web, use `onPress`
|
||||
onPointerDown: () => false,
|
||||
onPress: () =>
|
||||
control.isOpen ? control.close() : control.open(),
|
||||
// No-op override to prevent false positive that interprets mobile scroll as a tap.
|
||||
// This requires the custom onPress handler below to compensate.
|
||||
// https://github.com/radix-ui/primitives/issues/1912
|
||||
onPointerDown: undefined,
|
||||
onPress: () => {
|
||||
if (window.event instanceof KeyboardEvent) {
|
||||
// The onPointerDown hack above is not relevant to this press, so don't do anything.
|
||||
return
|
||||
}
|
||||
// Compensate for the disabled onPointerDown above by triggering it manually.
|
||||
if (control.isOpen) {
|
||||
control.close()
|
||||
} else {
|
||||
control.open()
|
||||
}
|
||||
},
|
||||
onFocus: onFocus,
|
||||
onBlur: onBlur,
|
||||
onMouseEnter,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import {ProfileHoverCardProps} from './types'
|
||||
|
||||
export function ProfileHoverCard({children}: ProfileHoverCardProps) {
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
|
||||
import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {pluralize} from '#/lib/strings/helpers'
|
||||
import {useModerationOpts} from '#/state/queries/preferences'
|
||||
import {usePrefetchProfileQuery, useProfileQuery} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||
import {formatCount} from '#/view/com/util/numeric/format'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {ProfileHeaderHandle} from '#/screens/Profile/Header/Handle'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {useFollowMethods} from '#/components/hooks/useFollowMethods'
|
||||
import {useRichText} from '#/components/hooks/useRichText'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {InlineLinkText, Link} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Portal} from '#/components/Portal'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ProfileHoverCardProps} from './types'
|
||||
|
||||
const floatingMiddlewares = [
|
||||
offset(4),
|
||||
flip({padding: 16}),
|
||||
shift({padding: 16}),
|
||||
size({
|
||||
padding: 16,
|
||||
apply({availableWidth, availableHeight, elements}) {
|
||||
Object.assign(elements.floating.style, {
|
||||
maxWidth: `${availableWidth}px`,
|
||||
maxHeight: `${availableHeight}px`,
|
||||
})
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0
|
||||
|
||||
export function ProfileHoverCard(props: ProfileHoverCardProps) {
|
||||
return isTouchDevice ? props.children : <ProfileHoverCardInner {...props} />
|
||||
}
|
||||
|
||||
type State =
|
||||
| {
|
||||
stage: 'hidden' | 'might-hide' | 'hiding'
|
||||
effect?: () => () => any
|
||||
}
|
||||
| {
|
||||
stage: 'might-show' | 'showing'
|
||||
effect?: () => () => any
|
||||
reason: 'hovered-target' | 'hovered-card'
|
||||
}
|
||||
|
||||
type Action =
|
||||
| 'pressed'
|
||||
| 'scrolled-while-showing'
|
||||
| 'hovered-target'
|
||||
| 'unhovered-target'
|
||||
| 'hovered-card'
|
||||
| 'unhovered-card'
|
||||
| 'hovered-long-enough'
|
||||
| 'unhovered-long-enough'
|
||||
| 'finished-animating-hide'
|
||||
|
||||
const SHOW_DELAY = 500
|
||||
const SHOW_DURATION = 300
|
||||
const HIDE_DELAY = 150
|
||||
const HIDE_DURATION = 200
|
||||
|
||||
export function ProfileHoverCardInner(props: ProfileHoverCardProps) {
|
||||
const {refs, floatingStyles} = useFloating({
|
||||
middleware: floatingMiddlewares,
|
||||
})
|
||||
|
||||
const [currentState, dispatch] = React.useReducer(
|
||||
// Tip: console.log(state, action) when debugging.
|
||||
(state: State, action: Action): State => {
|
||||
// Pressing within a card should always hide it.
|
||||
// No matter which stage we're in.
|
||||
if (action === 'pressed') {
|
||||
return hidden()
|
||||
}
|
||||
|
||||
// --- Hidden ---
|
||||
// In the beginning, the card is not displayed.
|
||||
function hidden(): State {
|
||||
return {stage: 'hidden'}
|
||||
}
|
||||
if (state.stage === 'hidden') {
|
||||
// The user can kick things off by hovering a target.
|
||||
if (action === 'hovered-target') {
|
||||
return mightShow({
|
||||
reason: action,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Might Show ---
|
||||
// The card is not visible yet but we're considering showing it.
|
||||
function mightShow({
|
||||
waitMs = SHOW_DELAY,
|
||||
reason,
|
||||
}: {
|
||||
waitMs?: number
|
||||
reason: 'hovered-target' | 'hovered-card'
|
||||
}): State {
|
||||
return {
|
||||
stage: 'might-show',
|
||||
reason,
|
||||
effect() {
|
||||
const id = setTimeout(() => dispatch('hovered-long-enough'), waitMs)
|
||||
return () => {
|
||||
clearTimeout(id)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
if (state.stage === 'might-show') {
|
||||
// We'll make a decision at the end of a grace period timeout.
|
||||
if (action === 'unhovered-target' || action === 'unhovered-card') {
|
||||
return hidden()
|
||||
}
|
||||
if (action === 'hovered-long-enough') {
|
||||
return showing({
|
||||
reason: state.reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Showing ---
|
||||
// The card is beginning to show up and then will remain visible.
|
||||
function showing({
|
||||
reason,
|
||||
}: {
|
||||
reason: 'hovered-target' | 'hovered-card'
|
||||
}): State {
|
||||
return {
|
||||
stage: 'showing',
|
||||
reason,
|
||||
effect() {
|
||||
function onScroll() {
|
||||
dispatch('scrolled-while-showing')
|
||||
}
|
||||
window.addEventListener('scroll', onScroll)
|
||||
return () => window.removeEventListener('scroll', onScroll)
|
||||
},
|
||||
}
|
||||
}
|
||||
if (state.stage === 'showing') {
|
||||
// If the user moves the pointer away, we'll begin to consider hiding it.
|
||||
if (action === 'unhovered-target' || action === 'unhovered-card') {
|
||||
return mightHide()
|
||||
}
|
||||
// Scrolling away if the hover is on the target instantly hides without a delay.
|
||||
// If the hover is already on the card, we won't this.
|
||||
if (
|
||||
state.reason === 'hovered-target' &&
|
||||
action === 'scrolled-while-showing'
|
||||
) {
|
||||
return hiding()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Might Hide ---
|
||||
// The user has moved hover away from a visible card.
|
||||
function mightHide({waitMs = HIDE_DELAY}: {waitMs?: number} = {}): State {
|
||||
return {
|
||||
stage: 'might-hide',
|
||||
effect() {
|
||||
const id = setTimeout(
|
||||
() => dispatch('unhovered-long-enough'),
|
||||
waitMs,
|
||||
)
|
||||
return () => clearTimeout(id)
|
||||
},
|
||||
}
|
||||
}
|
||||
if (state.stage === 'might-hide') {
|
||||
// We'll make a decision based on whether it received hover again in time.
|
||||
if (action === 'hovered-target' || action === 'hovered-card') {
|
||||
return showing({
|
||||
reason: action,
|
||||
})
|
||||
}
|
||||
if (action === 'unhovered-long-enough') {
|
||||
return hiding()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Hiding ---
|
||||
// The user waited enough outside that we're hiding the card.
|
||||
function hiding({
|
||||
animationDurationMs = HIDE_DURATION,
|
||||
}: {
|
||||
animationDurationMs?: number
|
||||
} = {}): State {
|
||||
return {
|
||||
stage: 'hiding',
|
||||
effect() {
|
||||
const id = setTimeout(
|
||||
() => dispatch('finished-animating-hide'),
|
||||
animationDurationMs,
|
||||
)
|
||||
return () => clearTimeout(id)
|
||||
},
|
||||
}
|
||||
}
|
||||
if (state.stage === 'hiding') {
|
||||
// While hiding, we don't want to be interrupted by anything else.
|
||||
// When the animation finishes, we loop back to the initial hidden state.
|
||||
if (action === 'finished-animating-hide') {
|
||||
return hidden()
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
},
|
||||
{stage: 'hidden'},
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (currentState.effect) {
|
||||
const effect = currentState.effect
|
||||
return effect()
|
||||
}
|
||||
}, [currentState])
|
||||
|
||||
const prefetchProfileQuery = usePrefetchProfileQuery()
|
||||
const prefetchedProfile = React.useRef(false)
|
||||
const prefetchIfNeeded = React.useCallback(async () => {
|
||||
if (!prefetchedProfile.current) {
|
||||
prefetchedProfile.current = true
|
||||
prefetchProfileQuery(props.did)
|
||||
}
|
||||
}, [prefetchProfileQuery, props.did])
|
||||
|
||||
const didFireHover = React.useRef(false)
|
||||
const onPointerMoveTarget = React.useCallback(() => {
|
||||
prefetchIfNeeded()
|
||||
// Conceptually we want something like onPointerEnter,
|
||||
// but we want to ignore entering only due to scrolling.
|
||||
// So instead we hover on the first onPointerMove.
|
||||
if (!didFireHover.current) {
|
||||
didFireHover.current = true
|
||||
dispatch('hovered-target')
|
||||
}
|
||||
}, [prefetchIfNeeded])
|
||||
|
||||
const onPointerLeaveTarget = React.useCallback(() => {
|
||||
didFireHover.current = false
|
||||
dispatch('unhovered-target')
|
||||
}, [])
|
||||
|
||||
const onPointerEnterCard = React.useCallback(() => {
|
||||
dispatch('hovered-card')
|
||||
}, [])
|
||||
|
||||
const onPointerLeaveCard = React.useCallback(() => {
|
||||
dispatch('unhovered-card')
|
||||
}, [])
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
dispatch('pressed')
|
||||
}, [])
|
||||
|
||||
const isVisible =
|
||||
currentState.stage === 'showing' ||
|
||||
currentState.stage === 'might-hide' ||
|
||||
currentState.stage === 'hiding'
|
||||
|
||||
const animationStyle = {
|
||||
animation:
|
||||
currentState.stage === 'hiding'
|
||||
? `avatarHoverFadeOut ${HIDE_DURATION}ms both`
|
||||
: `avatarHoverFadeIn ${SHOW_DURATION}ms both`,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={refs.setReference}
|
||||
onPointerMove={onPointerMoveTarget}
|
||||
onPointerLeave={onPointerLeaveTarget}
|
||||
onMouseUp={onPress}
|
||||
style={{
|
||||
display: props.inline ? 'inline' : 'block',
|
||||
}}>
|
||||
{props.children}
|
||||
{isVisible && (
|
||||
<Portal>
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
onPointerEnter={onPointerEnterCard}
|
||||
onPointerLeave={onPointerLeaveCard}>
|
||||
<div style={{willChange: 'transform', ...animationStyle}}>
|
||||
<Card did={props.did} hide={onPress} />
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
let Card = ({did, hide}: {did: string; hide: () => void}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
|
||||
const profile = useProfileQuery({did})
|
||||
const moderationOpts = useModerationOpts()
|
||||
|
||||
const data = profile.data
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.p_lg,
|
||||
a.border,
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.shadow_lg,
|
||||
{
|
||||
width: 300,
|
||||
},
|
||||
]}>
|
||||
{data && moderationOpts ? (
|
||||
<Inner profile={data} moderationOpts={moderationOpts} hide={hide} />
|
||||
) : (
|
||||
<View style={[a.justify_center]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
Card = React.memo(Card)
|
||||
|
||||
function Inner({
|
||||
profile,
|
||||
moderationOpts,
|
||||
hide,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
moderationOpts: ModerationOpts
|
||||
hide: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const moderation = React.useMemo(
|
||||
() => moderateProfile(profile, moderationOpts),
|
||||
[profile, moderationOpts],
|
||||
)
|
||||
const [descriptionRT] = useRichText(profile.description ?? '')
|
||||
const profileShadow = useProfileShadow(profile)
|
||||
const {follow, unfollow} = useFollowMethods({
|
||||
profile: profileShadow,
|
||||
logContext: 'ProfileHoverCard',
|
||||
})
|
||||
const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy
|
||||
const following = formatCount(profile.followsCount || 0)
|
||||
const followers = formatCount(profile.followersCount || 0)
|
||||
const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
|
||||
const profileURL = makeProfileLink({
|
||||
did: profile.did,
|
||||
handle: profile.handle,
|
||||
})
|
||||
const isMe = React.useMemo(
|
||||
() => currentAccount?.did === profile.did,
|
||||
[currentAccount, profile],
|
||||
)
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={[a.flex_row, a.justify_between, a.align_start]}>
|
||||
<Link to={profileURL} label={_(msg`View profile`)} onPress={hide}>
|
||||
<UserAvatar
|
||||
size={64}
|
||||
avatar={profile.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{!isMe && (
|
||||
<Button
|
||||
size="small"
|
||||
color={profileShadow.viewer?.following ? 'secondary' : 'primary'}
|
||||
variant="solid"
|
||||
label={
|
||||
profileShadow.viewer?.following ? _('Following') : _('Follow')
|
||||
}
|
||||
style={[a.rounded_full]}
|
||||
onPress={profileShadow.viewer?.following ? unfollow : follow}>
|
||||
<ButtonIcon
|
||||
position="left"
|
||||
icon={profileShadow.viewer?.following ? Check : Plus}
|
||||
/>
|
||||
<ButtonText>
|
||||
{profileShadow.viewer?.following ? _('Following') : _('Follow')}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Link to={profileURL} label={_(msg`View profile`)} onPress={hide}>
|
||||
<View style={[a.pb_sm, a.flex_1]}>
|
||||
<Text style={[a.pt_md, a.pb_xs, a.text_lg, a.font_bold]}>
|
||||
{sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<ProfileHeaderHandle profile={profileShadow} />
|
||||
</View>
|
||||
</Link>
|
||||
|
||||
{!blockHide && (
|
||||
<>
|
||||
<View style={[a.flex_row, a.flex_wrap, a.gap_md, a.pt_xs]}>
|
||||
<InlineLinkText
|
||||
to={makeProfileLink(profile, 'followers')}
|
||||
label={`${followers} ${pluralizedFollowers}`}
|
||||
style={[t.atoms.text]}
|
||||
onPress={hide}>
|
||||
<Trans>
|
||||
<Text style={[a.text_md, a.font_bold]}>{followers} </Text>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>
|
||||
{pluralizedFollowers}
|
||||
</Text>
|
||||
</Trans>
|
||||
</InlineLinkText>
|
||||
<InlineLinkText
|
||||
to={makeProfileLink(profile, 'follows')}
|
||||
label={_(msg`${following} following`)}
|
||||
style={[t.atoms.text]}
|
||||
onPress={hide}>
|
||||
<Trans>
|
||||
<Text style={[a.text_md, a.font_bold]}>{following} </Text>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>following</Text>
|
||||
</Trans>
|
||||
</InlineLinkText>
|
||||
</View>
|
||||
|
||||
{profile.description?.trim() && !moderation.ui('profileView').blur ? (
|
||||
<View style={[a.pt_md]}>
|
||||
<RichText
|
||||
numberOfLines={8}
|
||||
value={descriptionRT}
|
||||
onLinkPress={hide}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import React from 'react'
|
||||
|
||||
export type ProfileHoverCardProps = {
|
||||
children: React.ReactElement
|
||||
did: string
|
||||
inline?: boolean
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {getLabelingServiceTitle} from '#/lib/moderation'
|
||||
import {ReportOption} from '#/lib/moderation/useReportOptions'
|
||||
import {getAgent} from '#/state/session'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
@@ -35,6 +35,7 @@ export function SubmitView({
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {getAgent} = useAgent()
|
||||
const [details, setDetails] = React.useState<string>('')
|
||||
const [submitting, setSubmitting] = React.useState<boolean>(false)
|
||||
const [selectedServices, setSelectedServices] = React.useState<string[]>([
|
||||
@@ -90,6 +91,7 @@ export function SubmitView({
|
||||
selectedServices,
|
||||
onSubmitComplete,
|
||||
setError,
|
||||
getAgent,
|
||||
])
|
||||
|
||||
return (
|
||||
|
||||
+30
-14
@@ -2,12 +2,15 @@ import React from 'react'
|
||||
import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {toShortUrl} from '#/lib/strings/url-helpers'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {atoms as a, flatten, native, TextStyleProp, useTheme, web} from '#/alf'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {InlineLinkText, LinkProps} from '#/components/Link'
|
||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||
import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
|
||||
import {Text, TextProps} from '#/components/Typography'
|
||||
|
||||
@@ -22,6 +25,7 @@ export function RichText({
|
||||
selectable,
|
||||
enableTags = false,
|
||||
authorHandle,
|
||||
onLinkPress,
|
||||
}: TextStyleProp &
|
||||
Pick<TextProps, 'selectable'> & {
|
||||
value: RichTextAPI | string
|
||||
@@ -30,6 +34,7 @@ export function RichText({
|
||||
disableLinks?: boolean
|
||||
enableTags?: boolean
|
||||
authorHandle?: string
|
||||
onLinkPress?: LinkProps['onPress']
|
||||
}) {
|
||||
const richText = React.useMemo(
|
||||
() =>
|
||||
@@ -84,15 +89,17 @@ export function RichText({
|
||||
!disableLinks
|
||||
) {
|
||||
els.push(
|
||||
<InlineLinkText
|
||||
selectable={selectable}
|
||||
key={key}
|
||||
to={`/profile/${mention.did}`}
|
||||
style={[...styles, {pointerEvents: 'auto'}]}
|
||||
// @ts-ignore TODO
|
||||
dataSet={WORD_WRAP}>
|
||||
{segment.text}
|
||||
</InlineLinkText>,
|
||||
<ProfileHoverCard key={key} inline did={mention.did}>
|
||||
<InlineLinkText
|
||||
selectable={selectable}
|
||||
to={`/profile/${mention.did}`}
|
||||
style={[...styles, {pointerEvents: 'auto'}]}
|
||||
// @ts-ignore TODO
|
||||
dataSet={WORD_WRAP}
|
||||
onPress={onLinkPress}>
|
||||
{segment.text}
|
||||
</InlineLinkText>
|
||||
</ProfileHoverCard>,
|
||||
)
|
||||
} else if (link && AppBskyRichtextFacet.validateLink(link).success) {
|
||||
if (disableLinks) {
|
||||
@@ -106,7 +113,8 @@ export function RichText({
|
||||
style={[...styles, {pointerEvents: 'auto'}]}
|
||||
// @ts-ignore TODO
|
||||
dataSet={WORD_WRAP}
|
||||
shareOnLongPress>
|
||||
shareOnLongPress
|
||||
onPress={onLinkPress}>
|
||||
{toShortUrl(segment.text)}
|
||||
</InlineLinkText>,
|
||||
)
|
||||
@@ -172,8 +180,15 @@ function RichTextTag({
|
||||
onIn: onPressIn,
|
||||
onOut: onPressOut,
|
||||
} = useInteractionState()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
const open = React.useCallback(() => {
|
||||
const navigateToPage = React.useCallback(() => {
|
||||
navigation.push('Hashtag', {
|
||||
tag: encodeURIComponent(tag),
|
||||
})
|
||||
}, [navigation, tag])
|
||||
|
||||
const openDialog = React.useCallback(() => {
|
||||
control.open()
|
||||
}, [control])
|
||||
|
||||
@@ -189,9 +204,10 @@ function RichTextTag({
|
||||
selectable={selectable}
|
||||
{...native({
|
||||
accessibilityLabel: _(msg`Hashtag: #${tag}`),
|
||||
accessibilityHint: _(msg`Click here to open tag menu for #${tag}`),
|
||||
accessibilityHint: _(msg`Long press to open tag menu for #${tag}`),
|
||||
accessibilityRole: isNative ? 'button' : undefined,
|
||||
onPress: open,
|
||||
onPress: navigateToPage,
|
||||
onLongPress: openDialog,
|
||||
onPressIn: onPressIn,
|
||||
onPressOut: onPressOut,
|
||||
})}
|
||||
|
||||
@@ -6,10 +6,12 @@ type Control = Dialog.DialogOuterProps['control']
|
||||
|
||||
type ControlsContext = {
|
||||
mutedWordsDialogControl: Control
|
||||
signinDialogControl: Control
|
||||
}
|
||||
|
||||
const ControlsContext = React.createContext({
|
||||
mutedWordsDialogControl: {} as Control,
|
||||
signinDialogControl: {} as Control,
|
||||
})
|
||||
|
||||
export function useGlobalDialogsControlContext() {
|
||||
@@ -18,9 +20,10 @@ export function useGlobalDialogsControlContext() {
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const mutedWordsDialogControl = Dialog.useDialogControl()
|
||||
const signinDialogControl = Dialog.useDialogControl()
|
||||
const ctx = React.useMemo<ControlsContext>(
|
||||
() => ({mutedWordsDialogControl}),
|
||||
[mutedWordsDialogControl],
|
||||
() => ({mutedWordsDialogControl, signinDialogControl}),
|
||||
[mutedWordsDialogControl, signinDialogControl],
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import React, {memo, useRef, useState} from 'react'
|
||||
import {TextInput, View} from 'react-native'
|
||||
import {AppBskyActorDefs, AppBskyFeedPost, AtUri} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {EMBED_SCRIPT} from '#/lib/constants'
|
||||
import {niceDate} from '#/lib/strings/time'
|
||||
import {toShareUrl} from '#/lib/strings/url-helpers'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Button, ButtonIcon, ButtonText} from '../Button'
|
||||
|
||||
type EmbedDialogProps = {
|
||||
control: Dialog.DialogControlProps
|
||||
postAuthor: AppBskyActorDefs.ProfileViewBasic
|
||||
postCid: string
|
||||
postUri: string
|
||||
record: AppBskyFeedPost.Record
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
let EmbedDialog = ({control, ...rest}: EmbedDialogProps): React.ReactNode => {
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<EmbedDialogInner {...rest} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
EmbedDialog = memo(EmbedDialog)
|
||||
export {EmbedDialog}
|
||||
|
||||
function EmbedDialogInner({
|
||||
postAuthor,
|
||||
postCid,
|
||||
postUri,
|
||||
record,
|
||||
timestamp,
|
||||
}: Omit<EmbedDialogProps, 'control'>) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const ref = useRef<TextInput>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
// reset copied state after 2 seconds
|
||||
React.useEffect(() => {
|
||||
if (copied) {
|
||||
const timeout = setTimeout(() => {
|
||||
setCopied(false)
|
||||
}, 2000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [copied])
|
||||
|
||||
const snippet = React.useMemo(() => {
|
||||
function toEmbedUrl(href: string) {
|
||||
return toShareUrl(href) + '?ref_src=embed'
|
||||
}
|
||||
|
||||
const lang = record.langs && record.langs.length > 0 ? record.langs[0] : ''
|
||||
const profileHref = toEmbedUrl(['/profile', postAuthor.did].join('/'))
|
||||
const urip = new AtUri(postUri)
|
||||
const href = toEmbedUrl(
|
||||
['/profile', postAuthor.did, 'post', urip.rkey].join('/'),
|
||||
)
|
||||
|
||||
// x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x
|
||||
// DO NOT ADD ANY NEW INTERPOLATIONS BELOW WITHOUT ESCAPING THEM!
|
||||
// Also, keep this code synced with the bskyembed code in landing.tsx.
|
||||
// x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x-x
|
||||
return `<blockquote class="bluesky-embed" data-bluesky-uri="${escapeHtml(
|
||||
postUri,
|
||||
)}" data-bluesky-cid="${escapeHtml(postCid)}"><p lang="${escapeHtml(
|
||||
lang,
|
||||
)}">${escapeHtml(record.text)}${
|
||||
record.embed
|
||||
? `<br><br><a href="${escapeHtml(href)}">[image or embed]</a>`
|
||||
: ''
|
||||
}</p>— ${escapeHtml(
|
||||
postAuthor.displayName || postAuthor.handle,
|
||||
)} (<a href="${escapeHtml(profileHref)}">@${escapeHtml(
|
||||
postAuthor.handle,
|
||||
)}</a>) <a href="${escapeHtml(href)}">${escapeHtml(
|
||||
niceDate(timestamp),
|
||||
)}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>`
|
||||
}, [postUri, postCid, record, timestamp, postAuthor])
|
||||
|
||||
return (
|
||||
<Dialog.Inner label="Embed post" style={[a.gap_md, {maxWidth: 500}]}>
|
||||
<View style={[a.gap_sm, a.pb_lg]}>
|
||||
<Text style={[a.text_2xl, a.font_bold]}>
|
||||
<Trans>Embed post</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[a.text_md, t.atoms.text_contrast_medium, a.leading_normal]}>
|
||||
<Trans>
|
||||
Embed this post in your website. Simply copy the following snippet
|
||||
and paste it into the HTML code of your website.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={CodeBrackets} />
|
||||
<TextField.Input
|
||||
label={_(msg`Embed HTML code`)}
|
||||
editable={false}
|
||||
selection={{start: 0, end: snippet.length}}
|
||||
value={snippet}
|
||||
style={{}}
|
||||
/>
|
||||
</TextField.Root>
|
||||
<Button
|
||||
label={_(msg`Copy code`)}
|
||||
color="primary"
|
||||
variant="solid"
|
||||
size="medium"
|
||||
onPress={() => {
|
||||
ref.current?.focus()
|
||||
ref.current?.setSelection(0, snippet.length)
|
||||
navigator.clipboard.writeText(snippet)
|
||||
setCopied(true)
|
||||
}}>
|
||||
{copied ? (
|
||||
<>
|
||||
<ButtonIcon icon={Check} />
|
||||
<ButtonText>
|
||||
<Trans>Copied!</Trans>
|
||||
</ButtonText>
|
||||
</>
|
||||
) : (
|
||||
<ButtonText>
|
||||
<Trans>Copy code</Trans>
|
||||
</ButtonText>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
<Dialog.Close />
|
||||
</Dialog.Inner>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Based on a snippet of code from React, which itself was based on the escape-html library.
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates
|
||||
* Copyright (c) 2012-2013 TJ Holowaychuk
|
||||
* Copyright (c) 2015 Andreas Lubbe
|
||||
* Copyright (c) 2015 Tiancheng "Timothy" Gu
|
||||
* Licensed as MIT.
|
||||
*/
|
||||
const matchHtmlRegExp = /["'&<>]/
|
||||
function escapeHtml(string: string) {
|
||||
const str = String(string)
|
||||
const match = matchHtmlRegExp.exec(str)
|
||||
if (!match) {
|
||||
return str
|
||||
}
|
||||
let escape
|
||||
let html = ''
|
||||
let index
|
||||
let lastIndex = 0
|
||||
for (index = match.index; index < str.length; index++) {
|
||||
switch (str.charCodeAt(index)) {
|
||||
case 34: // "
|
||||
escape = '"'
|
||||
break
|
||||
case 38: // &
|
||||
escape = '&'
|
||||
break
|
||||
case 39: // '
|
||||
escape = '''
|
||||
break
|
||||
case 60: // <
|
||||
escape = '<'
|
||||
break
|
||||
case 62: // >
|
||||
escape = '>'
|
||||
break
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if (lastIndex !== index) {
|
||||
html += str.slice(lastIndex, index)
|
||||
}
|
||||
lastIndex = index + 1
|
||||
html += escape
|
||||
}
|
||||
return lastIndex !== index ? html + str.slice(lastIndex, index) : html
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import React, {useCallback, useMemo, useRef, useState} from 'react'
|
||||
import {Keyboard, TextInput, View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {
|
||||
Gif,
|
||||
useFeaturedGifsQuery,
|
||||
useGifSearchQuery,
|
||||
} from '#/state/queries/tenor'
|
||||
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
|
||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
|
||||
import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {Button, ButtonIcon, ButtonText} from '../Button'
|
||||
import {ListFooter, ListMaybePlaceholder} from '../Lists'
|
||||
|
||||
export function GifSelectDialog({
|
||||
control,
|
||||
onClose,
|
||||
onSelectGif: onSelectGifProp,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
onClose: () => void
|
||||
onSelectGif: (gif: Gif) => void
|
||||
}) {
|
||||
const onSelectGif = useCallback(
|
||||
(gif: Gif) => {
|
||||
control.close(() => onSelectGifProp(gif))
|
||||
},
|
||||
[control, onSelectGifProp],
|
||||
)
|
||||
|
||||
const renderErrorBoundary = useCallback(
|
||||
(error: any) => <DialogError details={String(error)} />,
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
nativeOptions={{sheet: {snapPoints: ['100%']}}}
|
||||
onClose={onClose}>
|
||||
<Dialog.Handle />
|
||||
<ErrorBoundary renderError={renderErrorBoundary}>
|
||||
<GifList control={control} onSelectGif={onSelectGif} />
|
||||
</ErrorBoundary>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function GifList({
|
||||
control,
|
||||
onSelectGif,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
onSelectGif: (gif: Gif) => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const textInputRef = useRef<TextInput>(null)
|
||||
const listRef = useRef<BottomSheetFlatListMethods>(null)
|
||||
const [undeferredSearch, setSearch] = useState('')
|
||||
const search = useThrottledValue(undeferredSearch, 500)
|
||||
|
||||
const isSearching = search.length > 0
|
||||
|
||||
const trendingQuery = useFeaturedGifsQuery()
|
||||
const searchQuery = useGifSearchQuery(search)
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
error,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = isSearching ? searchQuery : trendingQuery
|
||||
|
||||
const flattenedData = useMemo(() => {
|
||||
return data?.pages.flatMap(page => page.results) || []
|
||||
}, [data])
|
||||
|
||||
const renderItem = useCallback(
|
||||
({item}: {item: Gif}) => {
|
||||
return <GifPreview gif={item} onSelectGif={onSelectGif} />
|
||||
},
|
||||
[onSelectGif],
|
||||
)
|
||||
|
||||
const onEndReached = React.useCallback(() => {
|
||||
if (isFetchingNextPage || !hasNextPage || error) return
|
||||
fetchNextPage()
|
||||
}, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
|
||||
|
||||
const hasData = flattenedData.length > 0
|
||||
|
||||
const onGoBack = useCallback(() => {
|
||||
if (isSearching) {
|
||||
// clear the input and reset the state
|
||||
textInputRef.current?.clear()
|
||||
setSearch('')
|
||||
} else {
|
||||
control.close()
|
||||
}
|
||||
}, [control, isSearching])
|
||||
|
||||
const listHeader = useMemo(() => {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.mb_lg,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
!gtMobile && isWeb && a.gap_md,
|
||||
]}>
|
||||
{/* cover top corners */}
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
{
|
||||
borderBottomLeftRadius: 8,
|
||||
borderBottomRightRadius: 8,
|
||||
},
|
||||
t.atoms.bg,
|
||||
]}
|
||||
/>
|
||||
|
||||
{!gtMobile && isWeb && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
onPress={() => control.close()}
|
||||
label={_(msg`Close GIF dialog`)}>
|
||||
<ButtonIcon icon={Arrow} size="md" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={Search} />
|
||||
<TextField.Input
|
||||
label={_(msg`Search GIFs`)}
|
||||
placeholder={_(msg`Search Tenor`)}
|
||||
onChangeText={text => {
|
||||
setSearch(text)
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||
}}
|
||||
returnKeyType="search"
|
||||
clearButtonMode="while-editing"
|
||||
inputRef={textInputRef}
|
||||
maxLength={50}
|
||||
onKeyPress={({nativeEvent}) => {
|
||||
if (nativeEvent.key === 'Escape') {
|
||||
control.close()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
)
|
||||
}, [gtMobile, t.atoms.bg, _, control])
|
||||
|
||||
return (
|
||||
<>
|
||||
{gtMobile && <Dialog.Close />}
|
||||
<Dialog.InnerFlatList
|
||||
ref={listRef}
|
||||
key={gtMobile ? '3 cols' : '2 cols'}
|
||||
data={flattenedData}
|
||||
renderItem={renderItem}
|
||||
numColumns={gtMobile ? 3 : 2}
|
||||
columnWrapperStyle={a.gap_sm}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
{listHeader}
|
||||
{!hasData && (
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
onRetry={refetch}
|
||||
onGoBack={onGoBack}
|
||||
emptyType="results"
|
||||
sideBorders={false}
|
||||
errorTitle={_(msg`Failed to load GIFs`)}
|
||||
errorMessage={_(msg`There was an issue connecting to Tenor.`)}
|
||||
emptyMessage={
|
||||
isSearching
|
||||
? _(msg`No search results found for "${search}".`)
|
||||
: _(
|
||||
msg`No featured GIFs found. There may be an issue with Tenor.`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
stickyHeaderIndices={[0]}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={4}
|
||||
keyExtractor={(item: Gif) => item.id}
|
||||
// @ts-expect-error web only
|
||||
style={isWeb && {minHeight: '100vh'}}
|
||||
onScrollBeginDrag={() => Keyboard.dismiss()}
|
||||
ListFooterComponent={
|
||||
hasData ? (
|
||||
<ListFooter
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
error={cleanError(error)}
|
||||
onRetry={fetchNextPage}
|
||||
style={{borderTopWidth: 0}}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function GifPreview({
|
||||
gif,
|
||||
onSelectGif,
|
||||
}: {
|
||||
gif: Gif
|
||||
onSelectGif: (gif: Gif) => void
|
||||
}) {
|
||||
const {gtTablet} = useBreakpoints()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
logEvent('composer:gif:select', {})
|
||||
onSelectGif(gif)
|
||||
}, [onSelectGif, gif])
|
||||
|
||||
return (
|
||||
<Button
|
||||
label={_(msg`Select GIF "${gif.title}"`)}
|
||||
style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
|
||||
onPress={onPress}>
|
||||
{({pressed}) => (
|
||||
<Image
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.mb_sm,
|
||||
a.rounded_sm,
|
||||
{aspectRatio: 1, opacity: pressed ? 0.8 : 1},
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
source={{
|
||||
uri: gif.media_formats.tinygif.url,
|
||||
}}
|
||||
contentFit="cover"
|
||||
accessibilityLabel={gif.title}
|
||||
accessibilityHint=""
|
||||
cachePolicy="none"
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogError({details}: {details?: string}) {
|
||||
const {_} = useLingui()
|
||||
const control = Dialog.useDialogContext()
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner style={a.gap_md} label={_(msg`An error occured`)}>
|
||||
<Dialog.Close />
|
||||
<ErrorScreen
|
||||
title={_(msg`Oh no!`)}
|
||||
message={_(
|
||||
msg`There was an unexpected issue in the application. Please let us know if this happened to you!`,
|
||||
)}
|
||||
details={details}
|
||||
/>
|
||||
<Button
|
||||
label={_(msg`Close dialog`)}
|
||||
onPress={() => control.close()}
|
||||
color="primary"
|
||||
size="medium"
|
||||
variant="solid">
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
import {Logotype} from '#/view/icons/Logotype'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function SigninDialog() {
|
||||
const {signinDialogControl: control} = useGlobalDialogsControlContext()
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<SigninDialogInner control={control} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function SigninDialogInner({}: {control: Dialog.DialogOuterProps['control']}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {requestSwitchToAccount} = useLoggedOutViewControls()
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
|
||||
const showSignIn = React.useCallback(() => {
|
||||
closeAllActiveElements()
|
||||
requestSwitchToAccount({requestedAccount: 'none'})
|
||||
}, [requestSwitchToAccount, closeAllActiveElements])
|
||||
|
||||
const showCreateAccount = React.useCallback(() => {
|
||||
closeAllActiveElements()
|
||||
requestSwitchToAccount({requestedAccount: 'new'})
|
||||
}, [requestSwitchToAccount, closeAllActiveElements])
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Sign into Bluesky or create a new account`)}
|
||||
style={[gtMobile ? {width: 'auto', maxWidth: 420} : a.w_full]}>
|
||||
<View style={[!isNative && a.p_2xl]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.gap_sm,
|
||||
a.pb_lg,
|
||||
]}>
|
||||
<Logo width={36} />
|
||||
<View style={{paddingTop: 6}}>
|
||||
<Logotype width={120} fill={t.atoms.text.color} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
a.text_lg,
|
||||
a.text_center,
|
||||
t.atoms.text,
|
||||
a.pb_2xl,
|
||||
a.leading_snug,
|
||||
a.mx_auto,
|
||||
{
|
||||
maxWidth: 300,
|
||||
},
|
||||
]}>
|
||||
<Trans>
|
||||
Sign in or create your account to join the conversation!
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.flex_col, a.gap_md]}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="large"
|
||||
onPress={showCreateAccount}
|
||||
label={_(msg`Create an account`)}>
|
||||
<ButtonText>
|
||||
<Trans>Create an account</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="large"
|
||||
onPress={showSignIn}
|
||||
label={_(msg`Sign in`)}>
|
||||
<ButtonText>
|
||||
<Trans>Sign in</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{isNative && <View style={{height: 10}} />}
|
||||
</View>
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {useLingui} from '@lingui/react'
|
||||
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
|
||||
import {type SessionAccount, useSession} from '#/state/session'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {atoms as a} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {AccountList} from '../AccountList'
|
||||
@@ -21,23 +20,25 @@ export function SwitchAccountDialog({
|
||||
const {currentAccount} = useSession()
|
||||
const {onPressSwitchAccount} = useAccountSwitcher()
|
||||
const {setShowLoggedOut} = useLoggedOutViewControls()
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
|
||||
const onSelectAccount = useCallback(
|
||||
(account: SessionAccount) => {
|
||||
if (account.did === currentAccount?.did) {
|
||||
control.close()
|
||||
if (account.did !== currentAccount?.did) {
|
||||
control.close(() => {
|
||||
onPressSwitchAccount(account, 'SwitchAccount')
|
||||
})
|
||||
} else {
|
||||
onPressSwitchAccount(account, 'SwitchAccount')
|
||||
control.close()
|
||||
}
|
||||
},
|
||||
[currentAccount, control, onPressSwitchAccount],
|
||||
)
|
||||
|
||||
const onPressAddAccount = useCallback(() => {
|
||||
setShowLoggedOut(true)
|
||||
closeAllActiveElements()
|
||||
}, [setShowLoggedOut, closeAllActiveElements])
|
||||
control.close(() => {
|
||||
setShowLoggedOut(true)
|
||||
})
|
||||
}, [setShowLoggedOut, control])
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
|
||||
@@ -41,7 +41,7 @@ export function HostingProvider({
|
||||
onSelect={onSelectServiceUrl}
|
||||
/>
|
||||
<Button
|
||||
testID="loginSelectServiceButton"
|
||||
testID="selectServiceButton"
|
||||
label={toNiceDomain(serviceUrl)}
|
||||
accessibilityHint={_(msg`Press to change hosting provider`)}
|
||||
variant="solid"
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {LogEvents} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
|
||||
import {useRequireAuth} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
|
||||
export function useFollowMethods({
|
||||
profile,
|
||||
logContext,
|
||||
}: {
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
|
||||
logContext: LogEvents['profile:follow']['logContext'] &
|
||||
LogEvents['profile:unfollow']['logContext']
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const requireAuth = useRequireAuth()
|
||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
|
||||
profile,
|
||||
logContext,
|
||||
)
|
||||
|
||||
const follow = React.useCallback(() => {
|
||||
requireAuth(async () => {
|
||||
try {
|
||||
await queueFollow()
|
||||
} catch (e: any) {
|
||||
logger.error(`useFollowMethods: failed to follow`, {message: String(e)})
|
||||
if (e?.name !== 'AbortError') {
|
||||
Toast.show(_(msg`An issue occurred, please try again.`))
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [_, queueFollow, requireAuth])
|
||||
|
||||
const unfollow = React.useCallback(() => {
|
||||
requireAuth(async () => {
|
||||
try {
|
||||
await queueUnfollow()
|
||||
} catch (e: any) {
|
||||
logger.error(`useFollowMethods: failed to unfollow`, {
|
||||
message: String(e),
|
||||
})
|
||||
if (e?.name !== 'AbortError') {
|
||||
Toast.show(_(msg`An issue occurred, please try again.`))
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [_, queueUnfollow, requireAuth])
|
||||
|
||||
return {
|
||||
follow,
|
||||
unfollow,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react'
|
||||
import {RichText as RichTextAPI} from '@atproto/api'
|
||||
|
||||
import {useAgent} from '#/state/session'
|
||||
|
||||
export function useRichText(text: string): [RichTextAPI, boolean] {
|
||||
const [prevText, setPrevText] = React.useState(text)
|
||||
const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text}))
|
||||
const [resolvedRT, setResolvedRT] = React.useState<RichTextAPI | null>(null)
|
||||
const {getAgent} = useAgent()
|
||||
if (text !== prevText) {
|
||||
setPrevText(text)
|
||||
setRawRT(new RichTextAPI({text}))
|
||||
setResolvedRT(null)
|
||||
// This will queue an immediate re-render
|
||||
}
|
||||
React.useEffect(() => {
|
||||
let ignore = false
|
||||
async function resolveRTFacets() {
|
||||
// new each time
|
||||
const resolvedRT = new RichTextAPI({text})
|
||||
await resolvedRT.detectFacets(getAgent())
|
||||
if (!ignore) {
|
||||
setResolvedRT(resolvedRT)
|
||||
}
|
||||
}
|
||||
resolveRTFacets()
|
||||
return () => {
|
||||
ignore = true
|
||||
}
|
||||
}, [text, getAgent])
|
||||
const isResolving = resolvedRT === null
|
||||
return [resolvedRT ?? rawRT, isResolving]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
|
||||
export function useThrottledValue<T>(value: T, time?: number) {
|
||||
const pendingValueRef = useRef(value)
|
||||
const [throttledValue, setThrottledValue] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
pendingValueRef.current = value
|
||||
}, [value])
|
||||
|
||||
const handleTick = useNonReactiveCallback(() => {
|
||||
if (pendingValueRef.current !== throttledValue) {
|
||||
setThrottledValue(pendingValueRef.current)
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(handleTick, time)
|
||||
return () => {
|
||||
clearInterval(id)
|
||||
}
|
||||
}, [handleTick, time])
|
||||
|
||||
return throttledValue
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Alien_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M5 11a7 7 0 0 1 14 0c0 2.625-1.547 5.138-3.354 7.066a17.23 17.23 0 0 1-2.55 2.242 8.246 8.246 0 0 1-.924.577 2.904 2.904 0 0 1-.172.083 2.904 2.904 0 0 1-.172-.083 8.246 8.246 0 0 1-.923-.577 17.227 17.227 0 0 1-2.55-2.242C6.547 16.138 5 13.625 5 11Zm6.882 10.012Zm.232-.001h-.003a.047.047 0 0 0 .007.001l-.004-.001ZM12 2a9 9 0 0 0-9 9c0 3.375 1.953 6.362 3.895 8.434a19.216 19.216 0 0 0 2.856 2.508c.425.3.82.545 1.159.72.168.087.337.164.498.222.14.05.356.116.592.116s.451-.066.592-.116c.16-.058.33-.135.498-.222.339-.175.734-.42 1.159-.72.85-.6 1.87-1.457 2.856-2.508C19.047 17.362 21 14.375 21 11a9 9 0 0 0-9-9ZM7.38 9.927c2.774-.094 3.459 1.31 3.591 3.19a.89.89 0 0 1-.855.956c-2.774.094-3.458-1.31-3.59-3.19a.89.89 0 0 1 .854-.956Zm9.236 0c-2.774-.094-3.458 1.31-3.59 3.19a.89.89 0 0 0 .854.956c2.774.094 3.459-1.31 3.591-3.19a.89.89 0 0 0-.855-.956Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Apple_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M8.148 2.034a1 1 0 0 1 1.35-.419c1.054.556 1.873 1.266 2.46 2.174.369.57.63 1.197.807 1.873 1.143-.349 2.194-.46 3.15-.342a5.122 5.122 0 0 1 3.134 1.556c1.564 1.63 2.086 4.183 1.922 6.58-.166 2.414-1.043 4.938-2.607 6.619-.792.851-1.78 1.505-2.95 1.782-1.063.251-2.213.176-3.415-.262-1.202.438-2.351.513-3.414.262-1.17-.277-2.158-.93-2.95-1.782-1.563-1.682-2.44-4.205-2.606-6.62-.164-2.396.358-4.949 1.921-6.579A5.121 5.121 0 0 1 8.085 5.32c.777-.095 1.617-.04 2.518.172a4.011 4.011 0 0 0-.325-.618c-.372-.576-.912-1.068-1.712-1.49a1 1 0 0 1-.418-1.35Zm.897 17.877c.71.167 1.557.117 2.562-.31a1 1 0 0 1 .784 0c1.005.428 1.853.477 2.563.31.715-.17 1.37-.579 1.946-1.198 1.171-1.26 1.932-3.303 2.076-5.394.144-2.109-.353-3.998-1.37-5.059a3.124 3.124 0 0 0-1.936-.955c-.83-.102-1.912.043-3.282.621a1 1 0 0 1-.778 0c-1.37-.578-2.45-.723-3.281-.62-.815.1-1.445.443-1.935.954-1.017 1.06-1.514 2.95-1.37 5.059.144 2.09.905 4.135 2.076 5.394.575.62 1.23 1.029 1.945 1.198Z',
|
||||
})
|
||||
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||
export const ArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M8 6a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v9a1 1 0 1 1-2 0V8.414l-9.793 9.793a1 1 0 0 1-1.414-1.414L15.586 7H9a1 1 0 0 1-1-1Z',
|
||||
})
|
||||
|
||||
export const ArrowLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3 12a1 1 0 0 1 .293-.707l6-6a1 1 0 0 1 1.414 1.414L6.414 11H20a1 1 0 1 1 0 2H6.414l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6A1 1 0 0 1 3 12Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Atom_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M6.17 5.004c-.553-.029-.814.107-.937.23-.122.122-.258.383-.23.936.03.552.222 1.28.611 2.15.282.628.655 1.304 1.112 2.005a28.26 28.26 0 0 1 1.72-1.88 28.258 28.258 0 0 1 1.88-1.719 14.886 14.886 0 0 0-2.007-1.112c-.868-.39-1.597-.581-2.15-.61Zm5.83.44c-.985-.688-1.953-1.247-2.863-1.655-.998-.447-1.978-.736-2.863-.782-.885-.047-1.791.148-2.455.812-.664.664-.859 1.57-.812 2.455.046.885.335 1.865.782 2.863.408.91.967 1.878 1.655 2.863-.688.985-1.247 1.953-1.655 2.863-.447.998-.736 1.978-.782 2.863-.047.885.148 1.791.812 2.455.664.663 1.57.859 2.455.812.885-.046 1.865-.335 2.863-.782.91-.408 1.878-.967 2.863-1.655.985.688 1.952 1.247 2.863 1.655.998.447 1.978.736 2.863.782.885.047 1.791-.148 2.455-.812.663-.664.859-1.57.812-2.455-.046-.885-.335-1.865-.782-2.863-.408-.91-.967-1.878-1.655-2.863.688-.985 1.247-1.952 1.655-2.863.447-.998.736-1.978.782-2.863.047-.885-.148-1.791-.812-2.455-.664-.664-1.57-.859-2.455-.812-.885.046-1.865.335-2.863.782-.91.408-1.878.967-2.863 1.655Zm0 2.497A25.9 25.9 0 0 0 9.86 9.86 25.899 25.899 0 0 0 7.94 12c.569.711 1.211 1.431 1.92 2.14.709.709 1.429 1.351 2.14 1.92a25.925 25.925 0 0 0 2.14-1.92A25.925 25.925 0 0 0 16.06 12a25.921 25.921 0 0 0-1.92-2.14A25.904 25.904 0 0 0 12 7.94Zm5.274 2.384a28.232 28.232 0 0 0-1.72-1.88 28.27 28.27 0 0 0-1.88-1.719 14.89 14.89 0 0 1 2.007-1.112c.868-.39 1.597-.581 2.15-.61.552-.029.813.107.936.23.123.122.258.383.23.936-.03.552-.222 1.28-.611 2.15a14.883 14.883 0 0 1-1.112 2.005Zm0 3.35a28.24 28.24 0 0 1-1.72 1.88 28.24 28.24 0 0 1-1.88 1.719c.702.457 1.378.83 2.007 1.112.868.39 1.597.581 2.15.61.552.03.813-.106.936-.23.123-.122.258-.383.23-.935-.03-.553-.222-1.282-.611-2.15a14.888 14.888 0 0 0-1.112-2.006Zm-6.949 3.599a28.23 28.23 0 0 1-1.88-1.72 28.27 28.27 0 0 1-1.719-1.88 14.89 14.89 0 0 0-1.112 2.007c-.39.868-.581 1.597-.61 2.15-.029.552.107.813.23.936.122.123.383.258.936.23.552-.03 1.28-.222 2.15-.611a14.884 14.884 0 0 0 2.005-1.112Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Celebrate_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M13.832 1.014a1 1 0 0 1 1.154.818L14 2l.986-.168v.003l.001.005.002.014.007.045.021.156c.017.13.035.312.048.527.025.42.028 1.01-.082 1.6-.127.69-.447 1.31-.701 1.726a7.107 7.107 0 0 1-.498.712l-.01.014-.004.005-.002.001v.002L13 6l.767.642a1 1 0 0 1-1.535-1.282l.002-.003.017-.02a5.13 5.13 0 0 0 .324-.47c.198-.325.378-.705.442-1.049.068-.37.071-.78.051-1.12a6.268 6.268 0 0 0-.05-.504l-.004-.025m.818-1.155a1 1 0 0 0-.818 1.155Zm5.257 1.545a1 1 0 0 1 .602 1.28l-.45 1.25a1 1 0 0 1-1.882-.678l.45-1.25a1 1 0 0 1 1.28-.602ZM6.524 7.136a2 2 0 0 1 3.294-.732l7.778 7.778a2 2 0 0 1-.732 3.294L4.653 21.91c-1.596.579-3.142-.967-2.563-2.563L6.524 7.136Zm9.658 8.46L8.404 7.818 3.97 20.03l12.212-4.434Zm5.712-8.543a1 1 0 0 1-.447 1.341l-1 .5a1 1 0 1 1-.894-1.788l1-.5a1 1 0 0 1 1.341.447Zm-4.687-.26a1 1 0 0 1 0 1.414l-1 1a1 1 0 1 1-1.414-1.414l1-1a1 1 0 0 1 1.414 0Zm-.206 4.165A1 1 0 0 1 18.042 10L18 11l.042-1 .003.001h.014l.035.003.117.008a7.693 7.693 0 0 1 1.594.306c.423.135.861.352 1.168.516a11.873 11.873 0 0 1 .508.288l.032.02.01.006.004.002L21 12l.527-.85a1 1 0 0 1-1.054 1.7l-.005-.003-.023-.014a7.477 7.477 0 0 0-.415-.236 5.497 5.497 0 0 0-.835-.374A5.684 5.684 0 0 0 17.973 12l-.015-.002h-.002a1 1 0 0 1-.955-1.041Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const CodeBrackets_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M14.242 3.03a1 1 0 0 1 .728 1.213l-4 16a1 1 0 1 1-1.94-.485l4-16a1 1 0 0 1 1.213-.728ZM6.707 7.293a1 1 0 0 1 0 1.414L3.414 12l3.293 3.293a1 1 0 1 1-1.414 1.414l-4-4a1 1 0 0 1 0-1.414l4-4a1 1 0 0 1 1.414 0Zm10.586 0a1 1 0 0 1 1.414 0l4 4a1 1 0 0 1 0 1.414l-4 4a1 1 0 1 1-1.414-1.414L20.586 12l-3.293-3.293a1 1 0 0 1 0-1.414Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Coffee_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M7 2a1 1 0 0 1 1 1v2a1 1 0 0 1-2 0V3a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0V3a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v2a1 1 0 1 1-2 0V3a1 1 0 0 1 1-1ZM4 9a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2h.5a3.5 3.5 0 1 1 0 7H18v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V9Zm12 0H6v11h10V9Zm2 5h.5a1.5 1.5 0 0 0 0-3H18v3Z',
|
||||
})
|
||||
@@ -3,3 +3,11 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||
export const EmojiSad_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M6.343 6.343a8 8 0 1 1 11.314 11.314A8 8 0 0 1 6.343 6.343ZM19.071 4.93c-3.905-3.905-10.237-3.905-14.142 0-3.905 3.905-3.905 10.237 0 14.142 3.905 3.905 10.237 3.905 14.142 0 3.905-3.905 3.905-10.237 0-14.142Zm-3.537 9.535a5 5 0 0 0-7.07 0 1 1 0 1 0 1.413 1.415 3 3 0 0 1 4.243 0 1 1 0 0 0 1.414-1.415ZM16 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5S16 8.672 16 9.5ZM9.25 11c.69 0 1.25-.672 1.25-1.5S9.94 8 9.25 8 8 8.672 8 9.5 8.56 11 9.25 11Z',
|
||||
})
|
||||
|
||||
export const EmojiArc_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm8-5a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0V8a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0V8a1 1 0 0 1 1-1Zm-5.894 7.803a1 1 0 0 1 1.341-.447c1.719.859 3.387.859 5.106 0a1 1 0 1 1 .894 1.788c-2.281 1.141-4.613 1.141-6.894 0a1 1 0 0 1-.447-1.341Z',
|
||||
})
|
||||
|
||||
export const EmojiHeartEyes_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M4 12a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm8-10C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2ZM9.351 12.13c1.898-1.507 2.176-2.95 1.613-3.83a1.524 1.524 0 0 0-1.225-.707 1.562 1.562 0 0 0-1.218.53 1.561 1.561 0 0 0-1.326-.082c-.456.186-.8.588-.91 1.083-.227 1.02.527 2.282 2.826 3.048a.256.256 0 0 0 .24-.043Zm5.538.042c2.299-.766 3.053-2.027 2.826-3.048a1.524 1.524 0 0 0-.91-1.082 1.561 1.561 0 0 0-1.326.081 1.562 1.562 0 0 0-1.217-.53 1.524 1.524 0 0 0-1.226.706c-.563.881-.285 2.325 1.613 3.83.068.054.158.07.24.043Zm1.072 2.38a4 4 0 0 1-7.924 0c-.04-.293.218-.525.514-.499 2.309.206 4.587.206 6.896 0 .296-.026.555.206.514.5Z',
|
||||
})
|
||||
|
||||
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||
export const Envelope_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M4.568 4h14.864c.252 0 .498 0 .706.017.229.019.499.063.77.201a2 2 0 0 1 .874.874c.138.271.182.541.201.77.017.208.017.454.017.706v10.864c0 .252 0 .498-.017.706a2.022 2.022 0 0 1-.201.77 2 2 0 0 1-.874.874 2.022 2.022 0 0 1-.77.201c-.208.017-.454.017-.706.017H4.568c-.252 0-.498 0-.706-.017a2.022 2.022 0 0 1-.77-.201 2 2 0 0 1-.874-.874 2.022 2.022 0 0 1-.201-.77C2 17.93 2 17.684 2 17.432V6.568c0-.252 0-.498.017-.706.019-.229.063-.499.201-.77a2 2 0 0 1 .874-.874c.271-.138.541-.182.77-.201C4.07 4 4.316 4 4.568 4Zm.456 2L12 11.708 18.976 6H5.024ZM20 7.747l-6.733 5.509a2 2 0 0 1-2.534 0L4 7.746V17.4a8.187 8.187 0 0 0 .011.589h.014c.116.01.278.011.575.011h14.8a8.207 8.207 0 0 0 .589-.012v-.013c.01-.116.011-.279.011-.575V7.747Z',
|
||||
})
|
||||
|
||||
export const Envelope_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 11.708 2.654 4.06A.998.998 0 0 1 3 4h18c.122 0 .238.022.346.061L12 11.708ZM2 19V6.11l9.367 7.664a1 1 0 0 0 1.266 0L22 6.11V19a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1Z',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Explosion_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 2a1 1 0 0 1 .889.542l1.679 3.259 3.491-1.117a1 1 0 0 1 1.257 1.257L18.2 9.432l3.259 1.679a1 1 0 0 1 0 1.778l-3.124 1.61 2.609 5.098a1 1 0 0 1-1.346 1.346l-5.098-2.61-1.61 3.125a1 1 0 0 1-1.778 0l-1.679-3.259-3.491 1.117a1 1 0 0 1-1.257-1.257L5.8 14.568l-3.259-1.679a1 1 0 0 1 0-1.778l3.124-1.61-2.609-5.098a1 1 0 0 1 1.346-1.346l5.098 2.61-.455.89.455-.89 1.61-3.125A1 1 0 0 1 12 2Zm0 3.183-.72 1.4a2 2 0 0 1-2.69.864L6.248 6.248 7.447 8.59a2 2 0 0 1-.865 2.69L5.183 12l1.534.79a2 2 0 0 1 .989 2.387L7.18 16.82l1.643-.526a2 2 0 0 1 2.387.99l.79 1.533.72-1.4a2 2 0 0 1 2.69-.864l2.342 1.199-1.199-2.342a2 2 0 0 1 .864-2.69l1.4-.72-1.534-.79a2 2 0 0 1-.989-2.387l.526-1.643-1.643.526a2 2 0 0 1-2.387-.99L12 5.184Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const GameController_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M1 8a3 3 0 0 1 3-3h16a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V8Zm3-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1H4Zm4 2a1 1 0 0 1 1 1v1h1a1 1 0 1 1 0 2H9v1a1 1 0 1 1-2 0v-1H6a1 1 0 1 1 0-2h1v-1a1 1 0 0 1 1-1Zm5.5 4.5a1.25 1.25 0 1 0 2.5 0 1.25 1.25 0 0 0-2.5 0Zm3-3a1.25 1.25 0 1 0 2.5 0 1.25 1.25 0 0 0-2.5 0Z',
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Gif_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3 4a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H3Zm1 14V6h16v12H4Zm2-5.713c0 1.54.92 2.463 2.48 2.463 1.434 0 2.353-.807 2.353-2.06v-.166c0-.578-.267-.834-.884-.834h-.806c-.416 0-.632.182-.632.535 0 .357.22.55.632.55h.146v.063c0 .36-.299.609-.735.609-.597 0-.904-.4-.904-1.168v-.52c0-.775.307-1.155.951-1.155.325 0 .538.152.746.3.089.064.176.127.272.177a.82.82 0 0 0 .409.108c.385 0 .656-.263.656-.636 0-.353-.26-.679-.664-.915-.409-.24-.96-.388-1.548-.388C6.955 9.25 6 10.2 6 11.67v.617Zm6.358 2.385c.526 0 .813-.31.813-.872v-3.627c0-.558-.295-.873-.825-.873s-.825.31-.825.873V13.8c0 .558.302.872.837.872Zm3.367-.872c0 .566-.283.872-.802.872-.538 0-.848-.318-.848-.872v-3.635c0-.512.314-.826.82-.826h2.496c.35 0 .609.272.609.64 0 .369-.26.629-.609.629h-1.666v.973h1.47c.365 0 .608.248.608.613 0 .36-.247.613-.608.613h-1.47v.993Z',
|
||||
})
|
||||
|
||||
export const GifSquare_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M4 3a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H4Zm1 16V5h14v14H5Zm10.725-5.2c0 .566-.283.872-.802.872-.538 0-.848-.318-.848-.872v-3.635c0-.512.314-.826.82-.826h2.496c.35 0 .609.272.609.64 0 .369-.26.629-.609.629h-1.666v.973h1.47c.365 0 .608.248.608.613 0 .36-.247.613-.608.613h-1.47v.993Zm-3.367.872c.526 0 .813-.31.813-.872v-3.627c0-.558-.295-.873-.825-.873s-.825.31-.825.873V13.8c0 .558.302.872.837.872Zm-3.879.078C6.92 14.75 6 13.827 6 12.287v-.617c0-1.47.955-2.42 2.472-2.42.589 0 1.139.147 1.548.388.404.236.664.562.664.915 0 .373-.271.636-.656.636a.82.82 0 0 1-.41-.108 2.34 2.34 0 0 1-.271-.177c-.208-.148-.421-.3-.746-.3-.644 0-.95.38-.95 1.155v.52c0 .768.306 1.168.903 1.168.436 0 .735-.248.735-.61v-.061h-.146c-.412 0-.632-.194-.632-.551 0-.353.216-.535.632-.535h.806c.617 0 .884.256.884.834v.166c0 1.253-.92 2.06-2.354 2.06Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Image_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm16 0H5v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5Zm0 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Lab_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M13.5 4a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM10 5a1 1 0 1 0 0-2 1 1 0 0 0 0 2ZM8 6a1 1 0 0 0 0 2v2.64c-.212.25-.45.515-.711.8l-.129.142c-.312.342-.649.711-.974 1.092-.731.857-1.488 1.866-1.89 2.99A4.845 4.845 0 0 0 4 17.297 4.702 4.702 0 0 0 8.702 22h6.596A4.702 4.702 0 0 0 20 17.298c0-.575-.114-1.122-.297-1.634-.401-1.124-1.157-2.133-1.89-2.99-.324-.38-.66-.75-.973-1.092l-.129-.141c-.26-.286-.5-.55-.711-.8V8a1 1 0 1 0 0-2H8Zm2 5.35V8h4v3.35l.22.275c.306.383.661.777 1.013 1.163l.13.143c.315.345.628.688.93 1.042.372.435.704.861.974 1.28l-.159.025c-.845.13-1.838.242-2.581.222-.842-.022-1.475-.217-2.227-.454l-.027-.008c-.746-.235-1.61-.507-2.746-.538-.743-.02-1.617.064-2.38.165.173-.228.36-.459.56-.692.302-.354.615-.697.93-1.042l.13-.143c.352-.386.707-.78 1.014-1.163L10 11.35Zm7.41 5.905c.21-.032.407-.064.586-.095A2.702 2.702 0 0 1 15.298 20H8.702A2.702 2.702 0 0 1 6 17.298c0-.142.013-.286.039-.434.236-.043.53-.093.853-.142.845-.13 1.837-.242 2.581-.222.842.022 1.475.217 2.227.454l.027.008c.746.235 1.61.507 2.746.538.931.024 2.07-.113 2.937-.245Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Leaf_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3 4a1 1 0 0 1 1-1h1a8.003 8.003 0 0 1 7.75 6.006A7.985 7.985 0 0 1 19 6h1a1 1 0 0 1 1 1v1a8 8 0 0 1-8 8v4a1 1 0 1 1-2 0v-7a8 8 0 0 1-8-8V4Zm2 1a6 6 0 0 1 6 6 6 6 0 0 1-6-6Zm8 9a6 6 0 0 1 6-6 6 6 0 0 1-6 6Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const MusicNote_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M18.423 2.428a2 2 0 0 1 2.575 1.916V15.5c0 2.096-1.97 3.5-4 3.5-2.03 0-4-1.404-4-3.5s1.97-3.5 4-3.5c.7 0 1.392.167 2 .471V4.344l-8 2.4V18.5c0 2.096-1.97 3.5-4 3.5-2.03 0-4-1.404-4-3.5s1.97-3.5 4-3.5c.7 0 1.393.167 2 .471V6.744a2 2 0 0 1 1.425-1.916l8-2.4ZM8.998 18.5c0-.666-.717-1.5-2-1.5s-2 .834-2 1.5c0 .665.717 1.5 2 1.5s2-.835 2-1.5Zm10-3c0-.665-.717-1.5-2-1.5s-2 .835-2 1.5.717 1.5 2 1.5 2-.835 2-1.5Z',
|
||||
})
|
||||
@@ -1,5 +1,9 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Pencil_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M13.586 1.5a2 2 0 0 1 2.828 0L19.5 4.586a2 2 0 0 1 0 2.828l-13 13A2 2 0 0 1 5.086 21H1a1 1 0 0 1-1-1v-4.086A2 2 0 0 1 .586 14.5l13-13ZM15 2.914l-13 13V19h3.086l13-13L15 2.914ZM11 20a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Z',
|
||||
})
|
||||
|
||||
export const PencilLine_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M15.586 2.5a2 2 0 0 1 2.828 0L21.5 5.586a2 2 0 0 1 0 2.828l-13 13A2 2 0 0 1 7.086 22H3a1 1 0 0 1-1-1v-4.086a2 2 0 0 1 .586-1.414l13-13ZM17 3.914l-13 13V20h3.086l13-13L17 3.914ZM13 21a1 1 0 0 1 1-1h7a1 1 0 1 1 0 2h-7a1 1 0 0 1-1-1Z',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const PiggyBank_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M6.5 4.999Zm0 0a.054.054 0 0 1-.016.024.196.196 0 0 1-.152.043c.057.01.113.02.168.032V7.5a1 1 0 0 1-.33.742c-.543.49-.917 1.176-1.23 2.036a1 1 0 0 1-.94.657H3V14h1a1 1 0 0 1 .866.5c.436.754.879 1.195 1.637 1.636A1 1 0 0 1 7 17v2h2a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2h2v-2.537a1 1 0 0 1 .332-.744A4.978 4.978 0 0 0 19 11.999 5 5 0 0 0 14 7H9.474a1 1 0 0 1-.893-.55v-.001l-.001-.002-.005-.008L9.474 6l-.9.438h.001v.002l.002.001.001.003.002.003v.003a2.62 2.62 0 0 0-.443-.538c-.319-.298-.839-.648-1.637-.814V5Zm2.082 1.452ZM9.5 4.447c.211.196.379.387.508.553H14a7 7 0 0 1 6.72 8.96 1.003 1.003 0 0 0 1.146-1.46 1 1 0 1 1 1.731-1 3 3 0 0 1-3.714 4.286A7.074 7.074 0 0 1 19 16.888V19a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2h-2a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-1.45A5.783 5.783 0 0 1 3.448 16H3a2 2 0 0 1-2-2v-3.065a2 2 0 0 1 2-2h.324c.286-.649.657-1.291 1.176-1.852V5c0-1.047.89-2.12 2.161-1.907 1.33.223 2.248.805 2.839 1.354ZM8.25 12a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Pizza_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M18.438 3.253a2 2 0 0 1 2.309 2.31L18.243 20.17c-.186 1.083-1.244 1.868-2.387 1.588a18.525 18.525 0 0 1-8.702-4.912 18.525 18.525 0 0 1-4.912-8.702C1.962 7 2.747 5.943 3.83 5.757l14.608-2.504Zm-1.474 2.282a3 3 0 0 1-5.914 1.014l-3.368.577a13.022 13.022 0 0 0 3.376 5.816c.35.35.713.675 1.09.976a4.002 4.002 0 0 1 5.571-2.53l1.056-6.164-1.81.311Zm.388 7.991a2 2 0 0 0-3.346 1.634c.916.504 1.879.89 2.868 1.158l.478-2.792Zm-.817 4.77a15 15 0 0 1-6.891-3.94 15.02 15.02 0 0 1-3.94-6.89l-1.505.257a16.525 16.525 0 0 0 4.369 7.709 16.525 16.525 0 0 0 7.709 4.37l.258-1.505ZM13.022 6.212a1 1 0 0 0 1.97-.338l-1.97.338Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Poop_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 4c0-1.012.894-2.187 2.237-1.846a5.002 5.002 0 0 1 3.218 7.119 4.001 4.001 0 0 1 2.345 4.976A3.501 3.501 0 0 1 18.5 21h-13a3.5 3.5 0 0 1-1.3-6.75 4 4 0 0 1 2.052-4.848A4 4 0 0 1 10 4h2.001Zm-4.187 7.009A2 2 0 0 0 6 13c0 .366.098.706.271 1H12a1 1 0 1 1 0 2H5.5a1.5 1.5 0 0 0 0 3h13a1.5 1.5 0 1 0 0-3H17a1 1 0 1 1 0-2h.729c.173-.294.271-.634.271-1a2 2 0 0 0-2-2h-2a1 1 0 1 1 0-2h1.236a3.002 3.002 0 0 0-1.243-4.832A2 2 0 0 1 12 6h-2a2 2 0 0 0-1.728 3.009H9a1 1 0 0 1 0 2H7.813Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Rose_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M10.75 2.469a2 2 0 0 1 2.5 0l1.42 1.136 1.44-.576A1.378 1.378 0 0 1 18 4.309V8a6.002 6.002 0 0 1-5 5.917v1.69a6.12 6.12 0 0 1 4.224-1.606A1.796 1.796 0 0 1 19 15.857a6.143 6.143 0 0 1-6 6.141V22h-2v-.002a6.143 6.143 0 0 1-6-6.222A1.796 1.796 0 0 1 6.858 14 6.12 6.12 0 0 1 11 15.607v-1.69A6.002 6.002 0 0 1 6 8V4.308c0-.975.985-1.641 1.89-1.28l1.44.577 1.42-1.136ZM7.004 16.003a4.143 4.143 0 0 1 3.995 3.994 4.143 4.143 0 0 1-3.995-3.994Zm9.994 0a4.143 4.143 0 0 1-3.995 3.994 4.143 4.143 0 0 1 3.995-3.994ZM13.42 5.167 12 4.03l-1.42 1.136a2 2 0 0 1-1.992.295L8 5.227V8a4 4 0 0 0 8 0V5.227l-.588.235a2 2 0 0 1-1.992-.295Z',
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const SettingsSliderVertical_Stroke2_Corner0_Rounded =
|
||||
createSinglePathSVG({
|
||||
path: 'M7 3a1 1 0 0 1 1 1v1.126a4 4 0 0 1 0 7.748V20a1 1 0 1 1-2 0v-7.126a4 4 0 0 1 0-7.748V4a1 1 0 0 1 1-1Zm10 0a1 1 0 0 1 1 1v9.126a4 4 0 1 1-2 0V4a1 1 0 0 1 1-1ZM7 7a2 2 0 1 0 0 4 2 2 0 1 0 0-4Zm10 8a2 2 0 1 0 0 4 2 2 0 1 0 0-4Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Shaka_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M2.275 4.312A1 1 0 0 1 3 4h.55a4 4 0 0 1 3.656 2.375l.616 1.388.47-.47 1.001-1a2.414 2.414 0 0 1 3.948.807 2.41 2.41 0 0 1 2.5 1.5 2.41 2.41 0 0 1 2.234 1.01l.805-.804c.95-.95 2.49-.95 3.44 0 .93.93.958 2.439.035 3.395-1.228 1.271-3.406 3.497-5.078 5.035a94.045 94.045 0 0 1-2.82 2.467c-2.481 2.1-6.156 1.748-8.264-.684L3.87 16.452a6 6 0 0 1-1.458-3.614l-.41-7.785a1 1 0 0 1 .274-.741Zm14.022 6.977-1.004 1.004-.007.006-.493.494a.414.414 0 1 1-.586-.586l1.5-1.5a.414.414 0 0 1 .59.582Zm-4.18 1.595a2.414 2.414 0 0 0 4.09 1.323l1.5-1.5.01-.01 2.477-2.477c.169-.169.443-.169.612 0a.42.42 0 0 1 .01.591c-1.228 1.272-3.37 3.46-4.993 4.953a92.183 92.183 0 0 1-2.757 2.412c-1.62 1.371-4.05 1.162-5.461-.467L5.38 15.143a4 4 0 0 1-.972-2.41l-.35-6.668a2 2 0 0 1 1.32 1.123l1.208 2.718a1 1 0 0 0 1.42.456A2.421 2.421 0 0 0 10.26 11.4c.118.294.296.57.534.807.373.373.839.599 1.323.677Zm.676-2.091 1-1a.414.414 0 1 0-.586-.586l-1 1a.414.414 0 1 0 .586.586Zm-2-2 .5-.5a.414.414 0 1 0-.586-.586l-1 1a.414.414 0 0 0 .586.586l.5-.5Z',
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import Svg, {Path} from 'react-native-svg'
|
||||
|
||||
import {useCommonSVGProps, Props} from '#/components/icons/common'
|
||||
import {Props, useCommonSVGProps} from '#/components/icons/common'
|
||||
|
||||
export const IconTemplate_Stroke2_Corner0_Rounded = React.forwardRef(
|
||||
function LogoImpl(props: Props, ref) {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const UFO_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M7.03 6.443c-.889.17-1.707.385-2.431.638-.966.338-1.82.764-2.45 1.286C1.523 8.884 1 9.6 1 10.5c0 1.321 1.098 2.24 2.203 2.821.854.45 1.928.817 3.142 1.093l-3.19 5.052a1 1 0 1 0 1.69 1.068l5.767-9.13a8.502 8.502 0 0 1 2.776 0l5.766 9.13a1 1 0 0 0 1.692-1.068l-3.191-5.052c1.214-.276 2.288-.644 3.142-1.093 1.105-.58 2.203-1.5 2.203-2.821 0-.9-.524-1.616-1.148-2.133-.631-.522-1.485-.948-2.45-1.286a17.147 17.147 0 0 0-2.433-.638 5 5 0 0 0-9.938 0Zm2.095-.301A28.736 28.736 0 0 1 12 6c.992 0 1.957.049 2.875.142a3.001 3.001 0 0 0-5.75 0Zm7.389 6.466c1.403-.262 2.55-.635 3.352-1.057C20.87 11.023 21 10.611 21 10.5c0-.066-.036-.271-.423-.592-.381-.315-.993-.644-1.836-.939C17.063 8.382 14.68 8 12 8c-2.68 0-5.063.382-6.74.969-.844.295-1.456.624-1.837.94-.387.32-.423.525-.423.591 0 .11.13.523 1.134 1.051.802.422 1.95.795 3.352 1.057l1.441-2.282a1.952 1.952 0 0 1 1.328-.89 10.51 10.51 0 0 1 3.49 0c.57.094 1.042.437 1.328.89l1.44 2.282Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Zap_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'm9.368 4-4 8h2.944a1.5 1.5 0 0 1 1.427 1.963l-1.65 5.087L19.374 9h-3.49a1.5 1.5 0 0 1-1.287-2.272L16.234 4H9.368Zm-1.65-1.17A1.5 1.5 0 0 1 9.058 2h8.058a1.5 1.5 0 0 1 1.286 2.272L16.766 7h3.92c1.38 0 2.028 1.703.998 2.62L8.042 21.77c-1.142 1.018-2.896-.127-2.424-1.583L7.624 14H4.56a1.5 1.5 0 0 1-1.342-2.17l4.5-9Z',
|
||||
})
|
||||
@@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react'
|
||||
import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {getAgent} from '#/state/session'
|
||||
import {useAgent} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
@@ -173,6 +173,7 @@ function AppealForm({
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const [details, setDetails] = React.useState('')
|
||||
const isAccountReport = 'did' in subject
|
||||
const {getAgent} = useAgent()
|
||||
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import React, {ComponentProps} from 'react'
|
||||
import {StyleSheet, Pressable, View, ViewStyle, StyleProp} from 'react-native'
|
||||
import {ModerationUI} from '@atproto/api'
|
||||
import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||
import {AppBskyActorDefs, ModerationUI} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
import {addStyle} from 'lib/styles'
|
||||
|
||||
import {useTheme, atoms as a} from '#/alf'
|
||||
import {precacheProfile} from 'state/queries/profile'
|
||||
// import {Link} from '#/components/Link' TODO this imposes some styles that screw things up
|
||||
import {Link} from '#/view/com/util/Link'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {
|
||||
ModerationDetailsDialog,
|
||||
useModerationDetailsDialogControl,
|
||||
} from '#/components/moderation/ModerationDetailsDialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
// import {Link} from '#/components/Link' TODO this imposes some styles that screw things up
|
||||
import {Link} from '#/view/com/util/Link'
|
||||
|
||||
interface Props extends ComponentProps<typeof Link> {
|
||||
iconSize: number
|
||||
iconStyles: StyleProp<ViewStyle>
|
||||
modui: ModerationUI
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
}
|
||||
|
||||
export function PostHider({
|
||||
@@ -30,8 +32,10 @@ export function PostHider({
|
||||
children,
|
||||
iconSize,
|
||||
iconStyles,
|
||||
profile,
|
||||
...props
|
||||
}: Props) {
|
||||
const queryClient = useQueryClient()
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const [override, setOverride] = React.useState(false)
|
||||
@@ -39,6 +43,10 @@ export function PostHider({
|
||||
const blur = modui.blurs[0]
|
||||
const desc = useModerationCauseDescription(blur)
|
||||
|
||||
const onBeforePress = React.useCallback(() => {
|
||||
precacheProfile(queryClient, profile)
|
||||
}, [queryClient, profile])
|
||||
|
||||
if (!blur) {
|
||||
return (
|
||||
<Link
|
||||
@@ -46,6 +54,7 @@ export function PostHider({
|
||||
style={style}
|
||||
href={href}
|
||||
accessible={false}
|
||||
onBeforePress={onBeforePress}
|
||||
{...props}>
|
||||
{children}
|
||||
</Link>
|
||||
|
||||
@@ -5,6 +5,7 @@ const ScrollContext = createContext<ScrollHandlers<any>>({
|
||||
onBeginDrag: undefined,
|
||||
onEndDrag: undefined,
|
||||
onScroll: undefined,
|
||||
onMomentumEnd: undefined,
|
||||
})
|
||||
|
||||
export function useScrollHandlers(): ScrollHandlers<any> {
|
||||
@@ -20,14 +21,16 @@ export function ScrollProvider({
|
||||
onBeginDrag,
|
||||
onEndDrag,
|
||||
onScroll,
|
||||
onMomentumEnd,
|
||||
}: ProviderProps) {
|
||||
const handlers = useMemo(
|
||||
() => ({
|
||||
onBeginDrag,
|
||||
onEndDrag,
|
||||
onScroll,
|
||||
onMomentumEnd,
|
||||
}),
|
||||
[onBeginDrag, onEndDrag, onScroll],
|
||||
[onBeginDrag, onEndDrag, onScroll, onMomentumEnd],
|
||||
)
|
||||
return (
|
||||
<ScrollContext.Provider value={handlers}>{children}</ScrollContext.Provider>
|
||||
|
||||
@@ -76,6 +76,7 @@ export type TrackPropertiesMap = {
|
||||
'MobileShell:SearchButtonPressed': {}
|
||||
'MobileShell:NotificationsButtonPressed': {}
|
||||
'MobileShell:FeedsButtonPressed': {}
|
||||
'MobileShell:MessagesButtonPressed': {}
|
||||
// NOTIFICATIONS events
|
||||
'Notificatons:OpenApp': {}
|
||||
// LISTS events
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import {
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyEmbedRecord,
|
||||
} from '@atproto/api'
|
||||
import {ReasonFeedSource} from './feed/types'
|
||||
|
||||
import {isPostInLanguage} from '../../locale/helpers'
|
||||
import {ReasonFeedSource} from './feed/types'
|
||||
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
|
||||
|
||||
export type FeedTunerFn = (
|
||||
@@ -341,6 +342,8 @@ export class FeedTuner {
|
||||
tuner: FeedTuner,
|
||||
slices: FeedViewPostsSlice[],
|
||||
): FeedViewPostsSlice[] => {
|
||||
const candidateSlices = slices.slice()
|
||||
|
||||
// early return if no languages have been specified
|
||||
if (!preferredLangsCode2.length || preferredLangsCode2.length === 0) {
|
||||
return slices
|
||||
@@ -357,10 +360,17 @@ export class FeedTuner {
|
||||
|
||||
// if item does not fit preferred language, remove it
|
||||
if (!hasPreferredLang) {
|
||||
slices.splice(i, 1)
|
||||
candidateSlices.splice(i, 1)
|
||||
}
|
||||
}
|
||||
return slices
|
||||
|
||||
// if the language filter cleared out the entire page, return the original set
|
||||
// so that something always shows
|
||||
if (candidateSlices.length === 0) {
|
||||
return slices
|
||||
}
|
||||
|
||||
return candidateSlices
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
|
||||
BskyAgent,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
export class AuthorFeedAPI implements FeedAPI {
|
||||
constructor(public params: GetAuthorFeed.QueryParams) {}
|
||||
getAgent: () => BskyAgent
|
||||
params: GetAuthorFeed.QueryParams
|
||||
|
||||
constructor({
|
||||
getAgent,
|
||||
feedParams,
|
||||
}: {
|
||||
getAgent: () => BskyAgent
|
||||
feedParams: GetAuthorFeed.QueryParams
|
||||
}) {
|
||||
this.getAgent = getAgent
|
||||
this.params = feedParams
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await getAgent().getAuthorFeed({
|
||||
const res = await this.getAgent().getAuthorFeed({
|
||||
...this.params,
|
||||
limit: 1,
|
||||
})
|
||||
@@ -23,7 +36,7 @@ export class AuthorFeedAPI implements FeedAPI {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
const res = await getAgent().getAuthorFeed({
|
||||
const res = await this.getAgent().getAuthorFeed({
|
||||
...this.params,
|
||||
cursor,
|
||||
limit,
|
||||
|
||||
+89
-12
@@ -1,17 +1,31 @@
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetFeed as GetCustomFeed,
|
||||
AtpAgent,
|
||||
BskyAgent,
|
||||
} from '@atproto/api'
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
import {getContentLanguages} from '#/state/preferences/languages'
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
export class CustomFeedAPI implements FeedAPI {
|
||||
constructor(public params: GetCustomFeed.QueryParams) {}
|
||||
getAgent: () => BskyAgent
|
||||
params: GetCustomFeed.QueryParams
|
||||
|
||||
constructor({
|
||||
getAgent,
|
||||
feedParams,
|
||||
}: {
|
||||
getAgent: () => BskyAgent
|
||||
feedParams: GetCustomFeed.QueryParams
|
||||
}) {
|
||||
this.getAgent = getAgent
|
||||
this.params = feedParams
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const contentLangs = getContentLanguages().join(',')
|
||||
const res = await getAgent().app.bsky.feed.getFeed(
|
||||
const res = await this.getAgent().app.bsky.feed.getFeed(
|
||||
{
|
||||
...this.params,
|
||||
limit: 1,
|
||||
@@ -29,14 +43,21 @@ export class CustomFeedAPI implements FeedAPI {
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
const contentLangs = getContentLanguages().join(',')
|
||||
const res = await getAgent().app.bsky.feed.getFeed(
|
||||
{
|
||||
...this.params,
|
||||
cursor,
|
||||
limit,
|
||||
},
|
||||
{headers: {'Accept-Language': contentLangs}},
|
||||
)
|
||||
const agent = this.getAgent()
|
||||
const res = agent.session
|
||||
? await this.getAgent().app.bsky.feed.getFeed(
|
||||
{
|
||||
...this.params,
|
||||
cursor,
|
||||
limit,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Accept-Language': contentLangs,
|
||||
},
|
||||
},
|
||||
)
|
||||
: await loggedOutFetch({...this.params, cursor, limit})
|
||||
if (res.success) {
|
||||
// NOTE
|
||||
// some custom feeds fail to enforce the pagination limit
|
||||
@@ -55,3 +76,59 @@ export class CustomFeedAPI implements FeedAPI {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HACK
|
||||
// we want feeds to give language-specific results immediately when a
|
||||
// logged-out user changes their language. this comes with two problems:
|
||||
// 1. not all languages have content, and
|
||||
// 2. our public caching layer isnt correctly busting against the accept-language header
|
||||
// for now we handle both of these with a manual workaround
|
||||
// -prf
|
||||
async function loggedOutFetch({
|
||||
feed,
|
||||
limit,
|
||||
cursor,
|
||||
}: {
|
||||
feed: string
|
||||
limit: number
|
||||
cursor?: string
|
||||
}) {
|
||||
let contentLangs = getContentLanguages().join(',')
|
||||
|
||||
// manually construct fetch call so we can add the `lang` cache-busting param
|
||||
let res = await AtpAgent.fetch!(
|
||||
`https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${
|
||||
cursor ? `&cursor=${cursor}` : ''
|
||||
}&limit=${limit}&lang=${contentLangs}`,
|
||||
'GET',
|
||||
{'Accept-Language': contentLangs},
|
||||
undefined,
|
||||
)
|
||||
if (res.body?.feed?.length) {
|
||||
return {
|
||||
success: true,
|
||||
data: res.body,
|
||||
}
|
||||
}
|
||||
|
||||
// no data, try again with language headers removed
|
||||
res = await AtpAgent.fetch!(
|
||||
`https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${
|
||||
cursor ? `&cursor=${cursor}` : ''
|
||||
}&limit=${limit}`,
|
||||
'GET',
|
||||
{'Accept-Language': ''},
|
||||
undefined,
|
||||
)
|
||||
if (res.body?.feed?.length) {
|
||||
return {
|
||||
success: true,
|
||||
data: res.body,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
data: {feed: []},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import {AppBskyFeedDefs} from '@atproto/api'
|
||||
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
|
||||
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
export class FollowingFeedAPI implements FeedAPI {
|
||||
constructor() {}
|
||||
getAgent: () => BskyAgent
|
||||
|
||||
constructor({getAgent}: {getAgent: () => BskyAgent}) {
|
||||
this.getAgent = getAgent
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await getAgent().getTimeline({
|
||||
const res = await this.getAgent().getTimeline({
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
@@ -19,7 +23,7 @@ export class FollowingFeedAPI implements FeedAPI {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
const res = await getAgent().getTimeline({
|
||||
const res = await this.getAgent().getTimeline({
|
||||
cursor,
|
||||
limit,
|
||||
})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {AppBskyFeedDefs} from '@atproto/api'
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
import {FollowingFeedAPI} from './following'
|
||||
import {CustomFeedAPI} from './custom'
|
||||
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
|
||||
|
||||
import {PROD_DEFAULT_FEED} from '#/lib/constants'
|
||||
import {CustomFeedAPI} from './custom'
|
||||
import {FollowingFeedAPI} from './following'
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
// HACK
|
||||
// the feed API does not include any facilities for passing down
|
||||
@@ -26,19 +27,27 @@ export const FALLBACK_MARKER_POST: AppBskyFeedDefs.FeedViewPost = {
|
||||
}
|
||||
|
||||
export class HomeFeedAPI implements FeedAPI {
|
||||
getAgent: () => BskyAgent
|
||||
following: FollowingFeedAPI
|
||||
discover: CustomFeedAPI
|
||||
usingDiscover = false
|
||||
itemCursor = 0
|
||||
|
||||
constructor() {
|
||||
this.following = new FollowingFeedAPI()
|
||||
this.discover = new CustomFeedAPI({feed: PROD_DEFAULT_FEED('whats-hot')})
|
||||
constructor({getAgent}: {getAgent: () => BskyAgent}) {
|
||||
this.getAgent = getAgent
|
||||
this.following = new FollowingFeedAPI({getAgent})
|
||||
this.discover = new CustomFeedAPI({
|
||||
getAgent,
|
||||
feedParams: {feed: PROD_DEFAULT_FEED('whats-hot')},
|
||||
})
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.following = new FollowingFeedAPI()
|
||||
this.discover = new CustomFeedAPI({feed: PROD_DEFAULT_FEED('whats-hot')})
|
||||
this.following = new FollowingFeedAPI({getAgent: this.getAgent})
|
||||
this.discover = new CustomFeedAPI({
|
||||
getAgent: this.getAgent,
|
||||
feedParams: {feed: PROD_DEFAULT_FEED('whats-hot')},
|
||||
})
|
||||
this.usingDiscover = false
|
||||
this.itemCursor = 0
|
||||
}
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetActorLikes as GetActorLikes,
|
||||
BskyAgent,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
export class LikesFeedAPI implements FeedAPI {
|
||||
constructor(public params: GetActorLikes.QueryParams) {}
|
||||
getAgent: () => BskyAgent
|
||||
params: GetActorLikes.QueryParams
|
||||
|
||||
constructor({
|
||||
getAgent,
|
||||
feedParams,
|
||||
}: {
|
||||
getAgent: () => BskyAgent
|
||||
feedParams: GetActorLikes.QueryParams
|
||||
}) {
|
||||
this.getAgent = getAgent
|
||||
this.params = feedParams
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await getAgent().getActorLikes({
|
||||
const res = await this.getAgent().getActorLikes({
|
||||
...this.params,
|
||||
limit: 1,
|
||||
})
|
||||
@@ -23,7 +36,7 @@ export class LikesFeedAPI implements FeedAPI {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
const res = await getAgent().getActorLikes({
|
||||
const res = await this.getAgent().getActorLikes({
|
||||
...this.params,
|
||||
cursor,
|
||||
limit,
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetListFeed as GetListFeed,
|
||||
BskyAgent,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
export class ListFeedAPI implements FeedAPI {
|
||||
constructor(public params: GetListFeed.QueryParams) {}
|
||||
getAgent: () => BskyAgent
|
||||
params: GetListFeed.QueryParams
|
||||
|
||||
constructor({
|
||||
getAgent,
|
||||
feedParams,
|
||||
}: {
|
||||
getAgent: () => BskyAgent
|
||||
feedParams: GetListFeed.QueryParams
|
||||
}) {
|
||||
this.getAgent = getAgent
|
||||
this.params = feedParams
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await getAgent().app.bsky.feed.getListFeed({
|
||||
const res = await this.getAgent().app.bsky.feed.getListFeed({
|
||||
...this.params,
|
||||
limit: 1,
|
||||
})
|
||||
@@ -23,7 +36,7 @@ export class ListFeedAPI implements FeedAPI {
|
||||
cursor: string | undefined
|
||||
limit: number
|
||||
}): Promise<FeedAPIResponse> {
|
||||
const res = await getAgent().app.bsky.feed.getListFeed({
|
||||
const res = await this.getAgent().app.bsky.feed.getListFeed({
|
||||
...this.params,
|
||||
cursor,
|
||||
limit,
|
||||
|
||||
+72
-17
@@ -1,31 +1,51 @@
|
||||
import {AppBskyFeedDefs, AppBskyFeedGetTimeline} from '@atproto/api'
|
||||
import {AppBskyFeedDefs, AppBskyFeedGetTimeline, BskyAgent} from '@atproto/api'
|
||||
import shuffle from 'lodash.shuffle'
|
||||
import {timeout} from 'lib/async/timeout'
|
||||
|
||||
import {getContentLanguages} from '#/state/preferences/languages'
|
||||
import {FeedParams} from '#/state/queries/post-feed'
|
||||
import {bundleAsync} from 'lib/async/bundle'
|
||||
import {timeout} from 'lib/async/timeout'
|
||||
import {feedUriToHref} from 'lib/strings/url-helpers'
|
||||
import {FeedTuner} from '../feed-manip'
|
||||
import {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types'
|
||||
import {FeedParams} from '#/state/queries/post-feed'
|
||||
import {FeedTunerFn} from '../feed-manip'
|
||||
import {getAgent} from '#/state/session'
|
||||
import {getContentLanguages} from '#/state/preferences/languages'
|
||||
import {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types'
|
||||
|
||||
const REQUEST_WAIT_MS = 500 // 500ms
|
||||
const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
|
||||
|
||||
export class MergeFeedAPI implements FeedAPI {
|
||||
getAgent: () => BskyAgent
|
||||
params: FeedParams
|
||||
feedTuners: FeedTunerFn[]
|
||||
following: MergeFeedSource_Following
|
||||
customFeeds: MergeFeedSource_Custom[] = []
|
||||
feedCursor = 0
|
||||
itemCursor = 0
|
||||
sampleCursor = 0
|
||||
|
||||
constructor(public params: FeedParams, public feedTuners: FeedTunerFn[]) {
|
||||
this.following = new MergeFeedSource_Following(this.feedTuners)
|
||||
constructor({
|
||||
getAgent,
|
||||
feedParams,
|
||||
feedTuners,
|
||||
}: {
|
||||
getAgent: () => BskyAgent
|
||||
feedParams: FeedParams
|
||||
feedTuners: FeedTunerFn[]
|
||||
}) {
|
||||
this.getAgent = getAgent
|
||||
this.params = feedParams
|
||||
this.feedTuners = feedTuners
|
||||
this.following = new MergeFeedSource_Following({
|
||||
getAgent: this.getAgent,
|
||||
feedTuners: this.feedTuners,
|
||||
})
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.following = new MergeFeedSource_Following(this.feedTuners)
|
||||
this.following = new MergeFeedSource_Following({
|
||||
getAgent: this.getAgent,
|
||||
feedTuners: this.feedTuners,
|
||||
})
|
||||
this.customFeeds = []
|
||||
this.feedCursor = 0
|
||||
this.itemCursor = 0
|
||||
@@ -33,7 +53,12 @@ export class MergeFeedAPI implements FeedAPI {
|
||||
if (this.params.mergeFeedSources) {
|
||||
this.customFeeds = shuffle(
|
||||
this.params.mergeFeedSources.map(
|
||||
feedUri => new MergeFeedSource_Custom(feedUri, this.feedTuners),
|
||||
feedUri =>
|
||||
new MergeFeedSource_Custom({
|
||||
getAgent: this.getAgent,
|
||||
feedUri,
|
||||
feedTuners: this.feedTuners,
|
||||
}),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
@@ -42,7 +67,7 @@ export class MergeFeedAPI implements FeedAPI {
|
||||
}
|
||||
|
||||
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
|
||||
const res = await getAgent().getTimeline({
|
||||
const res = await this.getAgent().getTimeline({
|
||||
limit: 1,
|
||||
})
|
||||
return res.data.feed[0]
|
||||
@@ -136,12 +161,23 @@ export class MergeFeedAPI implements FeedAPI {
|
||||
}
|
||||
|
||||
class MergeFeedSource {
|
||||
getAgent: () => BskyAgent
|
||||
feedTuners: FeedTunerFn[]
|
||||
sourceInfo: ReasonFeedSource | undefined
|
||||
cursor: string | undefined = undefined
|
||||
queue: AppBskyFeedDefs.FeedViewPost[] = []
|
||||
hasMore = true
|
||||
|
||||
constructor(public feedTuners: FeedTunerFn[]) {}
|
||||
constructor({
|
||||
getAgent,
|
||||
feedTuners,
|
||||
}: {
|
||||
getAgent: () => BskyAgent
|
||||
feedTuners: FeedTunerFn[]
|
||||
}) {
|
||||
this.getAgent = getAgent
|
||||
this.feedTuners = feedTuners
|
||||
}
|
||||
|
||||
get numReady() {
|
||||
return this.queue.length
|
||||
@@ -203,7 +239,7 @@ class MergeFeedSource_Following extends MergeFeedSource {
|
||||
cursor: string | undefined,
|
||||
limit: number,
|
||||
): Promise<AppBskyFeedGetTimeline.Response> {
|
||||
const res = await getAgent().getTimeline({cursor, limit})
|
||||
const res = await this.getAgent().getTimeline({cursor, limit})
|
||||
// run the tuner pre-emptively to ensure better mixing
|
||||
const slices = this.tuner.tune(res.data.feed, {
|
||||
dryRun: false,
|
||||
@@ -215,10 +251,25 @@ class MergeFeedSource_Following extends MergeFeedSource {
|
||||
}
|
||||
|
||||
class MergeFeedSource_Custom extends MergeFeedSource {
|
||||
getAgent: () => BskyAgent
|
||||
minDate: Date
|
||||
feedUri: string
|
||||
|
||||
constructor(public feedUri: string, public feedTuners: FeedTunerFn[]) {
|
||||
super(feedTuners)
|
||||
constructor({
|
||||
getAgent,
|
||||
feedUri,
|
||||
feedTuners,
|
||||
}: {
|
||||
getAgent: () => BskyAgent
|
||||
feedUri: string
|
||||
feedTuners: FeedTunerFn[]
|
||||
}) {
|
||||
super({
|
||||
getAgent,
|
||||
feedTuners,
|
||||
})
|
||||
this.getAgent = getAgent
|
||||
this.feedUri = feedUri
|
||||
this.sourceInfo = {
|
||||
$type: 'reasonFeedSource',
|
||||
uri: feedUri,
|
||||
@@ -233,13 +284,17 @@ class MergeFeedSource_Custom extends MergeFeedSource {
|
||||
): Promise<AppBskyFeedGetTimeline.Response> {
|
||||
try {
|
||||
const contentLangs = getContentLanguages().join(',')
|
||||
const res = await getAgent().app.bsky.feed.getFeed(
|
||||
const res = await this.getAgent().app.bsky.feed.getFeed(
|
||||
{
|
||||
cursor,
|
||||
limit,
|
||||
feed: this.feedUri,
|
||||
},
|
||||
{headers: {'Accept-Language': contentLangs}},
|
||||
{
|
||||
headers: {
|
||||
'Accept-Language': contentLangs,
|
||||
},
|
||||
},
|
||||
)
|
||||
// NOTE
|
||||
// some custom feeds fail to enforce the pagination limit
|
||||
|
||||
+14
-6
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedExternal,
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedThreadgate,
|
||||
@@ -11,13 +11,15 @@ import {
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {isNetworkError} from 'lib/strings/errors'
|
||||
import {LinkMeta} from '../link-meta/link-meta'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {ImageModel} from 'state/models/media/image'
|
||||
import {shortenLinks} from 'lib/strings/rich-text-manip'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
||||
import {isNetworkError} from 'lib/strings/errors'
|
||||
import {shortenLinks} from 'lib/strings/rich-text-manip'
|
||||
import {isNative, isWeb} from 'platform/detection'
|
||||
import {ImageModel} from 'state/models/media/image'
|
||||
import {LinkMeta} from '../link-meta/link-meta'
|
||||
import {safeDeleteAsync} from '../media/manip'
|
||||
|
||||
export interface ExternalEmbedDraft {
|
||||
uri: string
|
||||
@@ -117,6 +119,9 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
const {width, height} = image.compressed || image
|
||||
logger.debug(`Uploading image`)
|
||||
const res = await uploadBlob(agent, path, 'image/jpeg')
|
||||
if (isNative) {
|
||||
safeDeleteAsync(path)
|
||||
}
|
||||
images.push({
|
||||
image: res.data.blob,
|
||||
alt: image.altText ?? '',
|
||||
@@ -171,6 +176,9 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
encoding,
|
||||
)
|
||||
thumb = thumbUploadRes.data.blob
|
||||
if (isNative) {
|
||||
safeDeleteAsync(opts.extLink.localThumb.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {nativeApplicationVersion, nativeBuildVersion} from 'expo-application'
|
||||
|
||||
export const BUILD_ENV = process.env.EXPO_PUBLIC_ENV
|
||||
export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
|
||||
export const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
|
||||
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export const LOGIN_INCLUDE_DEV_SERVERS = true
|
||||
export const PWI_ENABLED = true
|
||||
export const NEW_ONBOARDING_ENABLED = true
|
||||
|
||||
@@ -4,9 +4,13 @@ export const LOCAL_DEV_SERVICE =
|
||||
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
|
||||
export const STAGING_SERVICE = 'https://staging.bsky.dev'
|
||||
export const BSKY_SERVICE = 'https://bsky.social'
|
||||
export const PUBLIC_BSKY_SERVICE = 'https://public.api.bsky.app'
|
||||
export const DEFAULT_SERVICE = BSKY_SERVICE
|
||||
const HELP_DESK_LANG = 'en-us'
|
||||
export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
|
||||
export const EMBED_SERVICE = 'https://embed.bsky.app'
|
||||
export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
|
||||
export const BSKY_DOWNLOAD_URL = 'https://bsky.app/download'
|
||||
|
||||
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
|
||||
export function FEEDBACK_FORM_URL({
|
||||
@@ -87,3 +91,10 @@ export const BSKY_FEED_OWNER_DIDS = [
|
||||
'did:plc:vpkhqolt662uhesyj6nxm7ys',
|
||||
'did:plc:q6gjnaw2blty4crticxkmujt',
|
||||
]
|
||||
|
||||
export const GIF_SERVICE = 'https://gifs.bsky.app'
|
||||
|
||||
export const GIF_SEARCH = (params: string) =>
|
||||
`${GIF_SERVICE}/tenor/v2/search?${params}`
|
||||
export const GIF_FEATURED = (params: string) =>
|
||||
`${GIF_SERVICE}/tenor/v2/featured?${params}`
|
||||
|
||||
+9
-36
@@ -1,47 +1,20 @@
|
||||
import {
|
||||
impactAsync,
|
||||
ImpactFeedbackStyle,
|
||||
notificationAsync,
|
||||
NotificationFeedbackType,
|
||||
selectionAsync,
|
||||
} from 'expo-haptics'
|
||||
import React from 'react'
|
||||
import {impactAsync, ImpactFeedbackStyle} from 'expo-haptics'
|
||||
|
||||
import {isIOS, isWeb} from 'platform/detection'
|
||||
import {useHapticsDisabled} from 'state/preferences/disable-haptics'
|
||||
|
||||
const hapticImpact: ImpactFeedbackStyle = isIOS
|
||||
? ImpactFeedbackStyle.Medium
|
||||
: ImpactFeedbackStyle.Light // Users said the medium impact was too strong on Android; see APP-537s
|
||||
|
||||
export class Haptics {
|
||||
static default() {
|
||||
if (isWeb) {
|
||||
export function useHaptics() {
|
||||
const isHapticsDisabled = useHapticsDisabled()
|
||||
|
||||
return React.useCallback(() => {
|
||||
if (isHapticsDisabled || isWeb) {
|
||||
return
|
||||
}
|
||||
impactAsync(hapticImpact)
|
||||
}
|
||||
static impact(type: ImpactFeedbackStyle = hapticImpact) {
|
||||
if (isWeb) {
|
||||
return
|
||||
}
|
||||
impactAsync(type)
|
||||
}
|
||||
static selection() {
|
||||
if (isWeb) {
|
||||
return
|
||||
}
|
||||
selectionAsync()
|
||||
}
|
||||
static notification = (type: 'success' | 'warning' | 'error') => {
|
||||
if (isWeb) {
|
||||
return
|
||||
}
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return notificationAsync(NotificationFeedbackType.Success)
|
||||
case 'warning':
|
||||
return notificationAsync(NotificationFeedbackType.Warning)
|
||||
case 'error':
|
||||
return notificationAsync(NotificationFeedbackType.Error)
|
||||
}
|
||||
}
|
||||
}, [isHapticsDisabled])
|
||||
}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import {useCallback} from 'react'
|
||||
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {useSessionApi, SessionAccount} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {SessionAccount, useSessionApi} from '#/state/session'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {LogEvents} from '../statsig/statsig'
|
||||
|
||||
export function useAccountSwitcher() {
|
||||
const {track} = useAnalytics()
|
||||
const {selectAccount, clearCurrentAccount} = useSessionApi()
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
const {requestSwitchToAccount} = useLoggedOutViewControls()
|
||||
|
||||
const onPressSwitchAccount = useCallback(
|
||||
@@ -23,7 +21,6 @@ export function useAccountSwitcher() {
|
||||
|
||||
try {
|
||||
if (account.accessJwt) {
|
||||
closeAllActiveElements()
|
||||
if (isWeb) {
|
||||
// We're switching accounts, which remounts the entire app.
|
||||
// On mobile, this gets us Home, but on the web we also need reset the URL.
|
||||
@@ -37,7 +34,6 @@ export function useAccountSwitcher() {
|
||||
Toast.show(`Signed in as @${account.handle}`)
|
||||
}, 100)
|
||||
} else {
|
||||
closeAllActiveElements()
|
||||
requestSwitchToAccount({requestedAccount: account.did})
|
||||
Toast.show(
|
||||
`Please sign in as @${account.handle}`,
|
||||
@@ -49,13 +45,7 @@ export function useAccountSwitcher() {
|
||||
clearCurrentAccount() // back user out to login
|
||||
}
|
||||
},
|
||||
[
|
||||
track,
|
||||
clearCurrentAccount,
|
||||
selectAccount,
|
||||
closeAllActiveElements,
|
||||
requestSwitchToAccount,
|
||||
],
|
||||
[track, clearCurrentAccount, selectAccount, requestSwitchToAccount],
|
||||
)
|
||||
|
||||
return {onPressSwitchAccount}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {useNavigationState} from '@react-navigation/native'
|
||||
|
||||
import {getTabState, TabState} from 'lib/routes/helpers'
|
||||
|
||||
export function useNavigationTabState() {
|
||||
@@ -10,13 +11,15 @@ export function useNavigationTabState() {
|
||||
isAtNotifications:
|
||||
getTabState(state, 'Notifications') !== TabState.Outside,
|
||||
isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside,
|
||||
isAtMessages: getTabState(state, 'MessagesList') !== TabState.Outside,
|
||||
}
|
||||
if (
|
||||
!res.isAtHome &&
|
||||
!res.isAtSearch &&
|
||||
!res.isAtFeeds &&
|
||||
!res.isAtNotifications &&
|
||||
!res.isAtMyProfile
|
||||
!res.isAtMyProfile &&
|
||||
!res.isAtMessages
|
||||
) {
|
||||
// HACK for some reason useNavigationState will give us pre-hydration results
|
||||
// and not update after, so we force isAtHome if all came back false
|
||||
|
||||
@@ -30,6 +30,8 @@ async function setExtraParams() {
|
||||
}
|
||||
|
||||
export function useOTAUpdates() {
|
||||
const shouldReceiveUpdates = isEnabled && !__DEV__
|
||||
|
||||
const appState = React.useRef<AppStateStatus>('active')
|
||||
const lastMinimize = React.useRef(0)
|
||||
const ranInitialCheck = React.useRef(false)
|
||||
@@ -51,61 +53,59 @@ export function useOTAUpdates() {
|
||||
logger.debug('No update available.')
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('OTA Update Error', {error: `${e}`})
|
||||
logger.error('OTA Update Error', {error: `${e}`})
|
||||
}
|
||||
}, 10e3)
|
||||
}, [])
|
||||
|
||||
const onIsTestFlight = React.useCallback(() => {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await setExtraParams()
|
||||
const onIsTestFlight = React.useCallback(async () => {
|
||||
try {
|
||||
await setExtraParams()
|
||||
|
||||
const res = await checkForUpdateAsync()
|
||||
if (res.isAvailable) {
|
||||
await fetchUpdateAsync()
|
||||
const res = await checkForUpdateAsync()
|
||||
if (res.isAvailable) {
|
||||
await fetchUpdateAsync()
|
||||
|
||||
Alert.alert(
|
||||
'Update Available',
|
||||
'A new version of the app is available. Relaunch now?',
|
||||
[
|
||||
{
|
||||
text: 'No',
|
||||
style: 'cancel',
|
||||
Alert.alert(
|
||||
'Update Available',
|
||||
'A new version of the app is available. Relaunch now?',
|
||||
[
|
||||
{
|
||||
text: 'No',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Relaunch',
|
||||
style: 'default',
|
||||
onPress: async () => {
|
||||
await reloadAsync()
|
||||
},
|
||||
{
|
||||
text: 'Relaunch',
|
||||
style: 'default',
|
||||
onPress: async () => {
|
||||
await reloadAsync()
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
} catch (e: any) {
|
||||
// No need to handle
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
}, 3e3)
|
||||
} catch (e: any) {
|
||||
logger.error('Internal OTA Update Error', {error: `${e}`})
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
// We use this setTimeout to allow Statsig to initialize before we check for an update
|
||||
// For Testflight users, we can prompt the user to update immediately whenever there's an available update. This
|
||||
// is suspect however with the Apple App Store guidelines, so we don't want to prompt production users to update
|
||||
// immediately.
|
||||
if (IS_TESTFLIGHT) {
|
||||
onIsTestFlight()
|
||||
return
|
||||
} else if (!isEnabled || __DEV__ || ranInitialCheck.current) {
|
||||
// Development client shouldn't check for updates at all, so we skip that here.
|
||||
} else if (!shouldReceiveUpdates || ranInitialCheck.current) {
|
||||
return
|
||||
}
|
||||
|
||||
setCheckTimeout()
|
||||
ranInitialCheck.current = true
|
||||
}, [onIsTestFlight, setCheckTimeout])
|
||||
}, [onIsTestFlight, setCheckTimeout, shouldReceiveUpdates])
|
||||
|
||||
// After the app has been minimized for 30 minutes, we want to either A. install an update if one has become available
|
||||
// After the app has been minimized for 15 minutes, we want to either A. install an update if one has become available
|
||||
// or B check for an update again.
|
||||
React.useEffect(() => {
|
||||
if (!isEnabled) return
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export function useOTAUpdates() {}
|
||||
+45
-16
@@ -1,13 +1,14 @@
|
||||
import RNFetchBlob from 'rn-fetch-blob'
|
||||
import ImageResizer from '@bam.tech/react-native-image-resizer'
|
||||
import {Image as RNImage, Share as RNShare} from 'react-native'
|
||||
import {Image} from 'react-native-image-crop-picker'
|
||||
import * as RNFS from 'react-native-fs'
|
||||
import uuid from 'react-native-uuid'
|
||||
import * as Sharing from 'expo-sharing'
|
||||
import {cacheDirectory, copyAsync, deleteAsync} from 'expo-file-system'
|
||||
import * as MediaLibrary from 'expo-media-library'
|
||||
import {Dimensions} from './types'
|
||||
import * as Sharing from 'expo-sharing'
|
||||
import ImageResizer from '@bam.tech/react-native-image-resizer'
|
||||
import RNFetchBlob from 'rn-fetch-blob'
|
||||
|
||||
import {isAndroid, isIOS} from 'platform/detection'
|
||||
import {Dimensions} from './types'
|
||||
|
||||
export async function compressIfNeeded(
|
||||
img: Image,
|
||||
@@ -23,7 +24,10 @@ export async function compressIfNeeded(
|
||||
mode: 'stretch',
|
||||
maxSize,
|
||||
})
|
||||
const finalImageMovedPath = await moveToPermanentPath(resizedImage.path)
|
||||
const finalImageMovedPath = await moveToPermanentPath(
|
||||
resizedImage.path,
|
||||
'.jpg',
|
||||
)
|
||||
const finalImg = {
|
||||
...resizedImage,
|
||||
path: finalImageMovedPath,
|
||||
@@ -63,13 +67,15 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) {
|
||||
downloadRes = await downloadResPromise
|
||||
clearTimeout(to1)
|
||||
|
||||
let localUri = downloadRes.path()
|
||||
if (!localUri.startsWith('file://')) {
|
||||
localUri = `file://${localUri}`
|
||||
const status = downloadRes.info().status
|
||||
if (status !== 200) {
|
||||
return
|
||||
}
|
||||
|
||||
const localUri = normalizePath(downloadRes.path(), true)
|
||||
return await doResize(localUri, opts)
|
||||
} finally {
|
||||
// TODO Whenever we remove `rn-fetch-blob`, we will need to replace this `flush()` with a `deleteAsync()` -hailey
|
||||
if (downloadRes) {
|
||||
downloadRes.flush()
|
||||
}
|
||||
@@ -105,7 +111,8 @@ export async function shareImageModal({uri}: {uri: string}) {
|
||||
UTI: 'image/png',
|
||||
})
|
||||
}
|
||||
RNFS.unlink(imagePath)
|
||||
|
||||
safeDeleteAsync(imagePath)
|
||||
}
|
||||
|
||||
export async function saveImageToMediaLibrary({uri}: {uri: string}) {
|
||||
@@ -122,6 +129,7 @@ export async function saveImageToMediaLibrary({uri}: {uri: string}) {
|
||||
|
||||
// save
|
||||
await MediaLibrary.createAssetAsync(imagePath)
|
||||
safeDeleteAsync(imagePath)
|
||||
}
|
||||
|
||||
export function getImageDim(path: string): Promise<Dimensions> {
|
||||
@@ -168,6 +176,8 @@ async function doResize(localUri: string, opts: DoResizeOpts): Promise<Image> {
|
||||
width: resizeRes.width,
|
||||
height: resizeRes.height,
|
||||
}
|
||||
} else {
|
||||
safeDeleteAsync(resizeRes.path)
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
@@ -175,7 +185,7 @@ async function doResize(localUri: string, opts: DoResizeOpts): Promise<Image> {
|
||||
)
|
||||
}
|
||||
|
||||
async function moveToPermanentPath(path: string, ext = ''): Promise<string> {
|
||||
async function moveToPermanentPath(path: string, ext = 'jpg'): Promise<string> {
|
||||
/*
|
||||
Since this package stores images in a temp directory, we need to move the file to a permanent location.
|
||||
Relevant: IOS bug when trying to open a second time:
|
||||
@@ -183,14 +193,33 @@ async function moveToPermanentPath(path: string, ext = ''): Promise<string> {
|
||||
*/
|
||||
const filename = uuid.v4()
|
||||
|
||||
const destinationPath = joinPath(
|
||||
RNFS.TemporaryDirectoryPath,
|
||||
`${filename}${ext}`,
|
||||
)
|
||||
await RNFS.moveFile(path, destinationPath)
|
||||
// cacheDirectory will not ever be null on native, but it could be on web. This function only ever gets called on
|
||||
// native so we assert as a string.
|
||||
const destinationPath = joinPath(cacheDirectory as string, filename + ext)
|
||||
await copyAsync({
|
||||
from: normalizePath(path),
|
||||
to: normalizePath(destinationPath),
|
||||
})
|
||||
safeDeleteAsync(path)
|
||||
return normalizePath(destinationPath)
|
||||
}
|
||||
|
||||
export async function safeDeleteAsync(path: string) {
|
||||
// Normalize is necessary for Android, otherwise it doesn't delete.
|
||||
const normalizedPath = normalizePath(path)
|
||||
try {
|
||||
await Promise.allSettled([
|
||||
deleteAsync(normalizedPath, {idempotent: true}),
|
||||
// HACK: Try this one too. Might exist due to api-polyfill hack.
|
||||
deleteAsync(normalizedPath.replace(/\.jpe?g$/, '.bin'), {
|
||||
idempotent: true,
|
||||
}),
|
||||
])
|
||||
} catch (e) {
|
||||
console.error('Failed to delete file', e)
|
||||
}
|
||||
}
|
||||
|
||||
function joinPath(a: string, b: string) {
|
||||
if (a.endsWith('/')) {
|
||||
if (b.startsWith('/')) {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import {useEffect} from 'react'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import {BskyAgent} from '@atproto/api'
|
||||
import {QueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
|
||||
import {invalidateCachedUnreadPage} from '#/state/queries/notifications/unread'
|
||||
import {truncateAndInvalidate} from '#/state/queries/util'
|
||||
import {getAgent, SessionAccount} from '#/state/session'
|
||||
import {SessionAccount} from '#/state/session'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
import {devicePlatform, isIOS} from 'platform/detection'
|
||||
import {resetToTab} from '../../Navigation'
|
||||
@@ -17,6 +19,7 @@ const SERVICE_DID = (serviceUrl?: string) =>
|
||||
: 'did:web:api.bsky.app'
|
||||
|
||||
export async function requestPermissionsAndRegisterToken(
|
||||
getAgent: () => BskyAgent,
|
||||
account: SessionAccount,
|
||||
) {
|
||||
// request notifications permission once the user has logged in
|
||||
@@ -48,6 +51,7 @@ export async function requestPermissionsAndRegisterToken(
|
||||
}
|
||||
|
||||
export function registerTokenChangeHandler(
|
||||
getAgent: () => BskyAgent,
|
||||
account: SessionAccount,
|
||||
): () => void {
|
||||
// listens for new changes to the push token
|
||||
@@ -87,6 +91,7 @@ export function useNotificationsListener(queryClient: QueryClient) {
|
||||
// handle notifications that are received, both in the foreground or background
|
||||
// NOTE: currently just here for debug logging
|
||||
const sub1 = Notifications.addNotificationReceivedListener(event => {
|
||||
invalidateCachedUnreadPage()
|
||||
logger.debug(
|
||||
'Notifications: received',
|
||||
{event},
|
||||
@@ -131,11 +136,13 @@ export function useNotificationsListener(queryClient: QueryClient) {
|
||||
)
|
||||
track('Notificatons:OpenApp')
|
||||
logEvent('notifications:openApp', {})
|
||||
invalidateCachedUnreadPage()
|
||||
truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
|
||||
resetToTab('NotificationsTab') // open notifications tab
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return () => {
|
||||
sub1.remove()
|
||||
sub2.remove()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {RouteParams, Route} from './types'
|
||||
import {Route, RouteParams} from './types'
|
||||
|
||||
export class Router {
|
||||
routes: [string, Route][] = []
|
||||
|
||||
@@ -35,8 +35,11 @@ export type CommonNavigatorParams = {
|
||||
PreferencesFollowingFeed: undefined
|
||||
PreferencesThreads: undefined
|
||||
PreferencesExternalEmbeds: undefined
|
||||
AccessibilitySettings: undefined
|
||||
Search: {q?: string}
|
||||
Hashtag: {tag: string; author?: string}
|
||||
MessagesConversation: {conversation: string}
|
||||
MessagesSettings: undefined
|
||||
}
|
||||
|
||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||
@@ -45,6 +48,7 @@ export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||
FeedsTab: undefined
|
||||
NotificationsTab: undefined
|
||||
MyProfileTab: undefined
|
||||
MessagesTab: undefined
|
||||
}
|
||||
|
||||
export type HomeTabNavigatorParams = CommonNavigatorParams & {
|
||||
@@ -67,12 +71,17 @@ export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
|
||||
MyProfile: undefined
|
||||
}
|
||||
|
||||
export type MessagesTabNavigatorParams = CommonNavigatorParams & {
|
||||
MessagesList: undefined
|
||||
}
|
||||
|
||||
export type FlatNavigatorParams = CommonNavigatorParams & {
|
||||
Home: undefined
|
||||
Search: {q?: string}
|
||||
Feeds: undefined
|
||||
Notifications: undefined
|
||||
Hashtag: {tag: string; author?: string}
|
||||
MessagesList: undefined
|
||||
}
|
||||
|
||||
export type AllNavigatorParams = CommonNavigatorParams & {
|
||||
@@ -86,6 +95,8 @@ export type AllNavigatorParams = CommonNavigatorParams & {
|
||||
Notifications: undefined
|
||||
MyProfileTab: undefined
|
||||
Hashtag: {tag: string; author?: string}
|
||||
MessagesTab: undefined
|
||||
MessagesList: undefined
|
||||
}
|
||||
|
||||
// NOTE
|
||||
|
||||
+5
-12
@@ -5,16 +5,9 @@
|
||||
|
||||
import {Platform} from 'react-native'
|
||||
import {nativeApplicationVersion, nativeBuildVersion} from 'expo-application'
|
||||
import * as info from 'expo-updates'
|
||||
import {init} from 'sentry-expo'
|
||||
|
||||
/**
|
||||
* Matches the build profile `channel` props in `eas.json`
|
||||
*/
|
||||
const buildChannel = (info.channel || 'development') as
|
||||
| 'development'
|
||||
| 'preview'
|
||||
| 'production'
|
||||
import {BUILD_ENV, IS_DEV, IS_TESTFLIGHT} from 'lib/app-info'
|
||||
|
||||
/**
|
||||
* Examples:
|
||||
@@ -32,16 +25,16 @@ const release = nativeApplicationVersion ?? 'dev'
|
||||
* - `ios.1.57.0.3`
|
||||
* - `android.1.57.0.46`
|
||||
*/
|
||||
const dist = `${Platform.OS}.${release}${
|
||||
nativeBuildVersion ? `.${nativeBuildVersion}` : ''
|
||||
}`
|
||||
const dist = `${Platform.OS}.${nativeBuildVersion}.${
|
||||
IS_TESTFLIGHT ? 'tf' : ''
|
||||
}${IS_DEV ? 'dev' : ''}`
|
||||
|
||||
init({
|
||||
autoSessionTracking: false,
|
||||
dsn: 'https://05bc3789bf994b81bd7ce20c86ccd3ae@o4505071687041024.ingest.sentry.io/4505071690514432',
|
||||
debug: false, // If `true`, Sentry will try to print out useful debugging information if something goes wrong with sending the event. Set it to `false` in production
|
||||
enableInExpoDevelopment: false, // enable this to test in dev
|
||||
environment: buildChannel,
|
||||
environment: BUILD_ENV ?? 'development',
|
||||
dist,
|
||||
release,
|
||||
})
|
||||
|
||||
@@ -60,6 +60,8 @@ export type LogEvents = {
|
||||
feedType: string
|
||||
reason: 'pull-to-refresh' | 'soft-reset' | 'load-latest'
|
||||
}
|
||||
'composer:gif:open': {}
|
||||
'composer:gif:select': {}
|
||||
|
||||
// Data events
|
||||
'account:create:begin': {}
|
||||
@@ -99,6 +101,7 @@ export type LogEvents = {
|
||||
| 'ProfileHeader'
|
||||
| 'ProfileHeaderSuggestedFollows'
|
||||
| 'ProfileMenu'
|
||||
| 'ProfileHoverCard'
|
||||
}
|
||||
'profile:unfollow': {
|
||||
logContext:
|
||||
@@ -108,5 +111,16 @@ export type LogEvents = {
|
||||
| 'ProfileHeader'
|
||||
| 'ProfileHeaderSuggestedFollows'
|
||||
| 'ProfileMenu'
|
||||
| 'ProfileHoverCard'
|
||||
}
|
||||
|
||||
'test:all:always': {}
|
||||
'test:all:sometimes': {}
|
||||
'test:all:boosted_by_gate1': {reason: 'base' | 'gate1'}
|
||||
'test:all:boosted_by_gate2': {reason: 'base' | 'gate2'}
|
||||
'test:all:boosted_by_both': {reason: 'base' | 'gate1' | 'gate2'}
|
||||
'test:gate1:always': {}
|
||||
'test:gate1:sometimes': {}
|
||||
'test:gate2:always': {}
|
||||
'test:gate2:sometimes': {}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
export type Gate =
|
||||
// Keep this alphabetic please.
|
||||
| 'autoexpand_suggestions_on_profile_follow'
|
||||
| 'disable_min_shell_on_foregrounding'
|
||||
| 'disable_poll_on_discover'
|
||||
| 'new_search'
|
||||
| 'show_follow_back_label'
|
||||
| 'start_session_with_following'
|
||||
| 'autoexpand_suggestions_on_profile_follow_v2'
|
||||
| 'disable_min_shell_on_foregrounding_v2'
|
||||
| 'disable_poll_on_discover_v2'
|
||||
| 'dms'
|
||||
| 'hide_vertical_scroll_indicators'
|
||||
| 'show_follow_back_label_v2'
|
||||
| 'start_session_with_following_v2'
|
||||
| 'test_gate_1'
|
||||
| 'test_gate_2'
|
||||
|
||||
+150
-50
@@ -2,33 +2,60 @@ import React from 'react'
|
||||
import {Platform} from 'react-native'
|
||||
import {AppState, AppStateStatus} from 'react-native'
|
||||
import {sha256} from 'js-sha256'
|
||||
import {
|
||||
Statsig,
|
||||
StatsigProvider,
|
||||
useGate as useStatsigGate,
|
||||
} from 'statsig-react-native-expo'
|
||||
import {Statsig, StatsigProvider} from 'statsig-react-native-expo'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {IS_TESTFLIGHT} from 'lib/app-info'
|
||||
import {useSession} from '../../state/session'
|
||||
import {timeout} from '../async/timeout'
|
||||
import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback'
|
||||
import {LogEvents} from './events'
|
||||
import {Gate} from './gates'
|
||||
|
||||
type StatsigUser = {
|
||||
userID: string | undefined
|
||||
// TODO: Remove when enough users have custom.platform:
|
||||
platform: 'ios' | 'android' | 'web'
|
||||
custom: {
|
||||
// This is the place where we can add our own stuff.
|
||||
// Fields here have to be non-optional to be visible in the UI.
|
||||
platform: 'ios' | 'android' | 'web'
|
||||
refSrc: string
|
||||
refUrl: string
|
||||
appLanguage: string
|
||||
contentLanguages: string[]
|
||||
}
|
||||
}
|
||||
|
||||
let refSrc = ''
|
||||
let refUrl = ''
|
||||
if (isWeb && typeof window !== 'undefined') {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
refSrc = params.get('ref_src') ?? ''
|
||||
refUrl = decodeURIComponent(params.get('ref_url') ?? '')
|
||||
}
|
||||
|
||||
export type {LogEvents}
|
||||
|
||||
const statsigOptions = {
|
||||
environment: {
|
||||
tier:
|
||||
process.env.NODE_ENV === 'development'
|
||||
? 'development'
|
||||
: IS_TESTFLIGHT
|
||||
? 'staging'
|
||||
: 'production',
|
||||
},
|
||||
// Don't block on waiting for network. The fetched config will kick in on next load.
|
||||
// This ensures the UI is always consistent and doesn't update mid-session.
|
||||
// Note this makes cold load (no local storage) and private mode return `false` for all gates.
|
||||
initTimeoutMs: 1,
|
||||
function createStatsigOptions(prefetchUsers: StatsigUser[]) {
|
||||
return {
|
||||
environment: {
|
||||
tier:
|
||||
process.env.NODE_ENV === 'development'
|
||||
? 'development'
|
||||
: IS_TESTFLIGHT
|
||||
? 'staging'
|
||||
: 'production',
|
||||
},
|
||||
// Don't block on waiting for network. The fetched config will kick in on next load.
|
||||
// This ensures the UI is always consistent and doesn't update mid-session.
|
||||
// Note this makes cold load (no local storage) and private mode return `false` for all gates.
|
||||
initTimeoutMs: 1,
|
||||
// Get fresh flags for other accounts as well, if any.
|
||||
prefetchUsers,
|
||||
}
|
||||
}
|
||||
|
||||
type FlatJSONRecord = Record<
|
||||
@@ -76,26 +103,48 @@ export function logEvent<E extends keyof LogEvents>(
|
||||
}
|
||||
}
|
||||
|
||||
export function useGate(gateName: Gate): boolean {
|
||||
const {isLoading, value} = useStatsigGate(gateName)
|
||||
if (isLoading) {
|
||||
// This should not happen because of waitForInitialization={true}.
|
||||
console.error('Did not expected isLoading to ever be true.')
|
||||
// We roll our own cache in front of Statsig because it is a singleton
|
||||
// and it's been difficult to get it to behave in a predictable way.
|
||||
// Our own cache ensures consistent evaluation within a single session.
|
||||
const GateCache = React.createContext<Map<string, boolean> | null>(null)
|
||||
|
||||
export function useGate(): (gateName: Gate) => boolean {
|
||||
const cache = React.useContext(GateCache)
|
||||
if (!cache) {
|
||||
throw Error('useGate() cannot be called outside StatsigProvider.')
|
||||
}
|
||||
return value
|
||||
const gate = React.useCallback(
|
||||
(gateName: Gate): boolean => {
|
||||
const cachedValue = cache.get(gateName)
|
||||
if (cachedValue !== undefined) {
|
||||
return cachedValue
|
||||
}
|
||||
const value = Statsig.initializeCalled()
|
||||
? Statsig.checkGate(gateName)
|
||||
: false
|
||||
cache.set(gateName, value)
|
||||
return value
|
||||
},
|
||||
[cache],
|
||||
)
|
||||
return gate
|
||||
}
|
||||
|
||||
function toStatsigUser(did: string | undefined) {
|
||||
function toStatsigUser(did: string | undefined): StatsigUser {
|
||||
let userID: string | undefined
|
||||
if (did) {
|
||||
userID = sha256(did)
|
||||
}
|
||||
const languagePrefs = persisted.get('languagePrefs')
|
||||
return {
|
||||
userID,
|
||||
platform: Platform.OS,
|
||||
platform: Platform.OS as 'ios' | 'android' | 'web',
|
||||
custom: {
|
||||
// Need to specify here too for gating.
|
||||
platform: Platform.OS,
|
||||
refSrc,
|
||||
refUrl,
|
||||
platform: Platform.OS as 'ios' | 'android' | 'web',
|
||||
appLanguage: languagePrefs.appLanguage,
|
||||
contentLanguages: languagePrefs.contentLanguages,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -122,34 +171,85 @@ AppState.addEventListener('change', (state: AppStateStatus) => {
|
||||
}
|
||||
})
|
||||
|
||||
export async function tryFetchGates(
|
||||
did: string,
|
||||
strategy: 'prefer-low-latency' | 'prefer-fresh-gates',
|
||||
) {
|
||||
try {
|
||||
let timeoutMs = 250 // Don't block the UI if we can't do this fast.
|
||||
if (strategy === 'prefer-fresh-gates') {
|
||||
// Use this for less common operations where the user would be OK with a delay.
|
||||
timeoutMs = 1500
|
||||
}
|
||||
// Note: This condition is currently false the very first render because
|
||||
// Statsig has not initialized yet. In the future, we can fix this by
|
||||
// doing the initialization ourselves instead of relying on the provider.
|
||||
if (Statsig.initializeCalled()) {
|
||||
await Promise.race([
|
||||
timeout(timeoutMs),
|
||||
Statsig.prefetchUsers([toStatsigUser(did)]),
|
||||
])
|
||||
}
|
||||
} catch (e) {
|
||||
// Don't leak errors to the calling code, this is meant to be always safe.
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
const {currentAccount} = useSession()
|
||||
const currentStatsigUser = React.useMemo(
|
||||
() => toStatsigUser(currentAccount?.did),
|
||||
[currentAccount?.did],
|
||||
const {currentAccount, accounts} = useSession()
|
||||
const did = currentAccount?.did
|
||||
const currentStatsigUser = React.useMemo(() => toStatsigUser(did), [did])
|
||||
|
||||
const otherDidsConcatenated = accounts
|
||||
.map(account => account.did)
|
||||
.filter(accountDid => accountDid !== did)
|
||||
.join(' ') // We're only interested in DID changes.
|
||||
const otherStatsigUsers = React.useMemo(
|
||||
() => otherDidsConcatenated.split(' ').map(toStatsigUser),
|
||||
[otherDidsConcatenated],
|
||||
)
|
||||
const statsigOptions = React.useMemo(
|
||||
() => createStatsigOptions(otherStatsigUsers),
|
||||
[otherStatsigUsers],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
function refresh() {
|
||||
// Intentionally refetching the config using the JS SDK rather than React SDK
|
||||
// so that the new config is stored in cache but isn't used during this session.
|
||||
// It will kick in for the next reload.
|
||||
Statsig.updateUser(currentStatsigUser)
|
||||
// Have our own cache in front of Statsig.
|
||||
// This ensures the results remain stable until the active DID changes.
|
||||
const [gateCache, setGateCache] = React.useState(() => new Map())
|
||||
const [prevDid, setPrevDid] = React.useState(did)
|
||||
if (did !== prevDid) {
|
||||
setPrevDid(did)
|
||||
setGateCache(new Map())
|
||||
}
|
||||
|
||||
// Periodically poll Statsig to get the current rule evaluations for all stored accounts.
|
||||
// These changes are prefetched and stored, but don't get applied until the active DID changes.
|
||||
// This ensures that when you switch an account, it already has fresh results by then.
|
||||
const handleIntervalTick = useNonReactiveCallback(() => {
|
||||
if (Statsig.initializeCalled()) {
|
||||
// Note: Only first five will be taken into account by Statsig.
|
||||
Statsig.prefetchUsers([currentStatsigUser, ...otherStatsigUsers])
|
||||
}
|
||||
const id = setInterval(refresh, 3 * 60e3 /* 3 min */)
|
||||
})
|
||||
React.useEffect(() => {
|
||||
const id = setInterval(handleIntervalTick, 60e3 /* 1 min */)
|
||||
return () => clearInterval(id)
|
||||
}, [currentStatsigUser])
|
||||
}, [handleIntervalTick])
|
||||
|
||||
return (
|
||||
<StatsigProvider
|
||||
sdkKey="client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV"
|
||||
mountKey={currentStatsigUser.userID}
|
||||
user={currentStatsigUser}
|
||||
// This isn't really blocking due to short initTimeoutMs above.
|
||||
// However, it ensures `isLoading` is always `false`.
|
||||
waitForInitialization={true}
|
||||
options={statsigOptions}>
|
||||
{children}
|
||||
</StatsigProvider>
|
||||
<GateCache.Provider value={gateCache}>
|
||||
<StatsigProvider
|
||||
key={did}
|
||||
sdkKey="client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV"
|
||||
mountKey={currentStatsigUser.userID}
|
||||
user={currentStatsigUser}
|
||||
// This isn't really blocking due to short initTimeoutMs above.
|
||||
// However, it ensures `isLoading` is always `false`.
|
||||
waitForInitialization={true}
|
||||
options={statsigOptions}>
|
||||
{children}
|
||||
</StatsigProvider>
|
||||
</GateCache.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {Dimensions} from 'react-native'
|
||||
import {Dimensions, Platform} from 'react-native'
|
||||
|
||||
import {isWeb} from 'platform/detection'
|
||||
const {height: SCREEN_HEIGHT} = Dimensions.get('window')
|
||||
|
||||
@@ -60,6 +61,10 @@ export interface EmbedPlayerParams {
|
||||
source: EmbedPlayerSource
|
||||
metaUri?: string
|
||||
hideDetails?: boolean
|
||||
dimensions?: {
|
||||
height: number
|
||||
width: number
|
||||
}
|
||||
}
|
||||
|
||||
const giphyRegex = /media(?:[0-4]\.giphy\.com|\.giphy\.com)/i
|
||||
@@ -90,7 +95,8 @@ export function parseEmbedPlayerFromUrl(
|
||||
if (
|
||||
urlp.hostname === 'www.youtube.com' ||
|
||||
urlp.hostname === 'youtube.com' ||
|
||||
urlp.hostname === 'm.youtube.com'
|
||||
urlp.hostname === 'm.youtube.com' ||
|
||||
urlp.hostname === 'music.youtube.com'
|
||||
) {
|
||||
const [_, page, shortVideoId] = urlp.pathname.split('/')
|
||||
const videoId =
|
||||
@@ -266,7 +272,7 @@ export function parseEmbedPlayerFromUrl(
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: `https://giphy.com/gifs/${gifId}`,
|
||||
playerUri: `https://i.giphy.com/media/${gifId}/giphy.webp`,
|
||||
playerUri: `https://i.giphy.com/media/${gifId}/200.webp`,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,7 +293,7 @@ export function parseEmbedPlayerFromUrl(
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: `https://giphy.com/gifs/${trackingOrId}`,
|
||||
playerUri: `https://i.giphy.com/media/${trackingOrId}/giphy.webp`,
|
||||
playerUri: `https://i.giphy.com/media/${trackingOrId}/200.webp`,
|
||||
}
|
||||
} else if (filename && gifFilenameRegex.test(filename)) {
|
||||
return {
|
||||
@@ -296,7 +302,7 @@ export function parseEmbedPlayerFromUrl(
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: `https://giphy.com/gifs/${idOrFilename}`,
|
||||
playerUri: `https://i.giphy.com/media/${idOrFilename}/giphy.webp`,
|
||||
playerUri: `https://i.giphy.com/media/${idOrFilename}/200.webp`,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -315,7 +321,7 @@ export function parseEmbedPlayerFromUrl(
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
metaUri: `https://giphy.com/gifs/${gifId}`,
|
||||
playerUri: `https://i.giphy.com/media/${gifId}/giphy.webp`,
|
||||
playerUri: `https://i.giphy.com/media/${gifId}/200.webp`,
|
||||
}
|
||||
} else if (mediaOrFilename) {
|
||||
const gifId = mediaOrFilename.split('.')[0]
|
||||
@@ -327,26 +333,48 @@ export function parseEmbedPlayerFromUrl(
|
||||
metaUri: `https://giphy.com/gifs/${gifId}`,
|
||||
playerUri: `https://i.giphy.com/media/${
|
||||
mediaOrFilename.split('.')[0]
|
||||
}/giphy.webp`,
|
||||
}/200.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 (urlp.hostname === 'media.tenor.com') {
|
||||
let [_, id, filename] = urlp.pathname.split('/')
|
||||
|
||||
if ((pathOrIntl === 'view' || pathOrFilename === 'view') && filename) {
|
||||
const includesExt = filename.split('.').pop() === 'gif'
|
||||
const h = urlp.searchParams.get('hh')
|
||||
const w = urlp.searchParams.get('ww')
|
||||
let dimensions
|
||||
if (h && w) {
|
||||
dimensions = {
|
||||
height: Number(h),
|
||||
width: Number(w),
|
||||
}
|
||||
}
|
||||
|
||||
if (id && filename && dimensions && id.includes('AAAAC')) {
|
||||
if (Platform.OS === 'web') {
|
||||
const isSafari = /^((?!chrome|android).)*safari/i.test(
|
||||
navigator.userAgent,
|
||||
)
|
||||
|
||||
if (isSafari) {
|
||||
id = id.replace('AAAAC', 'AAAP1')
|
||||
filename = filename.replace('.gif', '.mp4')
|
||||
} else {
|
||||
id = id.replace('AAAAC', 'AAAP3')
|
||||
filename = filename.replace('.gif', '.webm')
|
||||
}
|
||||
} else {
|
||||
id = id.replace('AAAAC', 'AAAAM')
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'tenor_gif',
|
||||
source: 'tenor',
|
||||
isGif: true,
|
||||
hideDetails: true,
|
||||
playerUri: `${url}${!includesExt ? '.gif' : ''}`,
|
||||
playerUri: `https://t.gifs.bsky.app/${id}/${filename}`,
|
||||
dimensions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import {AtUri} from '@atproto/api'
|
||||
import {BSKY_SERVICE} from 'lib/constants'
|
||||
import TLDs from 'tlds'
|
||||
import psl from 'psl'
|
||||
import TLDs from 'tlds'
|
||||
|
||||
import {BSKY_SERVICE} from 'lib/constants'
|
||||
|
||||
export const BSKY_APP_HOST = 'https://bsky.app'
|
||||
const BSKY_TRUSTED_HOSTS = [
|
||||
'bsky.app',
|
||||
'bsky.social',
|
||||
'blueskyweb.xyz',
|
||||
'blueskyweb.zendesk.com',
|
||||
'bsky\\.app',
|
||||
'bsky\\.social',
|
||||
'blueskyweb\\.xyz',
|
||||
'blueskyweb\\.zendesk\\.com',
|
||||
...(__DEV__ ? ['localhost:19006', 'localhost:8100'] : []),
|
||||
]
|
||||
|
||||
@@ -145,6 +146,13 @@ export function isBskyListUrl(url: string): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
export function isBskyDownloadUrl(url: string): boolean {
|
||||
if (isExternalUrl(url)) {
|
||||
return false
|
||||
}
|
||||
return url === '/download' || url.startsWith('/download?')
|
||||
}
|
||||
|
||||
export function convertBskyAppUrlIfNeeded(url: string): string {
|
||||
if (isBskyAppUrl(url)) {
|
||||
try {
|
||||
|
||||
+910
-679
File diff suppressed because it is too large
Load Diff
+974
-743
File diff suppressed because it is too large
Load Diff
+876
-1342
File diff suppressed because it is too large
Load Diff
+883
-652
File diff suppressed because it is too large
Load Diff
+921
-1390
File diff suppressed because it is too large
Load Diff
+1153
-1326
File diff suppressed because it is too large
Load Diff
+1114
-2115
File diff suppressed because it is too large
Load Diff
+881
-650
File diff suppressed because it is too large
Load Diff
+884
-653
File diff suppressed because it is too large
Load Diff
+884
-653
File diff suppressed because it is too large
Load Diff
+878
-1562
File diff suppressed because it is too large
Load Diff
+888
-708
File diff suppressed because it is too large
Load Diff
+907
-1194
File diff suppressed because it is too large
Load Diff
+908
-677
File diff suppressed because it is too large
Load Diff
+1150
-1616
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1174
-1615
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import {Platform} from 'react-native'
|
||||
import {getLocales} from 'expo-localization'
|
||||
|
||||
import {dedupArray} from 'lib/functions'
|
||||
|
||||
export const isIOS = Platform.OS === 'ios'
|
||||
@@ -18,3 +19,8 @@ export const deviceLocales = dedupArray(
|
||||
.map?.(locale => locale.languageCode)
|
||||
.filter(code => typeof code === 'string'),
|
||||
) as string[]
|
||||
|
||||
export const prefersReducedMotion =
|
||||
isWeb &&
|
||||
// @ts-ignore we know window exists -prf
|
||||
!global.window.matchMedia('(prefers-reduced-motion: no-preference)')?.matches
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user