Merge remote-tracking branch 'origin/main' into hailey/onboarding-profile
* origin/main: (222 commits) Release 1.77.0 (#3549) Improve the language behaviors around the PWI (#3545) fix: only close drawer if directly tapping backdrop (#3534) Fix Android in-app browser closing when switching apps (#3546) Cache DID and profile basic on profile card presses (#3523) Remove extra wrapper on notification user links (#3548) Fix hover card animation with a reducer (#3547) Update Ukrainian translation (#3537) add oEmbed links to public post HTML meta headers (#3522) rebased embedr (#3511) Increase hit slop for web's app language picker (#3535) Update Japanese translation (#3425) Fix oopsie (#3538) Fade in animation for card (#3521) [Embeds] Add ref_url (#3533) add ref_src (#3532) [Embed] Don't reuse DOM when changing embed (#3530) [Statsig] Send ref source (#3531) More obvious click area (#3528) add hideRecord prop (#3527) ...
This commit is contained in:
+88
-90
@@ -1,59 +1,52 @@
|
||||
import 'react-native-url-polyfill/auto'
|
||||
import 'lib/sentry' // must be near top
|
||||
|
||||
import React, {useState, useEffect} from 'react'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||
import {PersistQueryClientProvider} from '@tanstack/react-query-persist-client'
|
||||
import {
|
||||
SafeAreaProvider,
|
||||
initialWindowMetrics,
|
||||
} from 'react-native-safe-area-context'
|
||||
|
||||
import 'view/icons'
|
||||
|
||||
import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
import {listenSessionDropped} from './state/events'
|
||||
import {ThemeProvider} from 'lib/ThemeContext'
|
||||
import {s} from 'lib/styles'
|
||||
import {Shell} from 'view/shell'
|
||||
import * as notifications from 'lib/notifications/notifications'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import {
|
||||
queryClient,
|
||||
asyncStoragePersister,
|
||||
dehydrateOptions,
|
||||
} from 'lib/react-query'
|
||||
import {TestCtrls} from 'view/com/testing/TestCtrls'
|
||||
import {Provider as ShellStateProvider} from 'state/shell'
|
||||
import {Provider as ModalStateProvider} from 'state/modals'
|
||||
import {Provider as DialogStateProvider} from 'state/dialogs'
|
||||
import {Provider as LightboxStateProvider} from 'state/lightbox'
|
||||
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
|
||||
import {Provider as InvitesStateProvider} from 'state/invites'
|
||||
import {Provider as PrefsStateProvider} from 'state/preferences'
|
||||
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
|
||||
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
|
||||
initialWindowMetrics,
|
||||
SafeAreaProvider,
|
||||
} from 'react-native-safe-area-context'
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
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 I18nProvider from './locale/i18nProvider'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
import {useNotificationsListener} from 'lib/notifications/notifications'
|
||||
import {QueryProvider} from 'lib/react-query'
|
||||
import {s} from 'lib/styles'
|
||||
import {ThemeProvider} from 'lib/ThemeContext'
|
||||
import {Provider as DialogStateProvider} from 'state/dialogs'
|
||||
import {Provider as InvitesStateProvider} from 'state/invites'
|
||||
import {Provider as LightboxStateProvider} from 'state/lightbox'
|
||||
import {Provider as ModalStateProvider} from 'state/modals'
|
||||
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
|
||||
import {Provider as PrefsStateProvider} from 'state/preferences'
|
||||
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||
import {
|
||||
Provider as SessionProvider,
|
||||
useSession,
|
||||
useSessionApi,
|
||||
} from 'state/session'
|
||||
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {Splash} from '#/Splash'
|
||||
import {Provider as ShellStateProvider} from 'state/shell'
|
||||
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
|
||||
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
|
||||
import {TestCtrls} from 'view/com/testing/TestCtrls'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {Shell} from 'view/shell'
|
||||
import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
import {StatusBar} from 'expo-status-bar'
|
||||
import {isAndroid} from 'platform/detection'
|
||||
import {Splash} from '#/Splash'
|
||||
import I18nProvider from './locale/i18nProvider'
|
||||
import {listenSessionDropped} from './state/events'
|
||||
|
||||
SplashScreen.preventAutoHideAsync()
|
||||
|
||||
@@ -62,11 +55,11 @@ function InnerApp() {
|
||||
const {resumeSession} = useSessionApi()
|
||||
const theme = useColorModeTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
useIntentHandler()
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
notifications.init(queryClient)
|
||||
listenSessionDropped(() => {
|
||||
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
|
||||
})
|
||||
@@ -77,31 +70,34 @@ function InnerApp() {
|
||||
|
||||
return (
|
||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||
{isAndroid && <StatusBar />}
|
||||
<Alf theme={theme}>
|
||||
<Splash isReady={!isInitialLoad}>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<StatsigProvider>
|
||||
<LabelDefsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* All components should be within this provider */}
|
||||
<RootSiblingParent>
|
||||
<GestureHandlerRootView style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</RootSiblingParent>
|
||||
</ThemeProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</LabelDefsProvider>
|
||||
</StatsigProvider>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<PushNotificationsListener>
|
||||
<StatsigProvider>
|
||||
<LabelDefsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* All components should be within this provider */}
|
||||
<RootSiblingParent>
|
||||
<GestureHandlerRootView style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</RootSiblingParent>
|
||||
</ThemeProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</LabelDefsProvider>
|
||||
</StatsigProvider>
|
||||
</PushNotificationsListener>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
</Splash>
|
||||
</Alf>
|
||||
@@ -109,6 +105,12 @@ function InnerApp() {
|
||||
)
|
||||
}
|
||||
|
||||
function PushNotificationsListener({children}: {children: React.ReactNode}) {
|
||||
const queryClient = useQueryClient()
|
||||
useNotificationsListener(queryClient)
|
||||
return children
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [isReady, setReady] = useState(false)
|
||||
|
||||
@@ -125,31 +127,27 @@ function App() {
|
||||
* that is set up in the InnerApp component above.
|
||||
*/
|
||||
return (
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{persister: asyncStoragePersister, dehydrateOptions}}>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<MutedThreadsProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PortalProvider>
|
||||
<InnerApp />
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</MutedThreadsProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
</PersistQueryClientProvider>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<MutedThreadsProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PortalProvider>
|
||||
<InnerApp />
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</MutedThreadsProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+66
-74
@@ -1,44 +1,38 @@
|
||||
import 'lib/sentry' // must be near top
|
||||
|
||||
import React, {useState, useEffect} from 'react'
|
||||
import {PersistQueryClientProvider} from '@tanstack/react-query-persist-client'
|
||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
|
||||
import 'view/icons'
|
||||
|
||||
import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
import {Shell} from 'view/shell/index'
|
||||
import {ToastContainer} from 'view/com/util/Toast.web'
|
||||
import {ThemeProvider} from 'lib/ThemeContext'
|
||||
import {
|
||||
queryClient,
|
||||
asyncStoragePersister,
|
||||
dehydrateOptions,
|
||||
} from 'lib/react-query'
|
||||
import {Provider as ShellStateProvider} from 'state/shell'
|
||||
import {Provider as ModalStateProvider} from 'state/modals'
|
||||
import {Provider as DialogStateProvider} from 'state/dialogs'
|
||||
import {Provider as LightboxStateProvider} from 'state/lightbox'
|
||||
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
|
||||
import {Provider as InvitesStateProvider} from 'state/invites'
|
||||
import {Provider as PrefsStateProvider} from 'state/preferences'
|
||||
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
|
||||
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||
import I18nProvider from './locale/i18nProvider'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
import {QueryProvider} from 'lib/react-query'
|
||||
import {ThemeProvider} from 'lib/ThemeContext'
|
||||
import {Provider as DialogStateProvider} from 'state/dialogs'
|
||||
import {Provider as InvitesStateProvider} from 'state/invites'
|
||||
import {Provider as LightboxStateProvider} from 'state/lightbox'
|
||||
import {Provider as ModalStateProvider} from 'state/modals'
|
||||
import {Provider as MutedThreadsProvider} from 'state/muted-threads'
|
||||
import {Provider as PrefsStateProvider} from 'state/preferences'
|
||||
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||
import {
|
||||
Provider as SessionProvider,
|
||||
useSession,
|
||||
useSessionApi,
|
||||
} from 'state/session'
|
||||
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {Provider as ShellStateProvider} from 'state/shell'
|
||||
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
|
||||
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
|
||||
import {ToastContainer} from 'view/com/util/Toast.web'
|
||||
import {Shell} from 'view/shell/index'
|
||||
import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
import I18nProvider from './locale/i18nProvider'
|
||||
|
||||
function InnerApp() {
|
||||
const {isInitialLoad, currentAccount} = useSession()
|
||||
@@ -60,25 +54,27 @@ function InnerApp() {
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<StatsigProvider>
|
||||
<LabelDefsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* All components should be within this provider */}
|
||||
<RootSiblingParent>
|
||||
<SafeAreaProvider>
|
||||
<Shell />
|
||||
</SafeAreaProvider>
|
||||
</RootSiblingParent>
|
||||
<ToastContainer />
|
||||
</ThemeProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</LabelDefsProvider>
|
||||
</StatsigProvider>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<StatsigProvider>
|
||||
<LabelDefsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* All components should be within this provider */}
|
||||
<RootSiblingParent>
|
||||
<SafeAreaProvider>
|
||||
<Shell />
|
||||
</SafeAreaProvider>
|
||||
</RootSiblingParent>
|
||||
<ToastContainer />
|
||||
</ThemeProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</LabelDefsProvider>
|
||||
</StatsigProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
</Alf>
|
||||
)
|
||||
@@ -100,31 +96,27 @@ function App() {
|
||||
* that is set up in the InnerApp component above.
|
||||
*/
|
||||
return (
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={{persister: asyncStoragePersister, dehydrateOptions}}>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<MutedThreadsProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PortalProvider>
|
||||
<InnerApp />
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</MutedThreadsProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
</PersistQueryClientProvider>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<MutedThreadsProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PortalProvider>
|
||||
<InnerApp />
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</MutedThreadsProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+94
-89
@@ -1,86 +1,86 @@
|
||||
import * as React from 'react'
|
||||
import {
|
||||
NavigationContainer,
|
||||
createNavigationContainerRef,
|
||||
CommonActions,
|
||||
StackActions,
|
||||
DefaultTheme,
|
||||
DarkTheme,
|
||||
} from '@react-navigation/native'
|
||||
import {JSX} from 'react/jsx-runtime'
|
||||
import {i18n, MessageDescriptor} from '@lingui/core'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {
|
||||
BottomTabBarProps,
|
||||
createBottomTabNavigator,
|
||||
} from '@react-navigation/bottom-tabs'
|
||||
import {
|
||||
HomeTabNavigatorParams,
|
||||
SearchTabNavigatorParams,
|
||||
FeedsTabNavigatorParams,
|
||||
NotificationsTabNavigatorParams,
|
||||
FlatNavigatorParams,
|
||||
AllNavigatorParams,
|
||||
MyProfileTabNavigatorParams,
|
||||
BottomTabNavigatorParams,
|
||||
} from 'lib/routes/types'
|
||||
import {BottomBar} from './view/shell/bottom-bar/BottomBar'
|
||||
import {buildStateObject} from 'lib/routes/helpers'
|
||||
import {State, RouteParams} from 'lib/routes/types'
|
||||
import {isAndroid, isNative} from 'platform/detection'
|
||||
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
||||
import {router} from './routes'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {bskyTitle} from 'lib/strings/headings'
|
||||
import {JSX} from 'react/jsx-runtime'
|
||||
import {timeout} from 'lib/async/timeout'
|
||||
import {useUnreadNotifications} from './state/queries/notifications/unread'
|
||||
import {useSession} from './state/session'
|
||||
import {useModalControls} from './state/modals'
|
||||
import {
|
||||
shouldRequestEmailConfirmation,
|
||||
setEmailConfirmationRequested,
|
||||
} from './state/shell/reminders'
|
||||
import {init as initAnalytics} from './lib/analytics/analytics'
|
||||
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
|
||||
CommonActions,
|
||||
createNavigationContainerRef,
|
||||
DarkTheme,
|
||||
DefaultTheme,
|
||||
NavigationContainer,
|
||||
StackActions,
|
||||
} from '@react-navigation/native'
|
||||
|
||||
import {HomeScreen} from './view/screens/Home'
|
||||
import {SearchScreen} from './view/screens/Search'
|
||||
import {FeedsScreen} from './view/screens/Feeds'
|
||||
import {NotificationsScreen} from './view/screens/Notifications'
|
||||
import {ListsScreen} from './view/screens/Lists'
|
||||
import {ModerationScreen} from '#/screens/Moderation'
|
||||
import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
|
||||
import {NotFoundScreen} from './view/screens/NotFound'
|
||||
import {SettingsScreen} from './view/screens/Settings'
|
||||
import {LanguageSettingsScreen} from './view/screens/LanguageSettings'
|
||||
import {ProfileScreen} from './view/screens/Profile'
|
||||
import {ProfileFollowersScreen} from './view/screens/ProfileFollowers'
|
||||
import {ProfileFollowsScreen} from './view/screens/ProfileFollows'
|
||||
import {ProfileFeedScreen} from './view/screens/ProfileFeed'
|
||||
import {ProfileFeedLikedByScreen} from './view/screens/ProfileFeedLikedBy'
|
||||
import {ProfileListScreen} from './view/screens/ProfileList'
|
||||
import {PostThreadScreen} from './view/screens/PostThread'
|
||||
import {PostLikedByScreen} from './view/screens/PostLikedBy'
|
||||
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
|
||||
import {Storybook} from './view/screens/Storybook'
|
||||
import {DebugModScreen} from './view/screens/DebugMod'
|
||||
import {LogScreen} from './view/screens/Log'
|
||||
import {SupportScreen} from './view/screens/Support'
|
||||
import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy'
|
||||
import {TermsOfServiceScreen} from './view/screens/TermsOfService'
|
||||
import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
|
||||
import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy'
|
||||
import {timeout} from 'lib/async/timeout'
|
||||
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {buildStateObject} from 'lib/routes/helpers'
|
||||
import {
|
||||
AllNavigatorParams,
|
||||
BottomTabNavigatorParams,
|
||||
FeedsTabNavigatorParams,
|
||||
FlatNavigatorParams,
|
||||
HomeTabNavigatorParams,
|
||||
MyProfileTabNavigatorParams,
|
||||
NotificationsTabNavigatorParams,
|
||||
SearchTabNavigatorParams,
|
||||
} from 'lib/routes/types'
|
||||
import {RouteParams, State} from 'lib/routes/types'
|
||||
import {bskyTitle} from 'lib/strings/headings'
|
||||
import {isAndroid, isNative} from 'platform/detection'
|
||||
import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds'
|
||||
import {AppPasswords} from 'view/screens/AppPasswords'
|
||||
import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
|
||||
import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
|
||||
import {SavedFeeds} from 'view/screens/SavedFeeds'
|
||||
import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
|
||||
import {PreferencesFollowingFeed} from 'view/screens/PreferencesFollowingFeed'
|
||||
import {PreferencesThreads} from 'view/screens/PreferencesThreads'
|
||||
import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds'
|
||||
import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {i18n, MessageDescriptor} from '@lingui/core'
|
||||
import {SavedFeeds} from 'view/screens/SavedFeeds'
|
||||
import HashtagScreen from '#/screens/Hashtag'
|
||||
import {ModerationScreen} from '#/screens/Moderation'
|
||||
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
||||
import {logEvent, attachRouteToLogEvents} from './lib/statsig/statsig'
|
||||
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 {useModalControls} from './state/modals'
|
||||
import {useUnreadNotifications} from './state/queries/notifications/unread'
|
||||
import {useSession} from './state/session'
|
||||
import {
|
||||
setEmailConfirmationRequested,
|
||||
shouldRequestEmailConfirmation,
|
||||
} from './state/shell/reminders'
|
||||
import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
|
||||
import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy'
|
||||
import {DebugModScreen} from './view/screens/DebugMod'
|
||||
import {FeedsScreen} from './view/screens/Feeds'
|
||||
import {HomeScreen} from './view/screens/Home'
|
||||
import {LanguageSettingsScreen} from './view/screens/LanguageSettings'
|
||||
import {ListsScreen} from './view/screens/Lists'
|
||||
import {LogScreen} from './view/screens/Log'
|
||||
import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
|
||||
import {NotFoundScreen} from './view/screens/NotFound'
|
||||
import {NotificationsScreen} from './view/screens/Notifications'
|
||||
import {PostLikedByScreen} from './view/screens/PostLikedBy'
|
||||
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
|
||||
import {PostThreadScreen} from './view/screens/PostThread'
|
||||
import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy'
|
||||
import {ProfileScreen} from './view/screens/Profile'
|
||||
import {ProfileFeedScreen} from './view/screens/ProfileFeed'
|
||||
import {ProfileFeedLikedByScreen} from './view/screens/ProfileFeedLikedBy'
|
||||
import {ProfileFollowersScreen} from './view/screens/ProfileFollowers'
|
||||
import {ProfileFollowsScreen} from './view/screens/ProfileFollows'
|
||||
import {ProfileListScreen} from './view/screens/ProfileList'
|
||||
import {SearchScreen} from './view/screens/Search'
|
||||
import {SettingsScreen} from './view/screens/Settings'
|
||||
import {Storybook} from './view/screens/Storybook'
|
||||
import {SupportScreen} from './view/screens/Support'
|
||||
import {TermsOfServiceScreen} from './view/screens/TermsOfService'
|
||||
import {BottomBar} from './view/shell/bottom-bar/BottomBar'
|
||||
import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
|
||||
|
||||
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
||||
|
||||
@@ -193,7 +193,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"
|
||||
@@ -331,11 +331,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 +367,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>
|
||||
)
|
||||
@@ -451,7 +443,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,7 +453,7 @@ const FlatNavigator = () => {
|
||||
<Flat.Screen
|
||||
name="Feeds"
|
||||
getComponent={() => FeedsScreen}
|
||||
options={{title: title(msg`Feeds`), requireAuth: true}}
|
||||
options={{title: title(msg`Feeds`)}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="Notifications"
|
||||
@@ -539,8 +531,10 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
const theme = useColorSchemeStyle(DefaultTheme, DarkTheme)
|
||||
const {currentAccount} = useSession()
|
||||
const {openModal} = useModalControls()
|
||||
const prevLoggedRouteName = React.useRef<string | undefined>(undefined)
|
||||
|
||||
function onReady() {
|
||||
prevLoggedRouteName.current = getCurrentRouteName()
|
||||
initAnalytics(currentAccount)
|
||||
|
||||
if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) {
|
||||
@@ -554,10 +548,17 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
ref={navigationRef}
|
||||
linking={LINKING}
|
||||
theme={theme}
|
||||
onStateChange={() => {
|
||||
logEvent('router:navigate', {
|
||||
from: prevLoggedRouteName.current,
|
||||
})
|
||||
prevLoggedRouteName.current = getCurrentRouteName()
|
||||
}}
|
||||
onReady={() => {
|
||||
attachRouteToLogEvents(getCurrentRouteName)
|
||||
logModuleInitTime()
|
||||
onReady()
|
||||
logEvent('router:navigate', {})
|
||||
}}>
|
||||
{children}
|
||||
</NavigationContainer>
|
||||
@@ -565,7 +566,11 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
}
|
||||
|
||||
function getCurrentRouteName() {
|
||||
return navigationRef.getCurrentRoute()?.name
|
||||
if (navigationRef.isReady()) {
|
||||
return navigationRef.getCurrentRoute()?.name
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -689,11 +694,11 @@ function logModuleInitTime() {
|
||||
}
|
||||
|
||||
export {
|
||||
navigate,
|
||||
resetToTab,
|
||||
reset,
|
||||
handleLink,
|
||||
TabsNavigator,
|
||||
FlatNavigator,
|
||||
handleLink,
|
||||
navigate,
|
||||
reset,
|
||||
resetToTab,
|
||||
RoutesContainer,
|
||||
TabsNavigator,
|
||||
}
|
||||
|
||||
+78
-6
@@ -1,6 +1,7 @@
|
||||
import {Platform} from 'react-native'
|
||||
import {web, native} from '#/alf/util/platform'
|
||||
|
||||
import * as tokens from '#/alf/tokens'
|
||||
import {native, web} from '#/alf/util/platform'
|
||||
|
||||
export const atoms = {
|
||||
/*
|
||||
@@ -157,6 +158,12 @@ export const atoms = {
|
||||
align_end: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
align_baseline: {
|
||||
alignItems: 'baseline',
|
||||
},
|
||||
align_stretch: {
|
||||
alignItems: 'stretch',
|
||||
},
|
||||
self_auto: {
|
||||
alignSelf: 'auto',
|
||||
},
|
||||
@@ -247,10 +254,10 @@ export const atoms = {
|
||||
fontWeight: tokens.fontWeight.normal,
|
||||
},
|
||||
font_semibold: {
|
||||
fontWeight: '500',
|
||||
fontWeight: tokens.fontWeight.semibold,
|
||||
},
|
||||
font_bold: {
|
||||
fontWeight: tokens.fontWeight.semibold,
|
||||
fontWeight: tokens.fontWeight.bold,
|
||||
},
|
||||
italic: {
|
||||
fontStyle: 'italic',
|
||||
@@ -300,6 +307,9 @@ export const atoms = {
|
||||
/*
|
||||
* Padding
|
||||
*/
|
||||
p_0: {
|
||||
padding: 0,
|
||||
},
|
||||
p_2xs: {
|
||||
padding: tokens.space._2xs,
|
||||
},
|
||||
@@ -330,6 +340,10 @@ export const atoms = {
|
||||
p_5xl: {
|
||||
padding: tokens.space._5xl,
|
||||
},
|
||||
px_0: {
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
},
|
||||
px_2xs: {
|
||||
paddingLeft: tokens.space._2xs,
|
||||
paddingRight: tokens.space._2xs,
|
||||
@@ -370,6 +384,10 @@ export const atoms = {
|
||||
paddingLeft: tokens.space._5xl,
|
||||
paddingRight: tokens.space._5xl,
|
||||
},
|
||||
py_0: {
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
py_2xs: {
|
||||
paddingTop: tokens.space._2xs,
|
||||
paddingBottom: tokens.space._2xs,
|
||||
@@ -410,6 +428,9 @@ export const atoms = {
|
||||
paddingTop: tokens.space._5xl,
|
||||
paddingBottom: tokens.space._5xl,
|
||||
},
|
||||
pt_0: {
|
||||
paddingTop: 0,
|
||||
},
|
||||
pt_2xs: {
|
||||
paddingTop: tokens.space._2xs,
|
||||
},
|
||||
@@ -440,6 +461,9 @@ export const atoms = {
|
||||
pt_5xl: {
|
||||
paddingTop: tokens.space._5xl,
|
||||
},
|
||||
pb_0: {
|
||||
paddingBottom: 0,
|
||||
},
|
||||
pb_2xs: {
|
||||
paddingBottom: tokens.space._2xs,
|
||||
},
|
||||
@@ -470,6 +494,9 @@ export const atoms = {
|
||||
pb_5xl: {
|
||||
paddingBottom: tokens.space._5xl,
|
||||
},
|
||||
pl_0: {
|
||||
paddingLeft: 0,
|
||||
},
|
||||
pl_2xs: {
|
||||
paddingLeft: tokens.space._2xs,
|
||||
},
|
||||
@@ -500,6 +527,9 @@ export const atoms = {
|
||||
pl_5xl: {
|
||||
paddingLeft: tokens.space._5xl,
|
||||
},
|
||||
pr_0: {
|
||||
paddingRight: 0,
|
||||
},
|
||||
pr_2xs: {
|
||||
paddingRight: tokens.space._2xs,
|
||||
},
|
||||
@@ -534,9 +564,8 @@ export const atoms = {
|
||||
/*
|
||||
* Margin
|
||||
*/
|
||||
mx_auto: {
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
m_0: {
|
||||
margin: 0,
|
||||
},
|
||||
m_2xs: {
|
||||
margin: tokens.space._2xs,
|
||||
@@ -568,6 +597,13 @@ export const atoms = {
|
||||
m_5xl: {
|
||||
margin: tokens.space._5xl,
|
||||
},
|
||||
m_auto: {
|
||||
margin: 'auto',
|
||||
},
|
||||
mx_0: {
|
||||
marginLeft: 0,
|
||||
marginRight: 0,
|
||||
},
|
||||
mx_2xs: {
|
||||
marginLeft: tokens.space._2xs,
|
||||
marginRight: tokens.space._2xs,
|
||||
@@ -608,6 +644,14 @@ export const atoms = {
|
||||
marginLeft: tokens.space._5xl,
|
||||
marginRight: tokens.space._5xl,
|
||||
},
|
||||
mx_auto: {
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
},
|
||||
my_0: {
|
||||
marginTop: 0,
|
||||
marginBottom: 0,
|
||||
},
|
||||
my_2xs: {
|
||||
marginTop: tokens.space._2xs,
|
||||
marginBottom: tokens.space._2xs,
|
||||
@@ -648,6 +692,13 @@ export const atoms = {
|
||||
marginTop: tokens.space._5xl,
|
||||
marginBottom: tokens.space._5xl,
|
||||
},
|
||||
my_auto: {
|
||||
marginTop: 'auto',
|
||||
marginBottom: 'auto',
|
||||
},
|
||||
mt_0: {
|
||||
marginTop: 0,
|
||||
},
|
||||
mt_2xs: {
|
||||
marginTop: tokens.space._2xs,
|
||||
},
|
||||
@@ -678,6 +729,12 @@ export const atoms = {
|
||||
mt_5xl: {
|
||||
marginTop: tokens.space._5xl,
|
||||
},
|
||||
mt_auto: {
|
||||
marginTop: 'auto',
|
||||
},
|
||||
mb_0: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
mb_2xs: {
|
||||
marginBottom: tokens.space._2xs,
|
||||
},
|
||||
@@ -708,6 +765,12 @@ export const atoms = {
|
||||
mb_5xl: {
|
||||
marginBottom: tokens.space._5xl,
|
||||
},
|
||||
mb_auto: {
|
||||
marginBottom: 'auto',
|
||||
},
|
||||
ml_0: {
|
||||
marginLeft: 0,
|
||||
},
|
||||
ml_2xs: {
|
||||
marginLeft: tokens.space._2xs,
|
||||
},
|
||||
@@ -738,6 +801,12 @@ export const atoms = {
|
||||
ml_5xl: {
|
||||
marginLeft: tokens.space._5xl,
|
||||
},
|
||||
ml_auto: {
|
||||
marginLeft: 'auto',
|
||||
},
|
||||
mr_0: {
|
||||
marginRight: 0,
|
||||
},
|
||||
mr_2xs: {
|
||||
marginRight: tokens.space._2xs,
|
||||
},
|
||||
@@ -768,4 +837,7 @@ export const atoms = {
|
||||
mr_5xl: {
|
||||
marginRight: tokens.space._5xl,
|
||||
},
|
||||
mr_auto: {
|
||||
marginRight: 'auto',
|
||||
},
|
||||
} as const
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
BLUE_HUE,
|
||||
RED_HUE,
|
||||
GREEN_HUE,
|
||||
generateScale,
|
||||
GREEN_HUE,
|
||||
RED_HUE,
|
||||
} from '#/alf/util/colorGeneration'
|
||||
|
||||
export const scale = generateScale(6, 100)
|
||||
@@ -116,8 +116,8 @@ export const borderRadius = {
|
||||
|
||||
export const fontWeight = {
|
||||
normal: '400',
|
||||
semibold: '600',
|
||||
bold: '900',
|
||||
semibold: '500',
|
||||
bold: '600',
|
||||
} as const
|
||||
|
||||
export const gradients = {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {type SessionAccount, useSession} from '#/state/session'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as Chevron} from '#/components/icons/Chevron'
|
||||
import {Button} from './Button'
|
||||
import {Text} from './Typography'
|
||||
|
||||
export function AccountList({
|
||||
onSelectAccount,
|
||||
onSelectOther,
|
||||
otherLabel,
|
||||
}: {
|
||||
onSelectAccount: (account: SessionAccount) => void
|
||||
onSelectOther: () => void
|
||||
otherLabel?: string
|
||||
}) {
|
||||
const {isSwitchingAccounts, currentAccount, accounts} = useSession()
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const onPressAddAccount = useCallback(() => {
|
||||
onSelectOther()
|
||||
}, [onSelectOther])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
{accounts.map(account => (
|
||||
<React.Fragment key={account.did}>
|
||||
<AccountItem
|
||||
account={account}
|
||||
onSelect={onSelectAccount}
|
||||
isCurrentAccount={account.did === currentAccount?.did}
|
||||
/>
|
||||
<View style={[a.border_b, t.atoms.border_contrast_low]} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
<Button
|
||||
testID="chooseAddAccountBtn"
|
||||
style={[a.flex_1]}
|
||||
onPress={isSwitchingAccounts ? undefined : onPressAddAccount}
|
||||
label={_(msg`Login to account that is not listed`)}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
{height: 48},
|
||||
(hovered || pressed || isSwitchingAccounts) &&
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.align_baseline,
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.py_sm,
|
||||
{paddingLeft: 48},
|
||||
]}>
|
||||
{otherLabel ?? <Trans>Other account</Trans>}
|
||||
</Text>
|
||||
<Chevron size="sm" style={[t.atoms.text, a.mr_md]} />
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountItem({
|
||||
account,
|
||||
onSelect,
|
||||
isCurrentAccount,
|
||||
}: {
|
||||
account: SessionAccount
|
||||
onSelect: (account: SessionAccount) => void
|
||||
isCurrentAccount: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {data: profile} = useProfileQuery({did: account.did})
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
onSelect(account)
|
||||
}, [account, onSelect])
|
||||
|
||||
return (
|
||||
<Button
|
||||
testID={`chooseAccountBtn-${account.handle}`}
|
||||
key={account.did}
|
||||
style={[a.flex_1]}
|
||||
onPress={onPress}
|
||||
label={
|
||||
isCurrentAccount
|
||||
? _(msg`Continue as ${account.handle} (currently signed in)`)
|
||||
: _(msg`Sign in as ${account.handle}`)
|
||||
}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
{height: 48},
|
||||
(hovered || pressed) && t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<View style={a.p_md}>
|
||||
<UserAvatar avatar={profile?.avatar} size={24} />
|
||||
</View>
|
||||
<Text style={[a.align_baseline, a.flex_1, a.flex_row, a.py_sm]}>
|
||||
<Text style={[a.font_bold]}>
|
||||
{profile?.displayName || account.handle}{' '}
|
||||
</Text>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>{account.handle}</Text>
|
||||
</Text>
|
||||
{isCurrentAccount ? (
|
||||
<Check
|
||||
size="sm"
|
||||
style={[{color: t.palette.positive_600}, a.mr_md]}
|
||||
/>
|
||||
) : (
|
||||
<Chevron size="sm" style={[t.atoms.text, a.mr_md]} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -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,79 @@
|
||||
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)},
|
||||
a.flex_row,
|
||||
a.gap_sm,
|
||||
a.align_center,
|
||||
a.flex_shrink,
|
||||
]}>
|
||||
<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_shrink} />
|
||||
|
||||
<select
|
||||
value={sanitizedLang}
|
||||
onChange={onChangeAppLanguage}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
MozAppearance: 'none',
|
||||
WebkitAppearance: 'none',
|
||||
appearance: 'none',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
color: 'transparent',
|
||||
background: 'transparent',
|
||||
border: 0,
|
||||
padding: 0,
|
||||
}}>
|
||||
{APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => (
|
||||
<option key={l.code2} value={l.code2}>
|
||||
{l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
+17
-21
@@ -1,19 +1,19 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Pressable,
|
||||
Text,
|
||||
PressableProps,
|
||||
TextProps,
|
||||
ViewStyle,
|
||||
AccessibilityProps,
|
||||
View,
|
||||
TextStyle,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
PressableProps,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextProps,
|
||||
TextStyle,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
|
||||
import {useTheme, atoms as a, tokens, android, flatten} from '#/alf'
|
||||
import {android, atoms as a, flatten, tokens, useTheme} from '#/alf'
|
||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {normalizeTextStyles} from '#/components/Typography'
|
||||
|
||||
@@ -58,6 +58,10 @@ export type ButtonState = {
|
||||
|
||||
export type ButtonContext = VariantProps & ButtonState
|
||||
|
||||
type NonTextElements =
|
||||
| React.ReactElement
|
||||
| Iterable<React.ReactElement | null | undefined | boolean>
|
||||
|
||||
export type ButtonProps = Pick<
|
||||
PressableProps,
|
||||
'disabled' | 'onPress' | 'testID'
|
||||
@@ -67,11 +71,9 @@ export type ButtonProps = Pick<
|
||||
testID?: string
|
||||
label: string
|
||||
style?: StyleProp<ViewStyle>
|
||||
children:
|
||||
| React.ReactNode
|
||||
| string
|
||||
| ((context: ButtonContext) => React.ReactNode | string)
|
||||
children: NonTextElements | ((context: ButtonContext) => NonTextElements)
|
||||
}
|
||||
|
||||
export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
|
||||
|
||||
const Context = React.createContext<VariantProps & ButtonState>({
|
||||
@@ -403,13 +405,7 @@ export function Button({
|
||||
</View>
|
||||
)}
|
||||
<Context.Provider value={context}>
|
||||
{typeof children === 'string' ? (
|
||||
<ButtonText>{children}</ButtonText>
|
||||
) : typeof children === 'function' ? (
|
||||
children(context)
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
{typeof children === 'function' ? children(context) : children}
|
||||
</Context.Provider>
|
||||
</Pressable>
|
||||
)
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import React, {useImperativeHandle} from 'react'
|
||||
import {View, Dimensions, Keyboard, Pressable} from 'react-native'
|
||||
import {Dimensions, Pressable, View} from 'react-native'
|
||||
import Animated, {useAnimatedStyle} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import BottomSheet, {
|
||||
BottomSheetBackdropProps,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetScrollViewMethods,
|
||||
BottomSheetTextInput,
|
||||
BottomSheetView,
|
||||
useBottomSheet,
|
||||
WINDOW_HEIGHT,
|
||||
} from '@gorhom/bottom-sheet'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import Animated, {useAnimatedStyle} from 'react-native-reanimated'
|
||||
} from '@discord/bottom-sheet/src'
|
||||
|
||||
import {useTheme, atoms as a, flatten} from '#/alf'
|
||||
import {Portal} from '#/components/Portal'
|
||||
import {createInput} from '#/components/forms/TextField'
|
||||
import {logger} from '#/logger'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
|
||||
import {isNative} from 'platform/detection'
|
||||
import {atoms as a, flatten, useTheme} from '#/alf'
|
||||
import {Context} from '#/components/Dialog/context'
|
||||
import {
|
||||
DialogOuterProps,
|
||||
DialogControlProps,
|
||||
DialogInnerProps,
|
||||
DialogOuterProps,
|
||||
} from '#/components/Dialog/types'
|
||||
import {Context} from '#/components/Dialog/context'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {createInput} from '#/components/forms/TextField'
|
||||
import {Portal} from '#/components/Portal'
|
||||
|
||||
export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
|
||||
export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
|
||||
export * from '#/components/Dialog/types'
|
||||
// @ts-ignore
|
||||
export const Input = createInput(BottomSheetTextInput)
|
||||
@@ -83,7 +83,7 @@ export function Outer({
|
||||
const sheetOptions = nativeOptions?.sheet || {}
|
||||
const hasSnapPoints = !!sheetOptions.snapPoints
|
||||
const insets = useSafeAreaInsets()
|
||||
const closeCallback = React.useRef<() => void>()
|
||||
const closeCallbacks = React.useRef<(() => void)[]>([])
|
||||
const {setDialogIsOpen} = useDialogStateControlContext()
|
||||
|
||||
/*
|
||||
@@ -96,22 +96,51 @@ export function Outer({
|
||||
*/
|
||||
const isOpen = openIndex > -1
|
||||
|
||||
const callQueuedCallbacks = React.useCallback(() => {
|
||||
for (const cb of closeCallbacks.current) {
|
||||
try {
|
||||
cb()
|
||||
} catch (e: any) {
|
||||
logger.error('Error running close callback', e)
|
||||
}
|
||||
}
|
||||
|
||||
closeCallbacks.current = []
|
||||
}, [])
|
||||
|
||||
const open = React.useCallback<DialogControlProps['open']>(
|
||||
({index} = {}) => {
|
||||
// Run any leftover callbacks that might have been queued up before calling `.open()`
|
||||
callQueuedCallbacks()
|
||||
|
||||
setDialogIsOpen(control.id, true)
|
||||
// can be set to any index of `snapPoints`, but `0` is the first i.e. "open"
|
||||
setOpenIndex(index || 0)
|
||||
sheet.current?.snapToIndex(index || 0)
|
||||
},
|
||||
[setOpenIndex, setDialogIsOpen, control.id],
|
||||
[setDialogIsOpen, control.id, callQueuedCallbacks],
|
||||
)
|
||||
|
||||
// This is the function that we call when we want to dismiss the dialog.
|
||||
const close = React.useCallback<DialogControlProps['close']>(cb => {
|
||||
if (cb && typeof cb === 'function') {
|
||||
closeCallback.current = cb
|
||||
if (typeof cb === 'function') {
|
||||
closeCallbacks.current.push(cb)
|
||||
}
|
||||
sheet.current?.close()
|
||||
}, [])
|
||||
|
||||
// This is the actual thing we are doing once we "confirm" the dialog. We want the dialog's close animation to
|
||||
// happen before we run this. It is passed to the `BottomSheet` component.
|
||||
const onCloseAnimationComplete = React.useCallback(() => {
|
||||
// This removes the dialog from our list of stored dialogs. Not super necessary on iOS, but on Android this
|
||||
// tells us that we need to toggle the accessibility overlay setting
|
||||
setDialogIsOpen(control.id, false)
|
||||
setOpenIndex(-1)
|
||||
|
||||
callQueuedCallbacks()
|
||||
onClose?.()
|
||||
}, [callQueuedCallbacks, control.id, onClose, setDialogIsOpen])
|
||||
|
||||
useImperativeHandle(
|
||||
control.ref,
|
||||
() => ({
|
||||
@@ -121,22 +150,6 @@ export function Outer({
|
||||
[open, close],
|
||||
)
|
||||
|
||||
const onCloseInner = React.useCallback(() => {
|
||||
Keyboard.dismiss()
|
||||
try {
|
||||
closeCallback.current?.()
|
||||
} catch (e: any) {
|
||||
logger.error(`Dialog closeCallback failed`, {
|
||||
message: e.message,
|
||||
})
|
||||
} finally {
|
||||
closeCallback.current = undefined
|
||||
}
|
||||
setDialogIsOpen(control.id, false)
|
||||
onClose?.()
|
||||
setOpenIndex(-1)
|
||||
}, [control.id, onClose, setDialogIsOpen])
|
||||
|
||||
const context = React.useMemo(() => ({close}), [close])
|
||||
|
||||
return (
|
||||
@@ -164,7 +177,7 @@ export function Outer({
|
||||
backdropComponent={Backdrop}
|
||||
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
|
||||
handleStyle={{display: 'none'}}
|
||||
onClose={onCloseInner}>
|
||||
onClose={onCloseAnimationComplete}>
|
||||
<Context.Provider value={context}>
|
||||
<View
|
||||
style={[
|
||||
@@ -190,14 +203,13 @@ export function Outer({
|
||||
export function Inner({
|
||||
children,
|
||||
style,
|
||||
noHorizontalPadding,
|
||||
}: DialogInnerProps) {
|
||||
const insets = useSafeAreaInsets()
|
||||
return (
|
||||
<BottomSheetView
|
||||
style={[
|
||||
a.py_xl,
|
||||
!noHorizontalPadding && a.px_xl,
|
||||
a.px_xl,
|
||||
{
|
||||
paddingTop: 40,
|
||||
borderTopLeftRadius: 40,
|
||||
@@ -211,12 +223,14 @@ export function Inner({
|
||||
)
|
||||
}
|
||||
|
||||
export function ScrollableInner({children, style}: DialogInnerProps) {
|
||||
export const ScrollableInner = React.forwardRef<
|
||||
BottomSheetScrollViewMethods,
|
||||
DialogInnerProps
|
||||
>(function ScrollableInner({children, style}, ref) {
|
||||
const insets = useSafeAreaInsets()
|
||||
return (
|
||||
<BottomSheetScrollView
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
style={[
|
||||
a.flex_1, // main diff is this
|
||||
a.p_xl,
|
||||
@@ -228,24 +242,19 @@ export function ScrollableInner({children, style}: DialogInnerProps) {
|
||||
},
|
||||
flatten(style),
|
||||
]}
|
||||
contentContainerStyle={isNative ? a.pb_4xl : undefined}>
|
||||
contentContainerStyle={isNative ? a.pb_4xl : undefined}
|
||||
ref={ref}>
|
||||
{children}
|
||||
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
|
||||
</BottomSheetScrollView>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export function Handle() {
|
||||
const t = useTheme()
|
||||
|
||||
const onTouchStart = React.useCallback(() => {
|
||||
Keyboard.dismiss()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}
|
||||
onTouchStart={onTouchStart}>
|
||||
<View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import React, {useImperativeHandle} from 'react'
|
||||
import {View, TouchableWithoutFeedback} from 'react-native'
|
||||
import {FocusScope} from '@tamagui/focus-scope'
|
||||
import Animated, {FadeInDown, FadeIn} from 'react-native-reanimated'
|
||||
import {TouchableWithoutFeedback, View} from 'react-native'
|
||||
import Animated, {FadeIn, FadeInDown} from 'react-native-reanimated'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {FocusScope} from '@tamagui/focus-scope'
|
||||
|
||||
import {useTheme, atoms as a, useBreakpoints, web, flatten} from '#/alf'
|
||||
import {logger} from '#/logger'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
import {atoms as a, flatten, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {Context} from '#/components/Dialog/context'
|
||||
import {
|
||||
DialogControlProps,
|
||||
DialogInnerProps,
|
||||
DialogOuterProps,
|
||||
} from '#/components/Dialog/types'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {Portal} from '#/components/Portal'
|
||||
|
||||
import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types'
|
||||
import {Context} from '#/components/Dialog/context'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
|
||||
export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
|
||||
export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
|
||||
export * from '#/components/Dialog/types'
|
||||
export {Input} from '#/components/forms/TextField'
|
||||
|
||||
@@ -29,22 +33,40 @@ export function Outer({
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
const [isVisible, setIsVisible] = React.useState(true)
|
||||
const {setDialogIsOpen} = useDialogStateControlContext()
|
||||
|
||||
const open = React.useCallback(() => {
|
||||
setIsOpen(true)
|
||||
setDialogIsOpen(control.id, true)
|
||||
setIsOpen(true)
|
||||
}, [setIsOpen, setDialogIsOpen, control.id])
|
||||
|
||||
const close = React.useCallback(async () => {
|
||||
setIsVisible(false)
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
setIsOpen(false)
|
||||
setIsVisible(true)
|
||||
setDialogIsOpen(control.id, false)
|
||||
onClose?.()
|
||||
}, [onClose, setIsOpen, setDialogIsOpen, control.id])
|
||||
const close = React.useCallback<DialogControlProps['close']>(
|
||||
cb => {
|
||||
setDialogIsOpen(control.id, false)
|
||||
setIsOpen(false)
|
||||
|
||||
try {
|
||||
if (cb && typeof cb === 'function') {
|
||||
// This timeout ensures that the callback runs at the same time as it would on native. I.e.
|
||||
// console.log('Step 1') -> close(() => console.log('Step 3')) -> console.log('Step 2')
|
||||
// This should always output 'Step 1', 'Step 2', 'Step 3', but without the timeout it would output
|
||||
// 'Step 1', 'Step 3', 'Step 2'.
|
||||
setTimeout(cb)
|
||||
}
|
||||
} catch (e: any) {
|
||||
logger.error(`Dialog closeCallback failed`, {
|
||||
message: e.message,
|
||||
})
|
||||
}
|
||||
|
||||
onClose?.()
|
||||
},
|
||||
[control.id, onClose, setDialogIsOpen],
|
||||
)
|
||||
|
||||
const handleBackgroundPress = React.useCallback(async () => {
|
||||
close()
|
||||
}, [close])
|
||||
|
||||
useImperativeHandle(
|
||||
control.ref,
|
||||
@@ -52,7 +74,7 @@ export function Outer({
|
||||
open,
|
||||
close,
|
||||
}),
|
||||
[open, close],
|
||||
[close, open],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -65,7 +87,7 @@ export function Outer({
|
||||
document.addEventListener('keydown', handler)
|
||||
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [isOpen, close])
|
||||
}, [close, isOpen])
|
||||
|
||||
const context = React.useMemo(
|
||||
() => ({
|
||||
@@ -82,7 +104,7 @@ export function Outer({
|
||||
<TouchableWithoutFeedback
|
||||
accessibilityHint={undefined}
|
||||
accessibilityLabel={_(msg`Close active dialog`)}
|
||||
onPress={close}>
|
||||
onPress={handleBackgroundPress}>
|
||||
<View
|
||||
style={[
|
||||
web(a.fixed),
|
||||
@@ -92,17 +114,15 @@ export function Outer({
|
||||
gtMobile ? a.p_lg : a.p_md,
|
||||
{overflowY: 'auto'},
|
||||
]}>
|
||||
{isVisible && (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(150)}
|
||||
// exiting={FadeOut.duration(150)}
|
||||
style={[
|
||||
web(a.fixed),
|
||||
a.inset_0,
|
||||
{opacity: 0.8, backgroundColor: t.palette.black},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(150)}
|
||||
// exiting={FadeOut.duration(150)}
|
||||
style={[
|
||||
web(a.fixed),
|
||||
a.inset_0,
|
||||
{opacity: 0.8, backgroundColor: t.palette.black},
|
||||
]}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={[
|
||||
@@ -114,7 +134,7 @@ export function Outer({
|
||||
minHeight: web('calc(90vh - 36px)') || undefined,
|
||||
},
|
||||
]}>
|
||||
{isVisible ? children : null}
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import React from 'react'
|
||||
import type {AccessibilityProps, GestureResponderEvent} from 'react-native'
|
||||
import {BottomSheetProps} from '@gorhom/bottom-sheet'
|
||||
import type {
|
||||
AccessibilityProps,
|
||||
GestureResponderEvent,
|
||||
ScrollViewProps,
|
||||
} from 'react-native'
|
||||
import {BottomSheetProps} from '@discord/bottom-sheet/src'
|
||||
|
||||
import {ViewStyleProp} from '#/alf'
|
||||
|
||||
@@ -61,11 +65,11 @@ export type DialogInnerProps =
|
||||
label?: undefined
|
||||
accessibilityLabelledBy: A11yProps['aria-labelledby']
|
||||
accessibilityDescribedBy: string
|
||||
noHorizontalPadding?: boolean
|
||||
keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
|
||||
}>
|
||||
| DialogInnerPropsBase<{
|
||||
label: string
|
||||
accessibilityLabelledBy?: undefined
|
||||
accessibilityDescribedBy?: undefined
|
||||
noHorizontalPadding?: boolean
|
||||
keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
|
||||
}>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/core'
|
||||
import {StackActions} from '@react-navigation/native'
|
||||
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {View} from 'react-native'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useNavigation} from '@react-navigation/core'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {StackActions} from '@react-navigation/native'
|
||||
import {router} from '#/routes'
|
||||
|
||||
export function Error({
|
||||
@@ -20,6 +22,7 @@ export function Error({
|
||||
onRetry?: () => unknown
|
||||
}) {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
@@ -68,21 +71,25 @@ export function Error({
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
label="Click here"
|
||||
label={_(msg`Press to retry`)}
|
||||
onPress={onRetry}
|
||||
size="large"
|
||||
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
|
||||
Retry
|
||||
<ButtonText>
|
||||
<Trans>Retry</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="solid"
|
||||
color={onRetry ? 'secondary' : 'primary'}
|
||||
label="Click here"
|
||||
label={_(msg`Return to previous page`)}
|
||||
onPress={onGoBack}
|
||||
size="large"
|
||||
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
|
||||
Go Back
|
||||
<ButtonText>
|
||||
<Trans>Go Back</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</CenteredView>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
|
||||
import {atoms as a, tokens} from '#/alf'
|
||||
|
||||
|
||||
@@ -1,47 +1,54 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {List} from '#/view/com/util/List'
|
||||
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
|
||||
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
||||
import {useLikedByQuery} from '#/state/queries/post-liked-by'
|
||||
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {ListFooter} from '#/components/Lists'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
|
||||
import {List} from '#/view/com/util/List'
|
||||
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
function renderItem({item}: {item: GetLikes.Like}) {
|
||||
return <ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
|
||||
}
|
||||
|
||||
function keyExtractor(item: GetLikes.Like) {
|
||||
return item.actor.did
|
||||
}
|
||||
|
||||
export function LikedByList({uri}: {uri: string}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const initialNumToRender = useInitialNumToRender()
|
||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
|
||||
const {
|
||||
data: resolvedUri,
|
||||
error: resolveError,
|
||||
isFetching: isFetchingResolvedUri,
|
||||
isLoading: isUriLoading,
|
||||
} = useResolveUriQuery(uri)
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
isFetched,
|
||||
isRefetching,
|
||||
isLoading: isLikedByLoading,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isError,
|
||||
error: likedByError,
|
||||
refetch,
|
||||
} = useLikedByQuery(resolvedUri?.uri)
|
||||
|
||||
const error = resolveError || likedByError
|
||||
const isError = !!resolveError || !!likedByError
|
||||
|
||||
const likes = React.useMemo(() => {
|
||||
if (data?.pages) {
|
||||
return data.pages.flatMap(page => page.likes)
|
||||
}
|
||||
return []
|
||||
}, [data])
|
||||
const initialNumToRender = useInitialNumToRender()
|
||||
const error = resolveError || likedByError
|
||||
|
||||
const onRefresh = React.useCallback(async () => {
|
||||
setIsPTRing(true)
|
||||
@@ -54,56 +61,47 @@ export function LikedByList({uri}: {uri: string}) {
|
||||
}, [refetch, setIsPTRing])
|
||||
|
||||
const onEndReached = React.useCallback(async () => {
|
||||
if (isFetching || !hasNextPage || isError) return
|
||||
if (isFetchingNextPage || !hasNextPage || isError) return
|
||||
try {
|
||||
await fetchNextPage()
|
||||
} catch (err) {
|
||||
logger.error('Failed to load more likes', {message: err})
|
||||
}
|
||||
}, [isFetching, hasNextPage, isError, fetchNextPage])
|
||||
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
|
||||
|
||||
const renderItem = React.useCallback(({item}: {item: GetLikes.Like}) => {
|
||||
if (likes.length < 1) {
|
||||
return (
|
||||
<ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
|
||||
)
|
||||
}, [])
|
||||
|
||||
if (isFetchingResolvedUri || !isFetched) {
|
||||
return (
|
||||
<View style={[a.w_full, a.align_center, a.p_lg]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isUriLoading || isLikedByLoading}
|
||||
isError={isError}
|
||||
emptyType="results"
|
||||
emptyMessage={_(
|
||||
msg`Nobody has liked this yet. Maybe you should be the first!`,
|
||||
)}
|
||||
errorMessage={cleanError(resolveError || error)}
|
||||
onRetry={isError ? refetch : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return likes.length ? (
|
||||
return (
|
||||
<List
|
||||
data={likes}
|
||||
keyExtractor={item => item.actor.did}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
refreshing={isPTRing}
|
||||
onRefresh={onRefresh}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={3}
|
||||
renderItem={renderItem}
|
||||
initialNumToRender={initialNumToRender}
|
||||
ListFooterComponent={() => (
|
||||
ListFooterComponent={
|
||||
<ListFooter
|
||||
isFetching={isFetching && !isRefetching}
|
||||
isError={isError}
|
||||
error={error ? error.toString() : undefined}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
error={cleanError(error)}
|
||||
onRetry={fetchNextPage}
|
||||
/>
|
||||
)}
|
||||
}
|
||||
onEndReachedThreshold={3}
|
||||
initialNumToRender={initialNumToRender}
|
||||
windowSize={11}
|
||||
/>
|
||||
) : (
|
||||
<View style={[a.p_lg]}>
|
||||
<View style={[a.p_lg, a.rounded_sm, t.atoms.bg_contrast_25]}>
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
Nobody has liked this yet. Maybe you should be the first!
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
+44
-10
@@ -1,23 +1,24 @@
|
||||
import React from 'react'
|
||||
import {GestureResponderEvent} from 'react-native'
|
||||
import {useLinkProps, StackActions} from '@react-navigation/native'
|
||||
import {sanitizeUrl} from '@braintree/sanitize-url'
|
||||
import {StackActions, useLinkProps} from '@react-navigation/native'
|
||||
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useTheme, web, flatten, TextStyleProp, atoms as a} from '#/alf'
|
||||
import {Button, ButtonProps} from '#/components/Button'
|
||||
import {AllNavigatorParams} from '#/lib/routes/types'
|
||||
import {shareUrl} from '#/lib/sharing'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
isExternalUrl,
|
||||
linkRequiresWarning,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {router} from '#/routes'
|
||||
import {Text, TextProps} from '#/components/Typography'
|
||||
import {useOpenLink} from 'state/preferences/in-app-browser'
|
||||
import {useOpenLink} from '#/state/preferences/in-app-browser'
|
||||
import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
|
||||
import {atoms as a, flatten, TextStyleProp, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonProps} from '#/components/Button'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {Text, TextProps} from '#/components/Typography'
|
||||
import {router} from '#/routes'
|
||||
|
||||
/**
|
||||
* Only available within a `Link`, since that inherits from `Button`.
|
||||
@@ -60,6 +61,11 @@ type BaseLinkProps = Pick<
|
||||
* Web-only attribute. Sets `download` attr on web.
|
||||
*/
|
||||
download?: string
|
||||
|
||||
/**
|
||||
* Native-only attribute. If true, will open the share sheet on long press.
|
||||
*/
|
||||
shareOnLongPress?: boolean
|
||||
}
|
||||
|
||||
export function useLink({
|
||||
@@ -68,6 +74,7 @@ export function useLink({
|
||||
action = 'push',
|
||||
disableMismatchWarning,
|
||||
onPress: outerOnPress,
|
||||
shareOnLongPress,
|
||||
}: BaseLinkProps & {
|
||||
displayText: string
|
||||
}) {
|
||||
@@ -157,10 +164,34 @@ export function useLink({
|
||||
],
|
||||
)
|
||||
|
||||
const handleLongPress = React.useCallback(() => {
|
||||
const requiresWarning = Boolean(
|
||||
!disableMismatchWarning &&
|
||||
displayText &&
|
||||
isExternal &&
|
||||
linkRequiresWarning(href, displayText),
|
||||
)
|
||||
|
||||
if (requiresWarning) {
|
||||
openModal({
|
||||
name: 'link-warning',
|
||||
text: displayText,
|
||||
href: href,
|
||||
share: true,
|
||||
})
|
||||
} else {
|
||||
shareUrl(href)
|
||||
}
|
||||
}, [disableMismatchWarning, displayText, href, isExternal, openModal])
|
||||
|
||||
const onLongPress =
|
||||
isNative && isExternal && shareOnLongPress ? handleLongPress : undefined
|
||||
|
||||
return {
|
||||
isExternal,
|
||||
href,
|
||||
onPress,
|
||||
onLongPress,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,7 +250,7 @@ export type InlineLinkProps = React.PropsWithChildren<
|
||||
BaseLinkProps & TextStyleProp & Pick<TextProps, 'selectable'>
|
||||
>
|
||||
|
||||
export function InlineLink({
|
||||
export function InlineLinkText({
|
||||
children,
|
||||
to,
|
||||
action = 'push',
|
||||
@@ -229,16 +260,18 @@ export function InlineLink({
|
||||
download,
|
||||
selectable,
|
||||
label,
|
||||
shareOnLongPress,
|
||||
...rest
|
||||
}: InlineLinkProps) {
|
||||
const t = useTheme()
|
||||
const stringChildren = typeof children === 'string'
|
||||
const {href, isExternal, onPress} = useLink({
|
||||
const {href, isExternal, onPress, onLongPress} = useLink({
|
||||
to,
|
||||
displayText: stringChildren ? children : '',
|
||||
action,
|
||||
disableMismatchWarning,
|
||||
onPress: outerOnPress,
|
||||
shareOnLongPress,
|
||||
})
|
||||
const {
|
||||
state: hovered,
|
||||
@@ -270,6 +303,7 @@ export function InlineLink({
|
||||
]}
|
||||
role="link"
|
||||
onPress={download ? undefined : onPress}
|
||||
onLongPress={onLongPress}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
onFocus={onFocus}
|
||||
|
||||
+29
-33
@@ -1,25 +1,23 @@
|
||||
import React from 'react'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {Button} from '#/components/Button'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Error} from '#/components/Error'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function ListFooter({
|
||||
isFetching,
|
||||
isError,
|
||||
isFetchingNextPage,
|
||||
error,
|
||||
onRetry,
|
||||
height,
|
||||
}: {
|
||||
isFetching?: boolean
|
||||
isError?: boolean
|
||||
isFetchingNextPage?: boolean
|
||||
error?: string
|
||||
onRetry?: () => Promise<unknown>
|
||||
height?: number
|
||||
@@ -36,32 +34,26 @@ export function ListFooter({
|
||||
t.atoms.border_contrast_low,
|
||||
{height: height ?? 180, paddingTop: 30},
|
||||
]}>
|
||||
{isFetching ? (
|
||||
{isFetchingNextPage ? (
|
||||
<Loader size="xl" />
|
||||
) : (
|
||||
<ListFooterMaybeError
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
<ListFooterMaybeError error={error} onRetry={onRetry} />
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ListFooterMaybeError({
|
||||
isError,
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
isError?: boolean
|
||||
error?: string
|
||||
onRetry?: () => Promise<unknown>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
if (!isError) return null
|
||||
if (!error) return null
|
||||
|
||||
return (
|
||||
<View style={[a.w_full, a.px_lg]}>
|
||||
@@ -95,7 +87,9 @@ function ListFooterMaybeError({
|
||||
a.py_sm,
|
||||
]}
|
||||
onPress={onRetry}>
|
||||
<Trans>Retry</Trans>
|
||||
<ButtonText>
|
||||
<Trans>Retry</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
@@ -128,7 +122,7 @@ export function ListHeaderDesktop({
|
||||
|
||||
export function ListMaybePlaceholder({
|
||||
isLoading,
|
||||
isEmpty,
|
||||
noEmpty,
|
||||
isError,
|
||||
emptyTitle,
|
||||
emptyMessage,
|
||||
@@ -138,7 +132,7 @@ export function ListMaybePlaceholder({
|
||||
onRetry,
|
||||
}: {
|
||||
isLoading: boolean
|
||||
isEmpty?: boolean
|
||||
noEmpty?: boolean
|
||||
isError?: boolean
|
||||
emptyTitle?: string
|
||||
emptyMessage?: string
|
||||
@@ -151,16 +145,6 @@ export function ListMaybePlaceholder({
|
||||
const {_} = useLingui()
|
||||
const {gtMobile, gtTablet} = useBreakpoints()
|
||||
|
||||
if (!isLoading && isError) {
|
||||
return (
|
||||
<Error
|
||||
title={errorTitle ?? _(msg`Oops!`)}
|
||||
message={errorMessage ?? _(`Something went wrong!`)}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<CenteredView
|
||||
@@ -180,7 +164,17 @@ export function ListMaybePlaceholder({
|
||||
)
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
if (isError) {
|
||||
return (
|
||||
<Error
|
||||
title={errorTitle ?? _(msg`Oops!`)}
|
||||
message={errorMessage ?? _(`Something went wrong!`)}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!noEmpty) {
|
||||
return (
|
||||
<Error
|
||||
title={
|
||||
@@ -197,4 +191,6 @@ export function ListMaybePlaceholder({
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React from 'react'
|
||||
import Animated, {
|
||||
Easing,
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
|
||||
import {atoms as a, useTheme, flatten} from '#/alf'
|
||||
import {atoms as a, flatten, useTheme} from '#/alf'
|
||||
import {Props, useCommonSVGProps} from '#/components/icons/common'
|
||||
import {Loader_Stroke2_Corner0_Rounded as Icon} from '#/components/icons/Loader'
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {atoms as a, flatten, useTheme} from '#/alf'
|
||||
import {Props, useCommonSVGProps} from '#/components/icons/common'
|
||||
import {Loader_Stroke2_Corner0_Rounded as Icon} from '#/components/icons/Loader'
|
||||
|
||||
export function Loader(props: Props) {
|
||||
const t = useTheme()
|
||||
const common = useCommonSVGProps(props)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
{width: common.size, height: common.size},
|
||||
]}>
|
||||
{/* css rotation animation - /bskyweb/templates/base.html */}
|
||||
<div className="rotate-500ms">
|
||||
<Icon
|
||||
{...props}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
t.atoms.text_contrast_high,
|
||||
flatten(props.style),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {ProfileHoverCardProps} from './types'
|
||||
|
||||
export function ProfileHoverCard({children}: ProfileHoverCardProps) {
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
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-show' | 'showing' | 'might-hide' | 'hiding'
|
||||
effect?: () => () => any
|
||||
}
|
||||
|
||||
type Action =
|
||||
| 'pressed'
|
||||
| 'hovered'
|
||||
| 'unhovered'
|
||||
| 'show-timer-elapsed'
|
||||
| 'hide-timer-elapsed'
|
||||
| 'hide-animation-completed'
|
||||
|
||||
const SHOW_DELAY = 350
|
||||
const SHOW_DURATION = 300
|
||||
const HIDE_DELAY = 200
|
||||
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 => {
|
||||
// Regardless of which stage we're in, pressing always hides the card.
|
||||
if (action === 'pressed') {
|
||||
return {stage: 'hidden'}
|
||||
}
|
||||
|
||||
if (state.stage === 'hidden') {
|
||||
// Our story starts when the card is hidden.
|
||||
// If the user hovers, we kick off a grace period before showing the card.
|
||||
if (action === 'hovered') {
|
||||
return {
|
||||
stage: 'might-show',
|
||||
effect() {
|
||||
const id = setTimeout(
|
||||
() => dispatch('show-timer-elapsed'),
|
||||
SHOW_DELAY,
|
||||
)
|
||||
return () => {
|
||||
clearTimeout(id)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.stage === 'might-show') {
|
||||
// We're in the grace period when we decide whether to show the card.
|
||||
// At this point, two things can happen. Either the user unhovers, and
|
||||
// we go back to hidden--or they linger enough that we'll show the card.
|
||||
if (action === 'unhovered') {
|
||||
return {stage: 'hidden'}
|
||||
}
|
||||
if (action === 'show-timer-elapsed') {
|
||||
return {stage: 'showing'}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.stage === 'showing') {
|
||||
// We're showing the card now.
|
||||
// If the user unhovers, we'll start a grace period before hiding the card.
|
||||
if (action === 'unhovered') {
|
||||
return {
|
||||
stage: 'might-hide',
|
||||
effect() {
|
||||
const id = setTimeout(
|
||||
() => dispatch('hide-timer-elapsed'),
|
||||
HIDE_DELAY,
|
||||
)
|
||||
return () => clearTimeout(id)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.stage === 'might-hide') {
|
||||
// We're in the grace period when we decide whether to hide the card.
|
||||
// At this point, two things can happen. Either the user hovers, and
|
||||
// we go back to showing it--or they linger enough that we'll start hiding the card.
|
||||
if (action === 'hovered') {
|
||||
return {stage: 'showing'}
|
||||
}
|
||||
if (action === 'hide-timer-elapsed') {
|
||||
return {
|
||||
stage: 'hiding',
|
||||
effect() {
|
||||
const id = setTimeout(
|
||||
() => dispatch('hide-animation-completed'),
|
||||
HIDE_DURATION,
|
||||
)
|
||||
return () => clearTimeout(id)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.stage === 'hiding') {
|
||||
// We're currently playing the hiding animation.
|
||||
// We'll ignore all inputs now and wait for the animation to finish.
|
||||
// At that point, we'll hide the entire thing, going back to square one.
|
||||
if (action === 'hide-animation-completed') {
|
||||
return {stage: 'hidden'}
|
||||
}
|
||||
}
|
||||
|
||||
// Something else happened. Keep calm and carry on.
|
||||
return state
|
||||
},
|
||||
{stage: 'hidden'},
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (currentState.effect) {
|
||||
const effect = currentState.effect
|
||||
delete currentState.effect // Mark as completed
|
||||
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 onPointerEnterTarget = React.useCallback(() => {
|
||||
prefetchIfNeeded()
|
||||
dispatch('hovered')
|
||||
}, [prefetchIfNeeded])
|
||||
|
||||
const onPointerLeaveTarget = React.useCallback(() => {
|
||||
dispatch('unhovered')
|
||||
}, [])
|
||||
|
||||
const onPointerEnterCard = React.useCallback(() => {
|
||||
dispatch('hovered')
|
||||
}, [])
|
||||
|
||||
const onPointerLeaveCard = React.useCallback(() => {
|
||||
dispatch('unhovered')
|
||||
}, [])
|
||||
|
||||
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}
|
||||
onPointerEnter={onPointerEnterTarget}
|
||||
onPointerLeave={onPointerLeaveTarget}
|
||||
onMouseUp={onPress}
|
||||
style={{
|
||||
display: props.inline ? 'inline' : 'block',
|
||||
}}>
|
||||
{props.children}
|
||||
{isVisible && (
|
||||
<Portal>
|
||||
<div style={animationStyle}>
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
onPointerEnter={onPointerEnterCard}
|
||||
onPointerLeave={onPointerLeaveCard}>
|
||||
<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
|
||||
}
|
||||
+30
-22
@@ -3,11 +3,10 @@ import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useTheme, atoms as a, useBreakpoints} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonColor, ButtonText} from '#/components/Button'
|
||||
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export {useDialogControl as usePromptControl} from '#/components/Dialog'
|
||||
|
||||
@@ -52,7 +51,7 @@ export function Outer({
|
||||
)
|
||||
}
|
||||
|
||||
export function Title({children}: React.PropsWithChildren<{}>) {
|
||||
export function TitleText({children}: React.PropsWithChildren<{}>) {
|
||||
const {titleId} = React.useContext(Context)
|
||||
return (
|
||||
<Text nativeID={titleId} style={[a.text_2xl, a.font_bold, a.pb_sm]}>
|
||||
@@ -61,7 +60,7 @@ export function Title({children}: React.PropsWithChildren<{}>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function Description({children}: React.PropsWithChildren<{}>) {
|
||||
export function DescriptionText({children}: React.PropsWithChildren<{}>) {
|
||||
const t = useTheme()
|
||||
const {descriptionId} = React.useContext(Context)
|
||||
return (
|
||||
@@ -80,7 +79,7 @@ export function Actions({children}: React.PropsWithChildren<{}>) {
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.gap_sm,
|
||||
a.gap_md,
|
||||
a.justify_end,
|
||||
gtMobile
|
||||
? [a.flex_row, a.flex_row_reverse, a.justify_start]
|
||||
@@ -92,15 +91,13 @@ export function Actions({children}: React.PropsWithChildren<{}>) {
|
||||
}
|
||||
|
||||
export function Cancel({
|
||||
children,
|
||||
cta,
|
||||
}: React.PropsWithChildren<{
|
||||
}: {
|
||||
/**
|
||||
* Optional i18n string, used in lieu of `children` for simple buttons. If
|
||||
* undefined (and `children` is undefined), it will default to "Cancel".
|
||||
* Optional i18n string. If undefined, it will default to "Cancel".
|
||||
*/
|
||||
cta?: string
|
||||
}>) {
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {close} = Dialog.useDialogContext()
|
||||
@@ -115,33 +112,37 @@ export function Cancel({
|
||||
size={gtMobile ? 'small' : 'medium'}
|
||||
label={cta || _(msg`Cancel`)}
|
||||
onPress={onPress}>
|
||||
{children ? children : <ButtonText>{cta || _(msg`Cancel`)}</ButtonText>}
|
||||
<ButtonText>{cta || _(msg`Cancel`)}</ButtonText>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function Action({
|
||||
children,
|
||||
onPress,
|
||||
color = 'primary',
|
||||
cta,
|
||||
testID,
|
||||
}: React.PropsWithChildren<{
|
||||
}: {
|
||||
/**
|
||||
* Callback to run when the action is pressed. The method is called _after_
|
||||
* the dialog closes.
|
||||
*
|
||||
* Note: The dialog will close automatically when the action is pressed, you
|
||||
* should NOT close the dialog as a side effect of this method.
|
||||
*/
|
||||
onPress: () => void
|
||||
color?: ButtonColor
|
||||
/**
|
||||
* Optional i18n string, used in lieu of `children` for simple buttons. If
|
||||
* undefined (and `children` is undefined), it will default to "Confirm".
|
||||
* Optional i18n string. If undefined, it will default to "Confirm".
|
||||
*/
|
||||
cta?: string
|
||||
testID?: string
|
||||
}>) {
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {close} = Dialog.useDialogContext()
|
||||
const handleOnPress = React.useCallback(() => {
|
||||
close()
|
||||
onPress()
|
||||
close(onPress)
|
||||
}, [close, onPress])
|
||||
|
||||
return (
|
||||
@@ -152,7 +153,7 @@ export function Action({
|
||||
label={cta || _(msg`Confirm`)}
|
||||
onPress={handleOnPress}
|
||||
testID={testID}>
|
||||
{children ? children : <ButtonText>{cta || _(msg`Confirm`)}</ButtonText>}
|
||||
<ButtonText>{cta || _(msg`Confirm`)}</ButtonText>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -171,13 +172,20 @@ export function Basic({
|
||||
description: string
|
||||
cancelButtonCta?: string
|
||||
confirmButtonCta?: string
|
||||
/**
|
||||
* Callback to run when the Confirm button is pressed. The method is called
|
||||
* _after_ the dialog closes.
|
||||
*
|
||||
* Note: The dialog will close automatically when the action is pressed, you
|
||||
* should NOT close the dialog as a side effect of this method.
|
||||
*/
|
||||
onConfirm: () => void
|
||||
confirmButtonColor?: ButtonColor
|
||||
}>) {
|
||||
return (
|
||||
<Outer control={control} testID="confirmModal">
|
||||
<Title>{title}</Title>
|
||||
<Description>{description}</Description>
|
||||
<TitleText>{title}</TitleText>
|
||||
<DescriptionText>{description}</DescriptionText>
|
||||
<Actions>
|
||||
<Action
|
||||
cta={confirmButtonCta}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
|
||||
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
|
||||
import {getLabelingServiceTitle} from '#/lib/moderation'
|
||||
|
||||
import {atoms as a, useTheme, useBreakpoints} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, useButtonContext} from '#/components/Button'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import * as LabelingServiceCard from '#/components/LabelingServiceCard'
|
||||
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ReportDialogProps} from './types'
|
||||
|
||||
export function SelectLabelerView({
|
||||
@@ -38,7 +36,7 @@ export function SelectLabelerView({
|
||||
|
||||
<Divider />
|
||||
|
||||
<View style={[a.gap_xs, {marginHorizontal: a.p_md.padding * -1}]}>
|
||||
<View style={[a.gap_sm]}>
|
||||
{props.labelers.map(labeler => {
|
||||
return (
|
||||
<Button
|
||||
@@ -63,17 +61,14 @@ function LabelerButton({
|
||||
const {hovered, pressed} = useButtonContext()
|
||||
const interacted = hovered || pressed
|
||||
|
||||
const styles = React.useMemo(() => {
|
||||
return {
|
||||
interacted: {
|
||||
backgroundColor: t.palette.contrast_50,
|
||||
},
|
||||
}
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<LabelingServiceCard.Outer
|
||||
style={[a.p_md, a.rounded_sm, interacted && styles.interacted]}>
|
||||
style={[
|
||||
a.p_md,
|
||||
a.rounded_sm,
|
||||
t.atoms.bg_contrast_25,
|
||||
interacted && t.atoms.bg_contrast_50,
|
||||
]}>
|
||||
<LabelingServiceCard.Avatar avatar={labeler.creator.avatar} />
|
||||
<LabelingServiceCard.Content>
|
||||
<LabelingServiceCard.Title
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
|
||||
import {useReportOptions, ReportOption} from '#/lib/moderation/useReportOptions'
|
||||
import {DMCA_LINK} from '#/components/ReportDialog/const'
|
||||
import {ReportOption, useReportOptions} from '#/lib/moderation/useReportOptions'
|
||||
import {Link} from '#/components/Link'
|
||||
import {DMCA_LINK} from '#/components/ReportDialog/const'
|
||||
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
|
||||
|
||||
import {atoms as a, useTheme, useBreakpoints} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {
|
||||
Button,
|
||||
ButtonIcon,
|
||||
@@ -19,11 +18,11 @@ import {
|
||||
} from '#/components/Button'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {
|
||||
ChevronRight_Stroke2_Corner0_Rounded as ChevronRight,
|
||||
ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft,
|
||||
ChevronRight_Stroke2_Corner0_Rounded as ChevronRight,
|
||||
} from '#/components/icons/Chevron'
|
||||
import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from '#/components/icons/SquareArrowTopRight'
|
||||
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ReportDialogProps} from './types'
|
||||
|
||||
export function SelectReportOptionView({
|
||||
@@ -86,11 +85,12 @@ export function SelectReportOptionView({
|
||||
|
||||
<Divider />
|
||||
|
||||
<View style={[a.gap_sm, {marginHorizontal: a.p_md.padding * -1}]}>
|
||||
<View style={[a.gap_sm]}>
|
||||
{reportOptions.map(reportOption => {
|
||||
return (
|
||||
<Button
|
||||
key={reportOption.reason}
|
||||
testID={reportOption.reason}
|
||||
label={_(msg`Create report for ${reportOption.title}`)}
|
||||
onPress={() => props.onSelectReportOption(reportOption)}>
|
||||
<ReportOptionButton
|
||||
@@ -102,39 +102,37 @@ export function SelectReportOptionView({
|
||||
})}
|
||||
|
||||
{(props.params.type === 'post' || props.params.type === 'account') && (
|
||||
<View style={[a.pt_md, a.px_md]}>
|
||||
<View
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.gap_lg,
|
||||
a.p_md,
|
||||
a.pl_lg,
|
||||
a.rounded_md,
|
||||
t.atoms.bg_contrast_900,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.gap_lg,
|
||||
a.p_md,
|
||||
a.pl_lg,
|
||||
a.rounded_md,
|
||||
t.atoms.bg_contrast_900,
|
||||
a.flex_1,
|
||||
t.atoms.text_inverted,
|
||||
a.italic,
|
||||
a.leading_snug,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
t.atoms.text_inverted,
|
||||
a.italic,
|
||||
a.leading_snug,
|
||||
]}>
|
||||
<Trans>Need to report a copyright violation?</Trans>
|
||||
</Text>
|
||||
<Link
|
||||
to={DMCA_LINK}
|
||||
label={_(msg`View details for reporting a copyright violation`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary">
|
||||
<ButtonText>
|
||||
<Trans>View details</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon position="right" icon={SquareArrowTopRight} />
|
||||
</Link>
|
||||
</View>
|
||||
<Trans>Need to report a copyright violation?</Trans>
|
||||
</Text>
|
||||
<Link
|
||||
to={DMCA_LINK}
|
||||
label={_(msg`View details for reporting a copyright violation`)}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary">
|
||||
<ButtonText>
|
||||
<Trans>View details</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon position="right" icon={SquareArrowTopRight} />
|
||||
</Link>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -153,14 +151,6 @@ function ReportOptionButton({
|
||||
const {hovered, pressed} = useButtonContext()
|
||||
const interacted = hovered || pressed
|
||||
|
||||
const styles = React.useMemo(() => {
|
||||
return {
|
||||
interacted: {
|
||||
backgroundColor: t.palette.contrast_50,
|
||||
},
|
||||
}
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -171,7 +161,8 @@ function ReportOptionButton({
|
||||
a.p_md,
|
||||
a.rounded_md,
|
||||
{paddingRight: 70},
|
||||
interacted && styles.interacted,
|
||||
t.atoms.bg_contrast_25,
|
||||
interacted && t.atoms.bg_contrast_50,
|
||||
]}>
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
|
||||
@@ -188,12 +179,7 @@ function ReportOptionButton({
|
||||
a.pr_md,
|
||||
{left: 'auto'},
|
||||
]}>
|
||||
<ChevronRight
|
||||
size="md"
|
||||
fill={
|
||||
hovered ? t.palette.primary_500 : t.atoms.text_contrast_low.color
|
||||
}
|
||||
/>
|
||||
<ChevronRight size="md" fill={t.atoms.text_contrast_low.color} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
|
||||
import {getLabelingServiceTitle} from '#/lib/moderation'
|
||||
import {ReportOption} from '#/lib/moderation/useReportOptions'
|
||||
|
||||
import {atoms as a, useTheme, native} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
|
||||
import {ReportDialogProps} from './types'
|
||||
import {getAgent} 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'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ReportDialogProps} from './types'
|
||||
|
||||
export function SubmitView({
|
||||
params,
|
||||
@@ -208,6 +206,7 @@ export function SubmitView({
|
||||
))}
|
||||
|
||||
<Button
|
||||
testID="sendReportBtn"
|
||||
size="large"
|
||||
variant="solid"
|
||||
color="negative"
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import React from 'react'
|
||||
import {View, Pressable} from 'react-native'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useMyLabelersQuery} from '#/state/queries/preferences'
|
||||
import {ReportOption} from '#/lib/moderation/useReportOptions'
|
||||
import {useMyLabelersQuery} from '#/state/queries/preferences'
|
||||
export {useDialogControl as useReportDialogControl} from '#/components/Dialog'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
import {BottomSheetScrollViewMethods} from '@discord/bottom-sheet/src'
|
||||
|
||||
import {ReportDialogProps} from './types'
|
||||
import {atoms as a} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
|
||||
import {useOnKeyboardDidShow} from '#/components/hooks/useOnKeyboard'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {SelectLabelerView} from './SelectLabelerView'
|
||||
import {SelectReportOptionView} from './SelectReportOptionView'
|
||||
import {SubmitView} from './SubmitView'
|
||||
import {useDelayedLoading} from '#/components/hooks/useDelayedLoading'
|
||||
import {AppBskyLabelerDefs} from '@atproto/api'
|
||||
import {ReportDialogProps} from './types'
|
||||
|
||||
export function ReportDialog(props: ReportDialogProps) {
|
||||
return (
|
||||
@@ -29,6 +32,7 @@ export function ReportDialog(props: ReportDialogProps) {
|
||||
}
|
||||
|
||||
function ReportDialogInner(props: ReportDialogProps) {
|
||||
const {_} = useLingui()
|
||||
const {
|
||||
isLoading: isLabelerLoading,
|
||||
data: labelers,
|
||||
@@ -36,8 +40,13 @@ function ReportDialogInner(props: ReportDialogProps) {
|
||||
} = useMyLabelersQuery()
|
||||
const isLoading = useDelayedLoading(500, isLabelerLoading)
|
||||
|
||||
const ref = React.useRef<BottomSheetScrollViewMethods>(null)
|
||||
useOnKeyboardDidShow(() => {
|
||||
ref.current?.scrollToEnd({animated: true})
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner label="Report Dialog">
|
||||
<Dialog.ScrollableInner label={_(msg`Report dialog`)} ref={ref}>
|
||||
{isLoading ? (
|
||||
<View style={[a.align_center, {height: 100}]}>
|
||||
<Loader size="xl" />
|
||||
@@ -53,8 +62,6 @@ function ReportDialogInner(props: ReportDialogProps) {
|
||||
) : (
|
||||
<ReportDialogLoaded labelers={labelers} {...props} />
|
||||
)}
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
|
||||
+26
-19
@@ -1,15 +1,16 @@
|
||||
import React from 'react'
|
||||
import {RichText as RichTextAPI, AppBskyRichtextFacet} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, TextStyleProp, flatten, useTheme, web, native} from '#/alf'
|
||||
import {InlineLink} from '#/components/Link'
|
||||
import {Text, TextProps} from '#/components/Typography'
|
||||
import {toShortUrl} from 'lib/strings/url-helpers'
|
||||
import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
|
||||
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, LinkProps} from '#/components/Link'
|
||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||
import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
|
||||
import {Text, TextProps} from '#/components/Typography'
|
||||
|
||||
const WORD_WRAP = {wordWrap: 1}
|
||||
|
||||
@@ -22,6 +23,7 @@ export function RichText({
|
||||
selectable,
|
||||
enableTags = false,
|
||||
authorHandle,
|
||||
onLinkPress,
|
||||
}: TextStyleProp &
|
||||
Pick<TextProps, 'selectable'> & {
|
||||
value: RichTextAPI | string
|
||||
@@ -30,6 +32,7 @@ export function RichText({
|
||||
disableLinks?: boolean
|
||||
enableTags?: boolean
|
||||
authorHandle?: string
|
||||
onLinkPress?: LinkProps['onPress']
|
||||
}) {
|
||||
const richText = React.useMemo(
|
||||
() =>
|
||||
@@ -84,30 +87,34 @@ export function RichText({
|
||||
!disableLinks
|
||||
) {
|
||||
els.push(
|
||||
<InlineLink
|
||||
selectable={selectable}
|
||||
key={key}
|
||||
to={`/profile/${mention.did}`}
|
||||
style={[...styles, {pointerEvents: 'auto'}]}
|
||||
// @ts-ignore TODO
|
||||
dataSet={WORD_WRAP}>
|
||||
{segment.text}
|
||||
</InlineLink>,
|
||||
<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) {
|
||||
els.push(toShortUrl(segment.text))
|
||||
} else {
|
||||
els.push(
|
||||
<InlineLink
|
||||
<InlineLinkText
|
||||
selectable={selectable}
|
||||
key={key}
|
||||
to={link.uri}
|
||||
style={[...styles, {pointerEvents: 'auto'}]}
|
||||
// @ts-ignore TODO
|
||||
dataSet={WORD_WRAP}>
|
||||
dataSet={WORD_WRAP}
|
||||
shareOnLongPress
|
||||
onPress={onLinkPress}>
|
||||
{toShortUrl(segment.text)}
|
||||
</InlineLink>,
|
||||
</InlineLinkText>,
|
||||
)
|
||||
}
|
||||
} else if (
|
||||
|
||||
@@ -87,7 +87,7 @@ export function TagMenu({
|
||||
author: authorHandle,
|
||||
})
|
||||
},
|
||||
testID: 'tagMenuSeachByUser',
|
||||
testID: 'tagMenuSearchByUser',
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'magnifyingglass',
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Text as RNText,
|
||||
StyleProp,
|
||||
TextStyle,
|
||||
TextProps as RNTextProps,
|
||||
} from 'react-native'
|
||||
import {UITextView} from 'react-native-ui-text-view'
|
||||
import {StyleProp, TextProps as RNTextProps, TextStyle} from 'react-native'
|
||||
import {UITextView} from 'react-native-uitextview'
|
||||
|
||||
import {useTheme, atoms, web, flatten} from '#/alf'
|
||||
import {isIOS, isNative} from '#/platform/detection'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {atoms, flatten, useTheme, web} from '#/alf'
|
||||
|
||||
export type TextProps = RNTextProps & {
|
||||
/**
|
||||
@@ -61,11 +56,8 @@ export function normalizeTextStyles(styles: StyleProp<TextStyle>) {
|
||||
export function Text({style, selectable, ...rest}: TextProps) {
|
||||
const t = useTheme()
|
||||
const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)])
|
||||
return selectable && isIOS ? (
|
||||
<UITextView style={s} {...rest} />
|
||||
) : (
|
||||
<RNText selectable={selectable} style={s} {...rest} />
|
||||
)
|
||||
|
||||
return <UITextView selectable={selectable} uiTextView style={s} {...rest} />
|
||||
}
|
||||
|
||||
export function createHeadingElement({level}: {level: number}) {
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import React from 'react'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Text} from '../Typography'
|
||||
import {DateInput} from '#/view/com/util/forms/DateInput'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {isIOS, isWeb} from '#/platform/detection'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
usePreferencesSetBirthDateMutation,
|
||||
UsePreferencesQueryResponse,
|
||||
usePreferencesSetBirthDateMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {Button, ButtonIcon, ButtonText} from '../Button'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {isIOS, isWeb} from '#/platform/detection'
|
||||
import {DateInput} from '#/view/com/util/forms/DateInput'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Button, ButtonIcon, ButtonText} from '../Button'
|
||||
import {Text} from '../Typography'
|
||||
|
||||
export function BirthDateSettingsDialog({
|
||||
control,
|
||||
|
||||
@@ -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,119 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {
|
||||
type EmbedPlayerSource,
|
||||
embedPlayerSources,
|
||||
externalEmbedLabels,
|
||||
} from '#/lib/strings/embed-player'
|
||||
import {useSetExternalEmbedPref} from '#/state/preferences'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Button, ButtonText} from '../Button'
|
||||
import {Text} from '../Typography'
|
||||
|
||||
export function EmbedConsentDialog({
|
||||
control,
|
||||
source,
|
||||
onAccept,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
source: EmbedPlayerSource
|
||||
onAccept: () => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const setExternalEmbedPref = useSetExternalEmbedPref()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
const onShowAllPress = useCallback(() => {
|
||||
for (const key of embedPlayerSources) {
|
||||
setExternalEmbedPref(key, 'show')
|
||||
}
|
||||
onAccept()
|
||||
control.close()
|
||||
}, [control, onAccept, setExternalEmbedPref])
|
||||
|
||||
const onShowPress = useCallback(() => {
|
||||
setExternalEmbedPref(source, 'show')
|
||||
onAccept()
|
||||
control.close()
|
||||
}, [control, onAccept, setExternalEmbedPref, source])
|
||||
|
||||
const onHidePress = useCallback(() => {
|
||||
setExternalEmbedPref(source, 'hide')
|
||||
control.close()
|
||||
}, [control, setExternalEmbedPref, source])
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`External Media`)}
|
||||
style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}>
|
||||
<View style={a.gap_sm}>
|
||||
<Text style={[a.text_2xl, a.font_bold]}>
|
||||
<Trans>External Media</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.mt_sm, a.mb_2xl, a.gap_lg]}>
|
||||
<Text>
|
||||
<Trans>
|
||||
This content is hosted by {externalEmbedLabels[source]}. Do you
|
||||
want to enable external media?
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Text style={t.atoms.text_contrast_medium}>
|
||||
<Trans>
|
||||
External media may allow websites to collect information about
|
||||
you and your device. No information is sent or requested until
|
||||
you press the "play" button.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={a.gap_md}>
|
||||
<Button
|
||||
style={gtMobile && a.flex_1}
|
||||
label={_(msg`Enable external media`)}
|
||||
onPress={onShowAllPress}
|
||||
onAccessibilityEscape={control.close}
|
||||
color="primary"
|
||||
size="medium"
|
||||
variant="solid">
|
||||
<ButtonText>
|
||||
<Trans>Enable external media</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
style={gtMobile && a.flex_1}
|
||||
label={_(msg`Enable this source only`)}
|
||||
onPress={onShowPress}
|
||||
onAccessibilityEscape={control.close}
|
||||
color="secondary"
|
||||
size="medium"
|
||||
variant="solid">
|
||||
<ButtonText>
|
||||
<Trans>Enable {externalEmbedLabels[source]} only</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
label={_(msg`No thanks`)}
|
||||
onAccessibilityEscape={control.close}
|
||||
onPress={onHidePress}
|
||||
color="secondary"
|
||||
size="medium"
|
||||
variant="ghost">
|
||||
<ButtonText>
|
||||
<Trans>No thanks</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +1,36 @@
|
||||
import React from 'react'
|
||||
import {Keyboard, View} from 'react-native'
|
||||
import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api'
|
||||
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
useUpsertMutedWordsMutation,
|
||||
useRemoveMutedWordMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {logger} from '#/logger'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
useRemoveMutedWordMutation,
|
||||
useUpsertMutedWordsMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {
|
||||
atoms as a,
|
||||
useTheme,
|
||||
native,
|
||||
useBreakpoints,
|
||||
useTheme,
|
||||
ViewStyleProp,
|
||||
web,
|
||||
native,
|
||||
} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
||||
import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {logger} from '#/logger'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
|
||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function MutedWordsDialog() {
|
||||
const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
|
||||
@@ -130,9 +129,9 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
|
||||
<TargetToggle>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.Label>
|
||||
<Toggle.LabelText>
|
||||
<Trans>Mute in text & tags</Trans>
|
||||
</Toggle.Label>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
<PageText size="sm" />
|
||||
</TargetToggle>
|
||||
@@ -145,9 +144,9 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
|
||||
<TargetToggle>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.Label>
|
||||
<Toggle.LabelText>
|
||||
<Trans>Mute in tags only</Trans>
|
||||
</Toggle.Label>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
<Hashtag size="sm" />
|
||||
</TargetToggle>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
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'
|
||||
import {Text} from '../Typography'
|
||||
|
||||
export function SwitchAccountDialog({
|
||||
control,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {currentAccount} = useSession()
|
||||
const {onPressSwitchAccount} = useAccountSwitcher()
|
||||
const {setShowLoggedOut} = useLoggedOutViewControls()
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
|
||||
const onSelectAccount = useCallback(
|
||||
(account: SessionAccount) => {
|
||||
if (account.did === currentAccount?.did) {
|
||||
control.close()
|
||||
} else {
|
||||
onPressSwitchAccount(account, 'SwitchAccount')
|
||||
}
|
||||
},
|
||||
[currentAccount, control, onPressSwitchAccount],
|
||||
)
|
||||
|
||||
const onPressAddAccount = useCallback(() => {
|
||||
setShowLoggedOut(true)
|
||||
closeAllActiveElements()
|
||||
}, [setShowLoggedOut, closeAllActiveElements])
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<Dialog.ScrollableInner label={_(msg`Switch Account`)}>
|
||||
<View style={[a.gap_lg]}>
|
||||
<Text style={[a.text_2xl, a.font_bold]}>
|
||||
<Trans>Switch Account</Trans>
|
||||
</Text>
|
||||
|
||||
<AccountList
|
||||
onSelectAccount={onSelectAccount}
|
||||
onSelectOther={onPressAddAccount}
|
||||
otherLabel={_(msg`Add account`)}
|
||||
/>
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,14 @@
|
||||
import React from 'react'
|
||||
import {View, Pressable} from 'react-native'
|
||||
|
||||
import {useTheme, atoms} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
|
||||
|
||||
import {DateFieldProps} from '#/components/forms/DateField/types'
|
||||
import {
|
||||
localizeDate,
|
||||
toSimpleDateString,
|
||||
} from '#/components/forms/DateField/utils'
|
||||
import DatePicker from 'react-native-date-picker'
|
||||
import {isAndroid} from 'platform/detection'
|
||||
|
||||
import {useTheme} from '#/alf'
|
||||
import {DateFieldProps} from '#/components/forms/DateField/types'
|
||||
import {toSimpleDateString} from '#/components/forms/DateField/utils'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {DateFieldButton} from './index.shared'
|
||||
|
||||
export * as utils from '#/components/forms/DateField/utils'
|
||||
export const Label = TextField.Label
|
||||
export const LabelText = TextField.LabelText
|
||||
|
||||
export function DateField({
|
||||
value,
|
||||
@@ -24,18 +16,10 @@ export function DateField({
|
||||
label,
|
||||
isInvalid,
|
||||
testID,
|
||||
accessibilityHint,
|
||||
}: DateFieldProps) {
|
||||
const t = useTheme()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const {
|
||||
state: pressed,
|
||||
onIn: onPressIn,
|
||||
onOut: onPressOut,
|
||||
} = useInteractionState()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
|
||||
const {chromeFocus, chromeError, chromeErrorHover} =
|
||||
TextField.useSharedInputStyles()
|
||||
|
||||
const onChangeInternal = React.useCallback(
|
||||
(date: Date) => {
|
||||
@@ -47,50 +31,29 @@ export function DateField({
|
||||
[onChangeDate, setOpen],
|
||||
)
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
setOpen(true)
|
||||
}, [])
|
||||
|
||||
const onCancel = React.useCallback(() => {
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View style={[atoms.relative, atoms.w_full]}>
|
||||
<Pressable
|
||||
aria-label={label}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={undefined}
|
||||
onPress={() => setOpen(true)}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={[
|
||||
{
|
||||
paddingTop: 16,
|
||||
paddingBottom: 16,
|
||||
borderColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
},
|
||||
atoms.flex_row,
|
||||
atoms.flex_1,
|
||||
atoms.w_full,
|
||||
atoms.px_lg,
|
||||
atoms.rounded_sm,
|
||||
t.atoms.bg_contrast_50,
|
||||
focused || pressed ? chromeFocus : {},
|
||||
isInvalid ? chromeError : {},
|
||||
isInvalid && (focused || pressed) ? chromeErrorHover : {},
|
||||
]}>
|
||||
<TextField.Icon icon={CalendarDays} />
|
||||
|
||||
<Text
|
||||
style={[atoms.text_md, atoms.pl_xs, t.atoms.text, {paddingTop: 3}]}>
|
||||
{localizeDate(value)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<>
|
||||
<DateFieldButton
|
||||
label={label}
|
||||
value={value}
|
||||
onPress={onPress}
|
||||
isInvalid={isInvalid}
|
||||
accessibilityHint={accessibilityHint}
|
||||
/>
|
||||
|
||||
{open && (
|
||||
<DatePicker
|
||||
modal={isAndroid}
|
||||
open={isAndroid}
|
||||
modal
|
||||
open
|
||||
timeZoneOffsetInMinutes={0}
|
||||
theme={t.name === 'light' ? 'light' : 'dark'}
|
||||
date={new Date(value)}
|
||||
onConfirm={onChangeInternal}
|
||||
@@ -99,9 +62,9 @@ export function DateField({
|
||||
testID={`${testID}-datepicker`}
|
||||
aria-label={label}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={undefined}
|
||||
accessibilityHint={accessibilityHint}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import React from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
|
||||
import {android, atoms as a, useTheme, web} from '#/alf'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {localizeDate} from './utils'
|
||||
|
||||
// looks like a TextField.Input, but is just a button. It'll do something different on each platform on press
|
||||
// iOS: open a dialog with an inline date picker
|
||||
// Android: open the date picker modal
|
||||
|
||||
export function DateFieldButton({
|
||||
label,
|
||||
value,
|
||||
onPress,
|
||||
isInvalid,
|
||||
accessibilityHint,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onPress: () => void
|
||||
isInvalid?: boolean
|
||||
accessibilityHint?: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
const {
|
||||
state: pressed,
|
||||
onIn: onPressIn,
|
||||
onOut: onPressOut,
|
||||
} = useInteractionState()
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onHoverIn,
|
||||
onOut: onHoverOut,
|
||||
} = useInteractionState()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
|
||||
const {chromeHover, chromeFocus, chromeError, chromeErrorHover} =
|
||||
TextField.useSharedInputStyles()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[a.relative, a.w_full]}
|
||||
{...web({
|
||||
onMouseOver: onHoverIn,
|
||||
onMouseOut: onHoverOut,
|
||||
})}>
|
||||
<Pressable
|
||||
aria-label={label}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={accessibilityHint}
|
||||
onPress={onPress}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={[
|
||||
{
|
||||
paddingTop: 12,
|
||||
paddingBottom: 12,
|
||||
paddingLeft: 14,
|
||||
paddingRight: 14,
|
||||
borderColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
},
|
||||
android({
|
||||
minHeight: 57.5,
|
||||
}),
|
||||
a.flex_row,
|
||||
a.flex_1,
|
||||
a.w_full,
|
||||
a.rounded_sm,
|
||||
t.atoms.bg_contrast_25,
|
||||
a.align_center,
|
||||
hovered ? chromeHover : {},
|
||||
focused || pressed ? chromeFocus : {},
|
||||
isInvalid || isInvalid ? chromeError : {},
|
||||
(isInvalid || isInvalid) && (hovered || focused)
|
||||
? chromeErrorHover
|
||||
: {},
|
||||
]}>
|
||||
<TextField.Icon icon={CalendarDays} />
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.pl_xs,
|
||||
t.atoms.text,
|
||||
{lineHeight: a.text_md.fontSize * 1.1875},
|
||||
]}>
|
||||
{localizeDate(value)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {useTheme, atoms} from '#/alf'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {toSimpleDateString} from '#/components/forms/DateField/utils'
|
||||
import {DateFieldProps} from '#/components/forms/DateField/types'
|
||||
import DatePicker from 'react-native-date-picker'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {DateFieldProps} from '#/components/forms/DateField/types'
|
||||
import {toSimpleDateString} from '#/components/forms/DateField/utils'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {DateFieldButton} from './index.shared'
|
||||
|
||||
export * as utils from '#/components/forms/DateField/utils'
|
||||
export const Label = TextField.Label
|
||||
export const LabelText = TextField.LabelText
|
||||
|
||||
/**
|
||||
* Date-only input. Accepts a date in the format YYYY-MM-DD, and reports date
|
||||
@@ -22,8 +27,12 @@ export function DateField({
|
||||
onChangeDate,
|
||||
testID,
|
||||
label,
|
||||
isInvalid,
|
||||
accessibilityHint,
|
||||
}: DateFieldProps) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
const onChangeInternal = React.useCallback(
|
||||
(date: Date | undefined) => {
|
||||
@@ -36,17 +45,44 @@ export function DateField({
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[atoms.relative, atoms.w_full]}>
|
||||
<DatePicker
|
||||
theme={t.name === 'light' ? 'light' : 'dark'}
|
||||
date={new Date(value)}
|
||||
onDateChange={onChangeInternal}
|
||||
mode="date"
|
||||
testID={`${testID}-datepicker`}
|
||||
aria-label={label}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={undefined}
|
||||
<>
|
||||
<DateFieldButton
|
||||
label={label}
|
||||
value={value}
|
||||
onPress={control.open}
|
||||
isInvalid={isInvalid}
|
||||
accessibilityHint={accessibilityHint}
|
||||
/>
|
||||
</View>
|
||||
<Dialog.Outer control={control} testID={testID}>
|
||||
<Dialog.Handle />
|
||||
<Dialog.Inner label={label}>
|
||||
<View style={a.gap_lg}>
|
||||
<View style={[a.relative, a.w_full, a.align_center]}>
|
||||
<DatePicker
|
||||
timeZoneOffsetInMinutes={0}
|
||||
theme={t.name === 'light' ? 'light' : 'dark'}
|
||||
date={new Date(value)}
|
||||
onDateChange={onChangeInternal}
|
||||
mode="date"
|
||||
testID={`${testID}-datepicker`}
|
||||
aria-label={label}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={accessibilityHint}
|
||||
/>
|
||||
</View>
|
||||
<Button
|
||||
label={_(msg`Done`)}
|
||||
onPress={() => control.close()}
|
||||
size="medium"
|
||||
color="primary"
|
||||
variant="solid">
|
||||
<ButtonText>
|
||||
<Trans>Done</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</Dialog.Inner>
|
||||
</Dialog.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import React from 'react'
|
||||
import {TextInput, TextInputProps, StyleSheet} from 'react-native'
|
||||
import {StyleSheet, TextInput, TextInputProps} from 'react-native'
|
||||
// @ts-ignore
|
||||
import {unstable_createElement} from 'react-native-web'
|
||||
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {toSimpleDateString} from '#/components/forms/DateField/utils'
|
||||
import {DateFieldProps} from '#/components/forms/DateField/types'
|
||||
import {toSimpleDateString} from '#/components/forms/DateField/utils'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
|
||||
|
||||
export * as utils from '#/components/forms/DateField/utils'
|
||||
export const Label = TextField.Label
|
||||
export const LabelText = TextField.LabelText
|
||||
|
||||
const InputBase = React.forwardRef<HTMLInputElement, TextInputProps>(
|
||||
({style, ...props}, ref) => {
|
||||
@@ -37,6 +38,7 @@ export function DateField({
|
||||
label,
|
||||
isInvalid,
|
||||
testID,
|
||||
accessibilityHint,
|
||||
}: DateFieldProps) {
|
||||
const handleOnChange = React.useCallback(
|
||||
(e: any) => {
|
||||
@@ -52,12 +54,14 @@ export function DateField({
|
||||
|
||||
return (
|
||||
<TextField.Root isInvalid={isInvalid}>
|
||||
<TextField.Icon icon={CalendarDays} />
|
||||
<Input
|
||||
value={value}
|
||||
label={label}
|
||||
onChange={handleOnChange}
|
||||
onChangeText={() => {}}
|
||||
testID={testID}
|
||||
accessibilityHint={accessibilityHint}
|
||||
/>
|
||||
</TextField.Root>
|
||||
)
|
||||
|
||||
@@ -4,4 +4,5 @@ export type DateFieldProps = {
|
||||
label: string
|
||||
isInvalid?: boolean
|
||||
testID?: string
|
||||
accessibilityHint?: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function FormError({error}: {error?: string}) {
|
||||
const t = useTheme()
|
||||
|
||||
if (!error) return null
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{backgroundColor: t.palette.negative_400},
|
||||
a.flex_row,
|
||||
a.rounded_sm,
|
||||
a.p_md,
|
||||
a.gap_sm,
|
||||
]}>
|
||||
<Warning fill={t.palette.white} size="md" />
|
||||
<View style={[a.flex_1]}>
|
||||
<Text style={[{color: t.palette.white}, a.font_bold, a.leading_snug]}>
|
||||
{error}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import React from 'react'
|
||||
import {Keyboard, View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {toNiceDomain} from '#/lib/strings/url-helpers'
|
||||
import {isAndroid} from '#/platform/detection'
|
||||
import {ServerInputDialog} from '#/view/com/auth/server-input'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe'
|
||||
import {PencilLine_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil'
|
||||
import {Button} from '../Button'
|
||||
import {useDialogControl} from '../Dialog'
|
||||
import {Text} from '../Typography'
|
||||
|
||||
export function HostingProvider({
|
||||
serviceUrl,
|
||||
onSelectServiceUrl,
|
||||
onOpenDialog,
|
||||
}: {
|
||||
serviceUrl: string
|
||||
onSelectServiceUrl: (provider: string) => void
|
||||
onOpenDialog?: () => void
|
||||
}) {
|
||||
const serverInputControl = useDialogControl()
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const onPressSelectService = React.useCallback(() => {
|
||||
Keyboard.dismiss()
|
||||
serverInputControl.open()
|
||||
if (onOpenDialog) {
|
||||
onOpenDialog()
|
||||
}
|
||||
}, [onOpenDialog, serverInputControl])
|
||||
|
||||
return (
|
||||
<>
|
||||
<ServerInputDialog
|
||||
control={serverInputControl}
|
||||
onSelect={onSelectServiceUrl}
|
||||
/>
|
||||
<Button
|
||||
testID="selectServiceButton"
|
||||
label={toNiceDomain(serviceUrl)}
|
||||
accessibilityHint={_(msg`Press to change hosting provider`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.rounded_sm,
|
||||
a.px_md,
|
||||
a.pr_sm,
|
||||
a.gap_xs,
|
||||
{paddingVertical: isAndroid ? 14 : 9},
|
||||
]}
|
||||
onPress={onPressSelectService}>
|
||||
{({hovered, pressed}) => {
|
||||
const interacted = hovered || pressed
|
||||
return (
|
||||
<>
|
||||
<View style={a.pr_xs}>
|
||||
<Globe
|
||||
size="md"
|
||||
fill={
|
||||
interacted ? t.palette.contrast_800 : t.palette.contrast_500
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[a.text_md]}>{toNiceDomain(serviceUrl)}</Text>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
interacted
|
||||
? t.atoms.bg_contrast_300
|
||||
: t.atoms.bg_contrast_100,
|
||||
{marginLeft: 'auto', padding: 6},
|
||||
]}>
|
||||
<Pencil
|
||||
size="sm"
|
||||
style={{
|
||||
color: interacted
|
||||
? t.palette.contrast_800
|
||||
: t.palette.contrast_500,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
View,
|
||||
AccessibilityProps,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TextInputProps,
|
||||
TextStyle,
|
||||
View,
|
||||
ViewStyle,
|
||||
StyleSheet,
|
||||
AccessibilityProps,
|
||||
} from 'react-native'
|
||||
|
||||
import {mergeRefs} from '#/lib/merge-refs'
|
||||
import {HITSLOP_20} from 'lib/constants'
|
||||
import {useTheme, atoms as a, web, android} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {android, atoms as a, useTheme, web} from '#/alf'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const Context = React.createContext<{
|
||||
inputRef: React.RefObject<TextInput> | null
|
||||
@@ -125,9 +126,10 @@ export function useSharedInputStyles() {
|
||||
|
||||
export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
|
||||
label: string
|
||||
value: string
|
||||
onChangeText: (value: string) => void
|
||||
value?: string
|
||||
onChangeText?: (value: string) => void
|
||||
isInvalid?: boolean
|
||||
inputRef?: React.RefObject<TextInput>
|
||||
}
|
||||
|
||||
export function createInput(Component: typeof TextInput) {
|
||||
@@ -137,6 +139,7 @@ export function createInput(Component: typeof TextInput) {
|
||||
value,
|
||||
onChangeText,
|
||||
isInvalid,
|
||||
inputRef,
|
||||
...rest
|
||||
}: InputProps) {
|
||||
const t = useTheme()
|
||||
@@ -161,19 +164,22 @@ export function createInput(Component: typeof TextInput) {
|
||||
)
|
||||
}
|
||||
|
||||
const refs = mergeRefs([ctx.inputRef, inputRef!].filter(Boolean))
|
||||
|
||||
return (
|
||||
<>
|
||||
<Component
|
||||
accessibilityHint={undefined}
|
||||
{...rest}
|
||||
accessibilityLabel={label}
|
||||
ref={ctx.inputRef}
|
||||
ref={refs}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
onFocus={ctx.onFocus}
|
||||
onBlur={ctx.onBlur}
|
||||
placeholder={placeholder || label}
|
||||
placeholderTextColor={t.palette.contrast_500}
|
||||
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
|
||||
hitSlop={HITSLOP_20}
|
||||
style={[
|
||||
a.relative,
|
||||
@@ -219,7 +225,7 @@ export function createInput(Component: typeof TextInput) {
|
||||
|
||||
export const Input = createInput(TextInput)
|
||||
|
||||
export function Label({
|
||||
export function LabelText({
|
||||
nativeID,
|
||||
children,
|
||||
}: React.PropsWithChildren<{nativeID?: string}>) {
|
||||
@@ -271,7 +277,7 @@ export function Icon({icon: Comp}: {icon: React.ComponentType<SVGIconProps>}) {
|
||||
<Comp
|
||||
size="md"
|
||||
style={[
|
||||
{color: t.palette.contrast_500, pointerEvents: 'none'},
|
||||
{color: t.palette.contrast_500, pointerEvents: 'none', flexShrink: 0},
|
||||
ctx.hovered ? hover : {},
|
||||
ctx.focused ? focus : {},
|
||||
ctx.isInvalid && ctx.hovered ? errorHover : {},
|
||||
@@ -282,7 +288,7 @@ export function Icon({icon: Comp}: {icon: React.ComponentType<SVGIconProps>}) {
|
||||
)
|
||||
}
|
||||
|
||||
export function Suffix({
|
||||
export function SuffixText({
|
||||
children,
|
||||
label,
|
||||
accessibilityHint,
|
||||
|
||||
@@ -3,16 +3,16 @@ import {Pressable, View, ViewStyle} from 'react-native'
|
||||
|
||||
import {HITSLOP_10} from 'lib/constants'
|
||||
import {
|
||||
useTheme,
|
||||
atoms as a,
|
||||
native,
|
||||
flatten,
|
||||
ViewStyleProp,
|
||||
native,
|
||||
TextStyleProp,
|
||||
useTheme,
|
||||
ViewStyleProp,
|
||||
} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export type ItemState = {
|
||||
name: string
|
||||
@@ -234,7 +234,7 @@ export function Item({
|
||||
)
|
||||
}
|
||||
|
||||
export function Label({
|
||||
export function LabelText({
|
||||
children,
|
||||
style,
|
||||
}: React.PropsWithChildren<TextStyleProp>) {
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import React from 'react'
|
||||
import {View, AccessibilityProps, TextStyle, ViewStyle} from 'react-native'
|
||||
import {AccessibilityProps, TextStyle, View, ViewStyle} from 'react-native'
|
||||
|
||||
import {atoms as a, useTheme, native} from '#/alf'
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
|
||||
export type ItemProps = Omit<Toggle.ItemProps, 'style' | 'role' | 'children'> &
|
||||
AccessibilityProps &
|
||||
React.PropsWithChildren<{
|
||||
type ItemProps = Omit<Toggle.ItemProps, 'style' | 'role' | 'children'> &
|
||||
AccessibilityProps & {
|
||||
children: React.ReactElement
|
||||
testID?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type GroupProps = Omit<Toggle.GroupProps, 'style' | 'type'> & {
|
||||
multiple?: boolean
|
||||
@@ -47,49 +46,42 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) {
|
||||
const t = useTheme()
|
||||
const state = Toggle.useItemContext()
|
||||
|
||||
const {baseStyles, hoverStyles, activeStyles, textStyles} =
|
||||
React.useMemo(() => {
|
||||
const base: ViewStyle[] = []
|
||||
const hover: ViewStyle[] = []
|
||||
const active: ViewStyle[] = []
|
||||
const text: TextStyle[] = []
|
||||
const {baseStyles, hoverStyles, activeStyles} = React.useMemo(() => {
|
||||
const base: ViewStyle[] = []
|
||||
const hover: ViewStyle[] = []
|
||||
const active: ViewStyle[] = []
|
||||
|
||||
hover.push(
|
||||
t.name === 'light' ? t.atoms.bg_contrast_100 : t.atoms.bg_contrast_25,
|
||||
)
|
||||
hover.push(
|
||||
t.name === 'light' ? t.atoms.bg_contrast_100 : t.atoms.bg_contrast_25,
|
||||
)
|
||||
|
||||
if (state.selected) {
|
||||
active.push({
|
||||
backgroundColor: t.palette.contrast_800,
|
||||
})
|
||||
text.push(t.atoms.text_inverted)
|
||||
hover.push({
|
||||
backgroundColor: t.palette.contrast_800,
|
||||
})
|
||||
|
||||
if (state.disabled) {
|
||||
active.push({
|
||||
backgroundColor: t.palette.contrast_500,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (state.selected) {
|
||||
active.push({
|
||||
backgroundColor: t.palette.contrast_800,
|
||||
})
|
||||
hover.push({
|
||||
backgroundColor: t.palette.contrast_800,
|
||||
})
|
||||
|
||||
if (state.disabled) {
|
||||
base.push({
|
||||
backgroundColor: t.palette.contrast_100,
|
||||
})
|
||||
text.push({
|
||||
opacity: 0.5,
|
||||
active.push({
|
||||
backgroundColor: t.palette.contrast_500,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
baseStyles: base,
|
||||
hoverStyles: hover,
|
||||
activeStyles: active,
|
||||
textStyles: text,
|
||||
}
|
||||
}, [t, state])
|
||||
if (state.disabled) {
|
||||
base.push({
|
||||
backgroundColor: t.palette.contrast_100,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
baseStyles: base,
|
||||
hoverStyles: hover,
|
||||
activeStyles: active,
|
||||
}
|
||||
}, [t, state])
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -110,19 +102,37 @@ function ButtonInner({children}: React.PropsWithChildren<{}>) {
|
||||
activeStyles,
|
||||
(state.hovered || state.pressed) && hoverStyles,
|
||||
]}>
|
||||
{typeof children === 'string' ? (
|
||||
<Text
|
||||
style={[
|
||||
a.text_center,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_medium,
|
||||
textStyles,
|
||||
]}>
|
||||
{children}
|
||||
</Text>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function ButtonText({children}: {children: React.ReactNode}) {
|
||||
const t = useTheme()
|
||||
const state = Toggle.useItemContext()
|
||||
|
||||
const textStyles = React.useMemo(() => {
|
||||
const text: TextStyle[] = []
|
||||
if (state.selected) {
|
||||
text.push(t.atoms.text_inverted)
|
||||
}
|
||||
if (state.disabled) {
|
||||
text.push({
|
||||
opacity: 0.5,
|
||||
})
|
||||
}
|
||||
return text
|
||||
}, [t, state])
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
a.text_center,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_medium,
|
||||
textStyles,
|
||||
]}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,12 @@
|
||||
import React from 'react'
|
||||
import {Keyboard} from 'react-native'
|
||||
|
||||
export function useOnKeyboardDidShow(cb: () => unknown) {
|
||||
React.useEffect(() => {
|
||||
const subscription = Keyboard.addListener('keyboardDidShow', cb)
|
||||
|
||||
return () => {
|
||||
subscription.remove()
|
||||
}
|
||||
}, [cb])
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react'
|
||||
import {RichText as RichTextAPI} from '@atproto/api'
|
||||
|
||||
import {getAgent} 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)
|
||||
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])
|
||||
const isResolving = resolvedRT === null
|
||||
return [resolvedRT ?? rawRT, isResolving]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Calendar_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M8 2a1 1 0 0 1 1 1v1h6V3a1 1 0 1 1 2 0v1h2a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2V3a1 1 0 0 1 1-1ZM5 6v3h14V6H5Zm14 5H5v8h14v-8Z',
|
||||
})
|
||||
@@ -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 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',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Lock_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M7 7a5 5 0 0 1 10 0v2h1a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2h1V7Zm-1 4v9h12v-9H6Zm9-2H9V7a3 3 0 1 1 6 0v2Zm-3 4a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
@@ -3,3 +3,7 @@ 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 Ticket_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M4 5.5a.5.5 0 0 0-.5.5v2.535a.5.5 0 0 0 .25.433A3.498 3.498 0 0 1 5.5 12a3.498 3.498 0 0 1-1.75 3.032.5.5 0 0 0-.25.433V18a.5.5 0 0 0 .5.5h16a.5.5 0 0 0 .5-.5v-2.535a.5.5 0 0 0-.25-.433A3.498 3.498 0 0 1 18.5 12a3.5 3.5 0 0 1 1.75-3.032.5.5 0 0 0 .25-.433V6a.5.5 0 0 0-.5-.5H4ZM2.5 6A1.5 1.5 0 0 1 4 4.5h16A1.5 1.5 0 0 1 21.5 6v3.17a.5.5 0 0 1-.333.472 2.501 2.501 0 0 0 0 4.716.5.5 0 0 1 .333.471V18a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18v-3.17a.5.5 0 0 1 .333-.472 2.501 2.501 0 0 0 0-4.716.5.5 0 0 1-.333-.471V6Zm12 2a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Zm0 4a.5.5 0 1 1 1 0 .5.5 0 0 1-1 0Z',
|
||||
})
|
||||
@@ -1,93 +0,0 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg} from '@lingui/macro'
|
||||
|
||||
import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
usePreferencesSetContentLabelMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
|
||||
import {useTheme, atoms as a} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as ToggleButton from '#/components/forms/ToggleButton'
|
||||
|
||||
export function GlobalModerationLabelPref({
|
||||
labelValueDefinition,
|
||||
disabled,
|
||||
}: {
|
||||
labelValueDefinition: InterpretedLabelValueDefinition
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const {identifier} = labelValueDefinition
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {mutate, variables} = usePreferencesSetContentLabelMutation()
|
||||
const savedPref = preferences?.moderationPrefs.labels[identifier]
|
||||
const pref = variables?.visibility ?? savedPref ?? 'warn'
|
||||
|
||||
const allLabelStrings = useGlobalLabelStrings()
|
||||
const labelStrings =
|
||||
labelValueDefinition.identifier in allLabelStrings
|
||||
? allLabelStrings[labelValueDefinition.identifier]
|
||||
: {
|
||||
name: labelValueDefinition.identifier,
|
||||
description: `Labeled "${labelValueDefinition.identifier}"`,
|
||||
}
|
||||
|
||||
const labelOptions = {
|
||||
hide: _(msg`Hide`),
|
||||
warn: _(msg`Warn`),
|
||||
ignore: _(msg`Show`),
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_between,
|
||||
a.gap_sm,
|
||||
a.py_md,
|
||||
a.pl_lg,
|
||||
a.pr_md,
|
||||
a.align_center,
|
||||
]}>
|
||||
<View style={[a.gap_xs, a.flex_1]}>
|
||||
<Text style={[a.font_bold]}>{labelStrings.name}</Text>
|
||||
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||
{labelStrings.description}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[a.justify_center, {minHeight: 35}]}>
|
||||
{!disabled && (
|
||||
<ToggleButton.Group
|
||||
label={_(
|
||||
msg`Configure content filtering setting for category: ${labelStrings.name.toLowerCase()}`,
|
||||
)}
|
||||
values={[pref]}
|
||||
onChange={newPref =>
|
||||
mutate({
|
||||
label: identifier,
|
||||
visibility: newPref[0] as LabelPreference,
|
||||
labelerDid: undefined,
|
||||
})
|
||||
}>
|
||||
<ToggleButton.Button name="ignore" label={labelOptions.ignore}>
|
||||
{labelOptions.ignore}
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button name="warn" label={labelOptions.warn}>
|
||||
{labelOptions.warn}
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button name="hide" label={labelOptions.hide}>
|
||||
{labelOptions.hide}
|
||||
</ToggleButton.Button>
|
||||
</ToggleButton.Group>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
|
||||
import {useLabelBehaviorDescription} from '#/lib/moderation/useLabelBehaviorDescription'
|
||||
import {getLabelStrings} from '#/lib/moderation/useLabelInfo'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
usePreferencesSetContentLabelMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import * as ToggleButton from '#/components/forms/ToggleButton'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '../icons/CircleInfo'
|
||||
|
||||
export function Outer({children}: React.PropsWithChildren<{}>) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.gap_md,
|
||||
a.px_lg,
|
||||
a.py_lg,
|
||||
a.justify_between,
|
||||
a.flex_wrap,
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Content({
|
||||
children,
|
||||
name,
|
||||
description,
|
||||
}: React.PropsWithChildren<{
|
||||
name: string
|
||||
description: string
|
||||
}>) {
|
||||
const t = useTheme()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
|
||||
return (
|
||||
<View style={[a.gap_xs, a.flex_1]}>
|
||||
<Text style={[a.font_bold, gtPhone ? a.text_sm : a.text_md]}>{name}</Text>
|
||||
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||
{description}
|
||||
</Text>
|
||||
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Buttons({
|
||||
name,
|
||||
values,
|
||||
onChange,
|
||||
ignoreLabel,
|
||||
warnLabel,
|
||||
hideLabel,
|
||||
}: {
|
||||
name: string
|
||||
values: ToggleButton.GroupProps['values']
|
||||
onChange: ToggleButton.GroupProps['onChange']
|
||||
ignoreLabel?: string
|
||||
warnLabel?: string
|
||||
hideLabel?: string
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
|
||||
return (
|
||||
<View style={[{minHeight: 35}, gtPhone ? undefined : a.w_full]}>
|
||||
<ToggleButton.Group
|
||||
label={_(
|
||||
msg`Configure content filtering setting for category: ${name}`,
|
||||
)}
|
||||
values={values}
|
||||
onChange={onChange}>
|
||||
{ignoreLabel && (
|
||||
<ToggleButton.Button name="ignore" label={ignoreLabel}>
|
||||
<ToggleButton.ButtonText>{ignoreLabel}</ToggleButton.ButtonText>
|
||||
</ToggleButton.Button>
|
||||
)}
|
||||
{warnLabel && (
|
||||
<ToggleButton.Button name="warn" label={warnLabel}>
|
||||
<ToggleButton.ButtonText>{warnLabel}</ToggleButton.ButtonText>
|
||||
</ToggleButton.Button>
|
||||
)}
|
||||
{hideLabel && (
|
||||
<ToggleButton.Button name="hide" label={hideLabel}>
|
||||
<ToggleButton.ButtonText>{hideLabel}</ToggleButton.ButtonText>
|
||||
</ToggleButton.Button>
|
||||
)}
|
||||
</ToggleButton.Group>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* For use on the global Moderation screen to set prefs for a "global" label,
|
||||
* not scoped to a single labeler.
|
||||
*/
|
||||
export function GlobalLabelPreference({
|
||||
labelDefinition,
|
||||
disabled,
|
||||
}: {
|
||||
labelDefinition: InterpretedLabelValueDefinition
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
|
||||
const {identifier} = labelDefinition
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {mutate, variables} = usePreferencesSetContentLabelMutation()
|
||||
const savedPref = preferences?.moderationPrefs.labels[identifier]
|
||||
const pref = variables?.visibility ?? savedPref ?? 'warn'
|
||||
|
||||
const allLabelStrings = useGlobalLabelStrings()
|
||||
const labelStrings =
|
||||
labelDefinition.identifier in allLabelStrings
|
||||
? allLabelStrings[labelDefinition.identifier]
|
||||
: {
|
||||
name: labelDefinition.identifier,
|
||||
description: `Labeled "${labelDefinition.identifier}"`,
|
||||
}
|
||||
|
||||
const labelOptions = {
|
||||
hide: _(msg`Hide`),
|
||||
warn: _(msg`Warn`),
|
||||
ignore: _(msg`Show`),
|
||||
}
|
||||
|
||||
return (
|
||||
<Outer>
|
||||
<Content
|
||||
name={labelStrings.name}
|
||||
description={labelStrings.description}
|
||||
/>
|
||||
{!disabled && (
|
||||
<Buttons
|
||||
name={labelStrings.name.toLowerCase()}
|
||||
values={[pref]}
|
||||
onChange={values => {
|
||||
mutate({
|
||||
label: identifier,
|
||||
visibility: values[0] as LabelPreference,
|
||||
labelerDid: undefined,
|
||||
})
|
||||
}}
|
||||
ignoreLabel={labelOptions.ignore}
|
||||
warnLabel={labelOptions.warn}
|
||||
hideLabel={labelOptions.hide}
|
||||
/>
|
||||
)}
|
||||
</Outer>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* For use on individual labeler pages
|
||||
*/
|
||||
export function LabelerLabelPreference({
|
||||
labelDefinition,
|
||||
disabled,
|
||||
labelerDid,
|
||||
}: {
|
||||
labelDefinition: InterpretedLabelValueDefinition
|
||||
disabled?: boolean
|
||||
labelerDid?: string
|
||||
}) {
|
||||
const {i18n} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
|
||||
const isGlobalLabel = !labelDefinition.definedBy
|
||||
const {identifier} = labelDefinition
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {mutate, variables} = usePreferencesSetContentLabelMutation()
|
||||
const savedPref =
|
||||
labelerDid && !isGlobalLabel
|
||||
? preferences?.moderationPrefs.labelers.find(l => l.did === labelerDid)
|
||||
?.labels[identifier]
|
||||
: preferences?.moderationPrefs.labels[identifier]
|
||||
const pref =
|
||||
variables?.visibility ??
|
||||
savedPref ??
|
||||
labelDefinition.defaultSetting ??
|
||||
'warn'
|
||||
|
||||
// does the 'warn' setting make sense for this label?
|
||||
const canWarn = !(
|
||||
labelDefinition.blurs === 'none' && labelDefinition.severity === 'none'
|
||||
)
|
||||
// is this label adult only?
|
||||
const adultOnly = labelDefinition.flags.includes('adult')
|
||||
// is this label disabled because it's adult only?
|
||||
const adultDisabled =
|
||||
adultOnly && !preferences?.moderationPrefs.adultContentEnabled
|
||||
// are there any reasons we cant configure this label here?
|
||||
const cantConfigure = isGlobalLabel || adultDisabled
|
||||
const showConfig = !disabled && (gtPhone || !cantConfigure)
|
||||
|
||||
// adjust the pref based on whether warn is available
|
||||
let prefAdjusted = pref
|
||||
if (adultDisabled) {
|
||||
prefAdjusted = 'hide'
|
||||
} else if (!canWarn && pref === 'warn') {
|
||||
prefAdjusted = 'ignore'
|
||||
}
|
||||
|
||||
// grab localized descriptions of the label and its settings
|
||||
const currentPrefLabel = useLabelBehaviorDescription(
|
||||
labelDefinition,
|
||||
prefAdjusted,
|
||||
)
|
||||
const hideLabel = useLabelBehaviorDescription(labelDefinition, 'hide')
|
||||
const warnLabel = useLabelBehaviorDescription(labelDefinition, 'warn')
|
||||
const ignoreLabel = useLabelBehaviorDescription(labelDefinition, 'ignore')
|
||||
const globalLabelStrings = useGlobalLabelStrings()
|
||||
const labelStrings = getLabelStrings(
|
||||
i18n.locale,
|
||||
globalLabelStrings,
|
||||
labelDefinition,
|
||||
)
|
||||
|
||||
return (
|
||||
<Outer>
|
||||
<Content name={labelStrings.name} description={labelStrings.description}>
|
||||
{cantConfigure && (
|
||||
<View style={[a.flex_row, a.gap_xs, a.align_center, a.mt_xs]}>
|
||||
<CircleInfo size="sm" fill={t.atoms.text_contrast_high.color} />
|
||||
|
||||
<Text
|
||||
style={[t.atoms.text_contrast_medium, a.font_semibold, a.italic]}>
|
||||
{adultDisabled ? (
|
||||
<Trans>Adult content is disabled.</Trans>
|
||||
) : isGlobalLabel ? (
|
||||
<Trans>
|
||||
Configured in{' '}
|
||||
<InlineLinkText to="/moderation" style={a.text_sm}>
|
||||
moderation settings
|
||||
</InlineLinkText>
|
||||
.
|
||||
</Trans>
|
||||
) : null}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Content>
|
||||
|
||||
{showConfig && (
|
||||
<View style={[gtPhone ? undefined : a.w_full]}>
|
||||
{cantConfigure ? (
|
||||
<View
|
||||
style={[
|
||||
{minHeight: 35},
|
||||
a.px_md,
|
||||
a.py_md,
|
||||
a.rounded_sm,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<Text style={[a.font_bold, t.atoms.text_contrast_low]}>
|
||||
{currentPrefLabel}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Buttons
|
||||
name={labelStrings.name.toLowerCase()}
|
||||
values={[pref]}
|
||||
onChange={values => {
|
||||
mutate({
|
||||
label: identifier,
|
||||
visibility: values[0] as LabelPreference,
|
||||
labelerDid,
|
||||
})
|
||||
}}
|
||||
ignoreLabel={ignoreLabel}
|
||||
warnLabel={canWarn ? warnLabel : undefined}
|
||||
hideLabel={hideLabel}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Outer>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,19 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {ComAtprotoLabelDefs, ComAtprotoModerationDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {ComAtprotoLabelDefs, ComAtprotoModerationDefs} from '@atproto/api'
|
||||
|
||||
import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {InlineLink} from '#/components/Link'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Divider} from '../Divider'
|
||||
|
||||
export {useDialogControl as useLabelsOnMeDialogControl} from '#/components/Dialog'
|
||||
@@ -145,13 +144,13 @@ function Label({
|
||||
<View style={[a.px_md, a.py_sm, t.atoms.bg_contrast_25]}>
|
||||
<Text style={[t.atoms.text_contrast_medium]}>
|
||||
<Trans>Source:</Trans>{' '}
|
||||
<InlineLink
|
||||
<InlineLinkText
|
||||
to={makeProfileLink(
|
||||
labeler ? labeler.creator : {did: label.src, handle: ''},
|
||||
)}
|
||||
onPress={() => control.close()}>
|
||||
{labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src}
|
||||
</InlineLink>
|
||||
</InlineLinkText>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -204,14 +203,14 @@ function AppealForm({
|
||||
<Text style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>
|
||||
This appeal will be sent to{' '}
|
||||
<InlineLink
|
||||
<InlineLinkText
|
||||
to={makeProfileLink(
|
||||
labeler ? labeler.creator : {did: label.src, handle: ''},
|
||||
)}
|
||||
onPress={() => control.close()}
|
||||
style={[a.text_md, a.leading_snug]}>
|
||||
{labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src}
|
||||
</InlineLink>
|
||||
</InlineLinkText>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
@@ -245,7 +244,7 @@ function AppealForm({
|
||||
size="medium"
|
||||
onPress={onPressBack}
|
||||
label={_(msg`Back`)}>
|
||||
{_(msg`Back`)}
|
||||
<ButtonText>{_(msg`Back`)}</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
testID="submitBtn"
|
||||
@@ -254,7 +253,7 @@ function AppealForm({
|
||||
size="medium"
|
||||
onPress={onSubmit}
|
||||
label={_(msg`Submit`)}>
|
||||
{_(msg`Submit`)}
|
||||
<ButtonText>{_(msg`Submit`)}</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {ModerationCause} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {ModerationCause} from '@atproto/api'
|
||||
|
||||
import {listUriToHref} from '#/lib/strings/url-helpers'
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
|
||||
import {listUriToHref} from '#/lib/strings/url-helpers'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useTheme, atoms as a} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {InlineLink} from '#/components/Link'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export {useDialogControl as useModerationDetailsDialogControl} from '#/components/Dialog'
|
||||
|
||||
@@ -55,9 +54,9 @@ function ModerationDetailsDialogInner({
|
||||
description = (
|
||||
<Trans>
|
||||
This user is included in the{' '}
|
||||
<InlineLink to={listUriToHref(list.uri)} style={[a.text_sm]}>
|
||||
<InlineLinkText to={listUriToHref(list.uri)} style={[a.text_sm]}>
|
||||
{list.name}
|
||||
</InlineLink>{' '}
|
||||
</InlineLinkText>{' '}
|
||||
list which you have blocked.
|
||||
</Trans>
|
||||
)
|
||||
@@ -84,9 +83,9 @@ function ModerationDetailsDialogInner({
|
||||
description = (
|
||||
<Trans>
|
||||
This user is included in the{' '}
|
||||
<InlineLink to={listUriToHref(list.uri)} style={[a.text_sm]}>
|
||||
<InlineLinkText to={listUriToHref(list.uri)} style={[a.text_sm]}>
|
||||
{list.name}
|
||||
</InlineLink>{' '}
|
||||
</InlineLinkText>{' '}
|
||||
list which you have muted.
|
||||
</Trans>
|
||||
)
|
||||
@@ -127,12 +126,12 @@ function ModerationDetailsDialogInner({
|
||||
{modcause.source.type === 'user' ? (
|
||||
<Trans>the author</Trans>
|
||||
) : (
|
||||
<InlineLink
|
||||
<InlineLinkText
|
||||
to={makeProfileLink({did: modcause.label.src, handle: ''})}
|
||||
onPress={() => control.close()}
|
||||
style={a.text_md}>
|
||||
{desc.source}
|
||||
</InlineLink>
|
||||
</InlineLinkText>
|
||||
)}
|
||||
.
|
||||
</Trans>
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
|
||||
import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
|
||||
import {useLabelBehaviorDescription} from '#/lib/moderation/useLabelBehaviorDescription'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
usePreferencesSetContentLabelMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {getLabelStrings} from '#/lib/moderation/useLabelInfo'
|
||||
|
||||
import {useTheme, atoms as a, useBreakpoints} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {InlineLink} from '#/components/Link'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '../icons/CircleInfo'
|
||||
import * as ToggleButton from '#/components/forms/ToggleButton'
|
||||
|
||||
export function ModerationLabelPref({
|
||||
labelValueDefinition,
|
||||
labelerDid,
|
||||
disabled,
|
||||
}: {
|
||||
labelValueDefinition: InterpretedLabelValueDefinition
|
||||
labelerDid: string | undefined
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const {_, i18n} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtPhone} = useBreakpoints()
|
||||
|
||||
const isGlobalLabel = !labelValueDefinition.definedBy
|
||||
const {identifier} = labelValueDefinition
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {mutate, variables} = usePreferencesSetContentLabelMutation()
|
||||
const savedPref =
|
||||
labelerDid && !isGlobalLabel
|
||||
? preferences?.moderationPrefs.labelers.find(l => l.did === labelerDid)
|
||||
?.labels[identifier]
|
||||
: preferences?.moderationPrefs.labels[identifier]
|
||||
const pref =
|
||||
variables?.visibility ??
|
||||
savedPref ??
|
||||
labelValueDefinition.defaultSetting ??
|
||||
'warn'
|
||||
|
||||
// does the 'warn' setting make sense for this label?
|
||||
const canWarn = !(
|
||||
labelValueDefinition.blurs === 'none' &&
|
||||
labelValueDefinition.severity === 'none'
|
||||
)
|
||||
// is this label adult only?
|
||||
const adultOnly = labelValueDefinition.flags.includes('adult')
|
||||
// is this label disabled because it's adult only?
|
||||
const adultDisabled =
|
||||
adultOnly && !preferences?.moderationPrefs.adultContentEnabled
|
||||
// are there any reasons we cant configure this label here?
|
||||
const cantConfigure = isGlobalLabel || adultDisabled
|
||||
const showConfig = !disabled && (gtPhone || !cantConfigure)
|
||||
|
||||
// adjust the pref based on whether warn is available
|
||||
let prefAdjusted = pref
|
||||
if (adultDisabled) {
|
||||
prefAdjusted = 'hide'
|
||||
} else if (!canWarn && pref === 'warn') {
|
||||
prefAdjusted = 'ignore'
|
||||
}
|
||||
|
||||
// grab localized descriptions of the label and its settings
|
||||
const currentPrefLabel = useLabelBehaviorDescription(
|
||||
labelValueDefinition,
|
||||
prefAdjusted,
|
||||
)
|
||||
const hideLabel = useLabelBehaviorDescription(labelValueDefinition, 'hide')
|
||||
const warnLabel = useLabelBehaviorDescription(labelValueDefinition, 'warn')
|
||||
const ignoreLabel = useLabelBehaviorDescription(
|
||||
labelValueDefinition,
|
||||
'ignore',
|
||||
)
|
||||
const globalLabelStrings = useGlobalLabelStrings()
|
||||
const labelStrings = getLabelStrings(
|
||||
i18n.locale,
|
||||
globalLabelStrings,
|
||||
labelValueDefinition,
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.gap_md,
|
||||
a.px_lg,
|
||||
a.py_lg,
|
||||
a.justify_between,
|
||||
a.flex_wrap,
|
||||
]}>
|
||||
<View style={[a.gap_xs, a.flex_1]}>
|
||||
<Text style={[a.font_bold, gtPhone ? a.text_sm : a.text_md]}>
|
||||
{labelStrings.name}
|
||||
</Text>
|
||||
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||
{labelStrings.description}
|
||||
</Text>
|
||||
|
||||
{cantConfigure && (
|
||||
<View style={[a.flex_row, a.gap_xs, a.align_center, a.mt_xs]}>
|
||||
<CircleInfo size="sm" fill={t.atoms.text_contrast_high.color} />
|
||||
|
||||
<Text
|
||||
style={[t.atoms.text_contrast_medium, a.font_semibold, a.italic]}>
|
||||
{adultDisabled ? (
|
||||
<Trans>Adult content is disabled.</Trans>
|
||||
) : isGlobalLabel ? (
|
||||
<Trans>
|
||||
Configured in{' '}
|
||||
<InlineLink to="/moderation" style={a.text_sm}>
|
||||
moderation settings
|
||||
</InlineLink>
|
||||
.
|
||||
</Trans>
|
||||
) : null}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{showConfig && (
|
||||
<View style={[gtPhone ? undefined : a.w_full]}>
|
||||
{cantConfigure ? (
|
||||
<View
|
||||
style={[
|
||||
{minHeight: 35},
|
||||
a.px_md,
|
||||
a.py_md,
|
||||
a.rounded_sm,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<Text style={[a.font_bold, t.atoms.text_contrast_low]}>
|
||||
{currentPrefLabel}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[{minHeight: 35}]}>
|
||||
<ToggleButton.Group
|
||||
label={_(
|
||||
msg`Configure content filtering setting for category: ${labelStrings.name.toLowerCase()}`,
|
||||
)}
|
||||
values={[prefAdjusted]}
|
||||
onChange={newPref =>
|
||||
mutate({
|
||||
label: identifier,
|
||||
visibility: newPref[0] as LabelPreference,
|
||||
labelerDid,
|
||||
})
|
||||
}>
|
||||
<ToggleButton.Button name="ignore" label={ignoreLabel}>
|
||||
{ignoreLabel}
|
||||
</ToggleButton.Button>
|
||||
{canWarn && (
|
||||
<ToggleButton.Button name="warn" label={warnLabel}>
|
||||
{warnLabel}
|
||||
</ToggleButton.Button>
|
||||
)}
|
||||
<ToggleButton.Button name="hide" label={hideLabel}>
|
||||
{hideLabel}
|
||||
</ToggleButton.Button>
|
||||
</ToggleButton.Group>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +1,26 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
TouchableWithoutFeedback,
|
||||
StyleProp,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {ModerationUI} from '@atproto/api'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
|
||||
import {useTheme, atoms as a} from '#/alf'
|
||||
import {CenteredView} from '#/view/com/util/Views'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {
|
||||
ModerationDetailsDialog,
|
||||
useModerationDetailsDialogControl,
|
||||
} from '#/components/moderation/ModerationDetailsDialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function ScreenHider({
|
||||
testID,
|
||||
@@ -56,7 +55,8 @@ export function ScreenHider({
|
||||
|
||||
const isNoPwi = !!modui.blurs.find(
|
||||
cause =>
|
||||
cause.type === 'label' && cause.labelDef.id === '!no-unauthenticated',
|
||||
cause.type === 'label' &&
|
||||
cause.labelDef.identifier === '!no-unauthenticated',
|
||||
)
|
||||
return (
|
||||
<CenteredView
|
||||
@@ -124,7 +124,15 @@ export function ScreenHider({
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Learn more about this warning`)}
|
||||
accessibilityHint="">
|
||||
<Text style={[a.text_lg, {color: t.palette.primary_500}]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_lg,
|
||||
{
|
||||
color: t.palette.primary_500,
|
||||
// @ts-ignore web only -prf
|
||||
cursor: 'pointer',
|
||||
},
|
||||
]}>
|
||||
<Trans>Learn More</Trans>
|
||||
</Text>
|
||||
</TouchableWithoutFeedback>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+71
-10
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetFeed as GetCustomFeed,
|
||||
AtpAgent,
|
||||
} from '@atproto/api'
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
import {getAgent} from '#/state/session'
|
||||
|
||||
import {getContentLanguages} from '#/state/preferences/languages'
|
||||
import {getAgent} from '#/state/session'
|
||||
import {FeedAPI, FeedAPIResponse} from './types'
|
||||
|
||||
export class CustomFeedAPI implements FeedAPI {
|
||||
constructor(public params: GetCustomFeed.QueryParams) {}
|
||||
@@ -29,14 +31,17 @@ 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 = getAgent()
|
||||
const res = agent.session
|
||||
? await 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 +60,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: []},
|
||||
}
|
||||
}
|
||||
|
||||
+8
-4
@@ -1,5 +1,9 @@
|
||||
import VersionNumber from 'react-native-version-number'
|
||||
import * as Updates from 'expo-updates'
|
||||
export const updateChannel = Updates.channel
|
||||
import {nativeApplicationVersion, nativeBuildVersion} from 'expo-application'
|
||||
|
||||
export const appVersion = `${VersionNumber.appVersion} (${VersionNumber.buildVersion})`
|
||||
export const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
|
||||
export const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
|
||||
|
||||
const UPDATES_CHANNEL = IS_TESTFLIGHT ? 'testflight' : 'production'
|
||||
export const appVersion = `${nativeApplicationVersion} (${nativeBuildVersion}, ${
|
||||
IS_DEV ? 'development' : UPDATES_CHANNEL
|
||||
})`
|
||||
|
||||
@@ -7,6 +7,8 @@ export const BSKY_SERVICE = 'https://bsky.social'
|
||||
export const DEFAULT_SERVICE = BSKY_SERVICE
|
||||
const HELP_DESK_LANG = 'en-us'
|
||||
export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
|
||||
export const EMBED_SERVICE = 'https://embed.bsky.app'
|
||||
export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
|
||||
|
||||
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
|
||||
export function FEEDBACK_FORM_URL({
|
||||
@@ -80,8 +82,10 @@ export const HITSLOP_30 = createHitslop(30)
|
||||
export const BACK_HITSLOP = HITSLOP_30
|
||||
export const MAX_POST_LINES = 25
|
||||
|
||||
export const BSKY_APP_ACCOUNT_DID = 'did:plc:z72i7hdynmk6r22z27h6tvur'
|
||||
|
||||
export const BSKY_FEED_OWNER_DIDS = [
|
||||
'did:plc:z72i7hdynmk6r22z27h6tvur',
|
||||
BSKY_APP_ACCOUNT_DID,
|
||||
'did:plc:vpkhqolt662uhesyj6nxm7ys',
|
||||
'did:plc:q6gjnaw2blty4crticxkmujt',
|
||||
]
|
||||
|
||||
+14
-34
@@ -1,40 +1,20 @@
|
||||
import React from 'react'
|
||||
import {impactAsync, ImpactFeedbackStyle} from 'expo-haptics'
|
||||
|
||||
import {isIOS, isWeb} from 'platform/detection'
|
||||
import ReactNativeHapticFeedback, {
|
||||
HapticFeedbackTypes,
|
||||
} from 'react-native-haptic-feedback'
|
||||
import {useHapticsDisabled} from 'state/preferences/disable-haptics'
|
||||
|
||||
const hapticImpact: HapticFeedbackTypes = isIOS ? 'impactMedium' : 'impactLight' // Users said the medium impact was too strong on Android; see APP-537s
|
||||
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
|
||||
}
|
||||
ReactNativeHapticFeedback.trigger(hapticImpact)
|
||||
}
|
||||
static impact(type: HapticFeedbackTypes = hapticImpact) {
|
||||
if (isWeb) {
|
||||
return
|
||||
}
|
||||
ReactNativeHapticFeedback.trigger(type)
|
||||
}
|
||||
static selection() {
|
||||
if (isWeb) {
|
||||
return
|
||||
}
|
||||
ReactNativeHapticFeedback.trigger('selection')
|
||||
}
|
||||
static notification = (type: 'success' | 'warning' | 'error') => {
|
||||
if (isWeb) {
|
||||
return
|
||||
}
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return ReactNativeHapticFeedback.trigger('notificationSuccess')
|
||||
case 'warning':
|
||||
return ReactNativeHapticFeedback.trigger('notificationWarning')
|
||||
case 'error':
|
||||
return ReactNativeHapticFeedback.trigger('notificationError')
|
||||
}
|
||||
}
|
||||
impactAsync(hapticImpact)
|
||||
}, [isHapticsDisabled])
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {useSessionApi, SessionAccount} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {useCloseAllActiveElements} from '#/state/util'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {LogEvents} from '../statsig/statsig'
|
||||
|
||||
export function useAccountSwitcher() {
|
||||
const {track} = useAnalytics()
|
||||
@@ -14,7 +15,10 @@ export function useAccountSwitcher() {
|
||||
const {requestSwitchToAccount} = useLoggedOutViewControls()
|
||||
|
||||
const onPressSwitchAccount = useCallback(
|
||||
async (account: SessionAccount) => {
|
||||
async (
|
||||
account: SessionAccount,
|
||||
logContext: LogEvents['account:loggedIn']['logContext'],
|
||||
) => {
|
||||
track('Settings:SwitchAccountButtonClicked')
|
||||
|
||||
try {
|
||||
@@ -28,7 +32,7 @@ export function useAccountSwitcher() {
|
||||
// So we change the URL ourselves. The navigator will pick it up on remount.
|
||||
history.pushState(null, '', '/')
|
||||
}
|
||||
await selectAccount(account)
|
||||
await selectAccount(account, logContext)
|
||||
setTimeout(() => {
|
||||
Toast.show(`Signed in as @${account.handle}`)
|
||||
}, 100)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import React from 'react'
|
||||
import {Alert, AppState, AppStateStatus} from 'react-native'
|
||||
import {nativeBuildVersion} from 'expo-application'
|
||||
import {
|
||||
checkForUpdateAsync,
|
||||
fetchUpdateAsync,
|
||||
isEnabled,
|
||||
reloadAsync,
|
||||
setExtraParamAsync,
|
||||
useUpdates,
|
||||
} from 'expo-updates'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {IS_TESTFLIGHT} from 'lib/app-info'
|
||||
import {useGate} from 'lib/statsig/statsig'
|
||||
import {isIOS} from 'platform/detection'
|
||||
|
||||
const MINIMUM_MINIMIZE_TIME = 15 * 60e3
|
||||
|
||||
async function setExtraParams() {
|
||||
await setExtraParamAsync(
|
||||
isIOS ? 'ios-build-number' : 'android-build-number',
|
||||
// Hilariously, `buildVersion` is not actually a string on Android even though the TS type says it is.
|
||||
// This just ensures it gets passed as a string
|
||||
`${nativeBuildVersion}`,
|
||||
)
|
||||
await setExtraParamAsync(
|
||||
'channel',
|
||||
IS_TESTFLIGHT ? 'testflight' : 'production',
|
||||
)
|
||||
}
|
||||
|
||||
export function useOTAUpdates() {
|
||||
const shouldReceiveUpdates =
|
||||
useGate('receive_updates') && isEnabled && !__DEV__
|
||||
|
||||
const appState = React.useRef<AppStateStatus>('active')
|
||||
const lastMinimize = React.useRef(0)
|
||||
const ranInitialCheck = React.useRef(false)
|
||||
const timeout = React.useRef<NodeJS.Timeout>()
|
||||
const {isUpdatePending} = useUpdates()
|
||||
|
||||
const setCheckTimeout = React.useCallback(() => {
|
||||
timeout.current = setTimeout(async () => {
|
||||
try {
|
||||
await setExtraParams()
|
||||
|
||||
logger.debug('Checking for update...')
|
||||
const res = await checkForUpdateAsync()
|
||||
|
||||
if (res.isAvailable) {
|
||||
logger.debug('Attempting to fetch update...')
|
||||
await fetchUpdateAsync()
|
||||
} else {
|
||||
logger.debug('No update available.')
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('OTA Update Error', {error: `${e}`})
|
||||
}
|
||||
}, 10e3)
|
||||
}, [])
|
||||
|
||||
const onIsTestFlight = React.useCallback(async () => {
|
||||
try {
|
||||
await setExtraParams()
|
||||
|
||||
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',
|
||||
},
|
||||
{
|
||||
text: 'Relaunch',
|
||||
style: 'default',
|
||||
onPress: async () => {
|
||||
await reloadAsync()
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
} 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 (!shouldReceiveUpdates || ranInitialCheck.current) {
|
||||
return
|
||||
}
|
||||
|
||||
setCheckTimeout()
|
||||
ranInitialCheck.current = true
|
||||
}, [onIsTestFlight, setCheckTimeout, shouldReceiveUpdates])
|
||||
|
||||
// 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
|
||||
|
||||
const subscription = AppState.addEventListener(
|
||||
'change',
|
||||
async nextAppState => {
|
||||
if (
|
||||
appState.current.match(/inactive|background/) &&
|
||||
nextAppState === 'active'
|
||||
) {
|
||||
// If it's been 15 minutes since the last "minimize", we should feel comfortable updating the client since
|
||||
// chances are that there isn't anything important going on in the current session.
|
||||
if (lastMinimize.current <= Date.now() - MINIMUM_MINIMIZE_TIME) {
|
||||
if (isUpdatePending) {
|
||||
await reloadAsync()
|
||||
} else {
|
||||
setCheckTimeout()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lastMinimize.current = Date.now()
|
||||
}
|
||||
|
||||
appState.current = nextAppState
|
||||
},
|
||||
)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout.current)
|
||||
subscription.remove()
|
||||
}
|
||||
}, [isUpdatePending, setCheckTimeout])
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export function useOTAUpdates() {}
|
||||
+8
-8
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, TextStyle, ViewStyle} from 'react-native'
|
||||
import Svg, {Path, Rect, Line, Ellipse} from 'react-native-svg'
|
||||
import Svg, {Ellipse, Line, Path, Rect} from 'react-native-svg'
|
||||
|
||||
export function GridIcon({
|
||||
style,
|
||||
@@ -141,8 +141,8 @@ export function MagnifyingGlassIcon2({
|
||||
width={size || 24}
|
||||
height={size || 24}
|
||||
style={style}>
|
||||
<Ellipse cx="12" cy="11" rx="9" ry="9" />
|
||||
<Line x1="19" y1="17.3" x2="23.5" y2="21" strokeLinecap="round" />
|
||||
<Ellipse cx="12" cy="10.5" rx="9" ry="9" />
|
||||
<Line x1="18.5" y1="17" x2="22" y2="20.5" strokeLinecap="round" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
@@ -167,14 +167,14 @@ export function MagnifyingGlassIcon2Solid({
|
||||
style={style}>
|
||||
<Ellipse
|
||||
cx="12"
|
||||
cy="11"
|
||||
rx="7"
|
||||
ry="7"
|
||||
cy="10.5"
|
||||
rx="6.5"
|
||||
ry="6.5"
|
||||
stroke="none"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<Ellipse cx="12" cy="11" rx="9" ry="9" />
|
||||
<Line x1="19" y1="17.3" x2="23.5" y2="21" strokeLinecap="round" />
|
||||
<Ellipse cx="12" cy="10.5" rx="9" ry="9" />
|
||||
<Line x1="18.5" y1="17" x2="22" y2="20.5" strokeLinecap="round" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import RNFS from 'react-native-fs'
|
||||
import {CropperOptions} from './types'
|
||||
import {compressIfNeeded} from './manip'
|
||||
|
||||
let _imageCounter = 0
|
||||
async function getFile() {
|
||||
let files = await RNFS.readDir(
|
||||
RNFS.LibraryDirectoryPath.split('/')
|
||||
@@ -12,7 +11,7 @@ async function getFile() {
|
||||
.join('/'),
|
||||
)
|
||||
files = files.filter(file => file.path.endsWith('.JPG'))
|
||||
const file = files[_imageCounter++ % files.length]
|
||||
const file = files[0]
|
||||
return await compressIfNeeded({
|
||||
path: file.path,
|
||||
mime: 'image/jpeg',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {useMemo} from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMemo} from 'react'
|
||||
|
||||
export type GlobalLabelStrings = Record<
|
||||
string,
|
||||
@@ -31,7 +31,7 @@ export function useGlobalLabelStrings(): GlobalLabelStrings {
|
||||
),
|
||||
},
|
||||
porn: {
|
||||
name: _(msg`Pornography`),
|
||||
name: _(msg`Adult Content`),
|
||||
description: _(msg`Explicit sexual images.`),
|
||||
},
|
||||
sexual: {
|
||||
|
||||
@@ -118,11 +118,15 @@ export function useModerationCauseDescription(
|
||||
(labeler?.creator.handle ? '@' + labeler?.creator.handle : undefined)
|
||||
if (!source) {
|
||||
if (cause.label.src === BSKY_LABELER_DID) {
|
||||
source = 'Bluesky Moderation'
|
||||
source = 'Bluesky Moderation Service'
|
||||
} else {
|
||||
source = cause.label.src
|
||||
}
|
||||
}
|
||||
if (def.identifier === 'porn' || def.identifier === 'sexual') {
|
||||
strings.name = 'Adult Content'
|
||||
}
|
||||
|
||||
return {
|
||||
icon:
|
||||
def.identifier === '!no-unauthenticated'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMemo} from 'react'
|
||||
import {ComAtprotoModerationDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
export interface ReportOption {
|
||||
reason: string
|
||||
@@ -68,7 +68,7 @@ export function useReportOptions(): ReportOptions {
|
||||
{
|
||||
reason: ComAtprotoModerationDefs.REASONSEXUAL,
|
||||
title: _(msg`Unwanted Sexual Content`),
|
||||
description: _(msg`Nudity or pornography not labeled as such`),
|
||||
description: _(msg`Nudity or adult content not labeled as such`),
|
||||
},
|
||||
...common,
|
||||
],
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import {useEffect} from 'react'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import {QueryClient} from '@tanstack/react-query'
|
||||
import {resetToTab} from '../../Navigation'
|
||||
import {devicePlatform, isIOS} from 'platform/detection'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
|
||||
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 {SessionAccount, getAgent} from '#/state/session'
|
||||
import {getAgent, SessionAccount} from '#/state/session'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
import {devicePlatform, isIOS} from 'platform/detection'
|
||||
import {resetToTab} from '../../Navigation'
|
||||
import {logEvent} from '../statsig/statsig'
|
||||
|
||||
const SERVICE_DID = (serviceUrl?: string) =>
|
||||
@@ -80,53 +83,66 @@ export function registerTokenChangeHandler(
|
||||
}
|
||||
}
|
||||
|
||||
export function init(queryClient: QueryClient) {
|
||||
// handle notifications that are received, both in the foreground or background
|
||||
// NOTE: currently just here for debug logging
|
||||
Notifications.addNotificationReceivedListener(event => {
|
||||
logger.debug(
|
||||
'Notifications: received',
|
||||
{event},
|
||||
logger.DebugContext.notifications,
|
||||
)
|
||||
if (event.request.trigger.type === 'push') {
|
||||
// handle payload-based deeplinks
|
||||
let payload
|
||||
if (isIOS) {
|
||||
payload = event.request.trigger.payload
|
||||
} else {
|
||||
// TODO: handle android payload deeplink
|
||||
}
|
||||
if (payload) {
|
||||
logger.debug(
|
||||
'Notifications: received payload',
|
||||
payload,
|
||||
logger.DebugContext.notifications,
|
||||
)
|
||||
// TODO: deeplink notif here
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// handle notifications that are tapped on
|
||||
Notifications.addNotificationResponseReceivedListener(response => {
|
||||
logger.debug(
|
||||
'Notifications: response received',
|
||||
{
|
||||
actionIdentifier: response.actionIdentifier,
|
||||
},
|
||||
logger.DebugContext.notifications,
|
||||
)
|
||||
if (response.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER) {
|
||||
export function useNotificationsListener(queryClient: QueryClient) {
|
||||
useEffect(() => {
|
||||
// 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(
|
||||
'User pressed a notification, opening notifications tab',
|
||||
{},
|
||||
'Notifications: received',
|
||||
{event},
|
||||
logger.DebugContext.notifications,
|
||||
)
|
||||
track('Notificatons:OpenApp')
|
||||
logEvent('notifications:openApp', {})
|
||||
truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
|
||||
resetToTab('NotificationsTab') // open notifications tab
|
||||
if (event.request.trigger.type === 'push') {
|
||||
// handle payload-based deeplinks
|
||||
let payload
|
||||
if (isIOS) {
|
||||
payload = event.request.trigger.payload
|
||||
} else {
|
||||
// TODO: handle android payload deeplink
|
||||
}
|
||||
if (payload) {
|
||||
logger.debug(
|
||||
'Notifications: received payload',
|
||||
payload,
|
||||
logger.DebugContext.notifications,
|
||||
)
|
||||
// TODO: deeplink notif here
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// handle notifications that are tapped on
|
||||
const sub2 = Notifications.addNotificationResponseReceivedListener(
|
||||
response => {
|
||||
logger.debug(
|
||||
'Notifications: response received',
|
||||
{
|
||||
actionIdentifier: response.actionIdentifier,
|
||||
},
|
||||
logger.DebugContext.notifications,
|
||||
)
|
||||
if (
|
||||
response.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER
|
||||
) {
|
||||
logger.debug(
|
||||
'User pressed a notification, opening notifications tab',
|
||||
{},
|
||||
logger.DebugContext.notifications,
|
||||
)
|
||||
track('Notificatons:OpenApp')
|
||||
logEvent('notifications:openApp', {})
|
||||
invalidateCachedUnreadPage()
|
||||
truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
|
||||
resetToTab('NotificationsTab') // open notifications tab
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return () => {
|
||||
sub1.remove()
|
||||
sub2.remove()
|
||||
}
|
||||
})
|
||||
}, [queryClient])
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import {AppState, AppStateStatus} from 'react-native'
|
||||
import {QueryClient, focusManager} from '@tanstack/react-query'
|
||||
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import {PersistQueryClientProviderProps} from '@tanstack/react-query-persist-client'
|
||||
|
||||
import {isNative} from '#/platform/detection'
|
||||
|
||||
// any query keys in this array will be persisted to AsyncStorage
|
||||
const STORED_CACHE_QUERY_KEYS = ['labelers-detailed-info']
|
||||
|
||||
focusManager.setEventListener(onFocus => {
|
||||
if (isNative) {
|
||||
const subscription = AppState.addEventListener(
|
||||
'change',
|
||||
(status: AppStateStatus) => {
|
||||
focusManager.setFocused(status === 'active')
|
||||
},
|
||||
)
|
||||
|
||||
return () => subscription.remove()
|
||||
} else if (typeof window !== 'undefined' && window.addEventListener) {
|
||||
// these handlers are a bit redundant but focus catches when the browser window
|
||||
// is blurred/focused while visibilitychange seems to only handle when the
|
||||
// window minimizes (both of them catch tab changes)
|
||||
// there's no harm to redundant fires because refetchOnWindowFocus is only
|
||||
// used with queries that employ stale data times
|
||||
const handler = () => onFocus()
|
||||
window.addEventListener('focus', handler, false)
|
||||
window.addEventListener('visibilitychange', handler, false)
|
||||
return () => {
|
||||
window.removeEventListener('visibilitychange', handler)
|
||||
window.removeEventListener('focus', handler)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// NOTE
|
||||
// refetchOnWindowFocus breaks some UIs (like feeds)
|
||||
// so we only selectively want to enable this
|
||||
// -prf
|
||||
refetchOnWindowFocus: false,
|
||||
// Structural sharing between responses makes it impossible to rely on
|
||||
// "first seen" timestamps on objects to determine if they're fresh.
|
||||
// Disable this optimization so that we can rely on "first seen" timestamps.
|
||||
structuralSharing: false,
|
||||
// We don't want to retry queries by default, because in most cases we
|
||||
// want to fail early and show a response to the user. There are
|
||||
// exceptions, and those can be made on a per-query basis. For others, we
|
||||
// should give users controls to retry.
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const asyncStoragePersister = createAsyncStoragePersister({
|
||||
storage: AsyncStorage,
|
||||
key: 'queryCache',
|
||||
})
|
||||
|
||||
export const dehydrateOptions: PersistQueryClientProviderProps['persistOptions']['dehydrateOptions'] =
|
||||
{
|
||||
shouldDehydrateMutation: (_: any) => false,
|
||||
shouldDehydrateQuery: query => {
|
||||
return STORED_CACHE_QUERY_KEYS.includes(String(query.queryKey[0]))
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, {useRef, useState} from 'react'
|
||||
import {AppState, AppStateStatus} from 'react-native'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
|
||||
import {focusManager, QueryClient} from '@tanstack/react-query'
|
||||
import {
|
||||
PersistQueryClientProvider,
|
||||
PersistQueryClientProviderProps,
|
||||
} from '@tanstack/react-query-persist-client'
|
||||
|
||||
import {isNative} from '#/platform/detection'
|
||||
|
||||
// any query keys in this array will be persisted to AsyncStorage
|
||||
export const labelersDetailedInfoQueryKeyRoot = 'labelers-detailed-info'
|
||||
const STORED_CACHE_QUERY_KEY_ROOTS = [labelersDetailedInfoQueryKeyRoot]
|
||||
|
||||
focusManager.setEventListener(onFocus => {
|
||||
if (isNative) {
|
||||
const subscription = AppState.addEventListener(
|
||||
'change',
|
||||
(status: AppStateStatus) => {
|
||||
focusManager.setFocused(status === 'active')
|
||||
},
|
||||
)
|
||||
|
||||
return () => subscription.remove()
|
||||
} else if (typeof window !== 'undefined' && window.addEventListener) {
|
||||
// these handlers are a bit redundant but focus catches when the browser window
|
||||
// is blurred/focused while visibilitychange seems to only handle when the
|
||||
// window minimizes (both of them catch tab changes)
|
||||
// there's no harm to redundant fires because refetchOnWindowFocus is only
|
||||
// used with queries that employ stale data times
|
||||
const handler = () => onFocus()
|
||||
window.addEventListener('focus', handler, false)
|
||||
window.addEventListener('visibilitychange', handler, false)
|
||||
return () => {
|
||||
window.removeEventListener('visibilitychange', handler)
|
||||
window.removeEventListener('focus', handler)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// NOTE
|
||||
// refetchOnWindowFocus breaks some UIs (like feeds)
|
||||
// so we only selectively want to enable this
|
||||
// -prf
|
||||
refetchOnWindowFocus: false,
|
||||
// Structural sharing between responses makes it impossible to rely on
|
||||
// "first seen" timestamps on objects to determine if they're fresh.
|
||||
// Disable this optimization so that we can rely on "first seen" timestamps.
|
||||
structuralSharing: false,
|
||||
// We don't want to retry queries by default, because in most cases we
|
||||
// want to fail early and show a response to the user. There are
|
||||
// exceptions, and those can be made on a per-query basis. For others, we
|
||||
// should give users controls to retry.
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const dehydrateOptions: PersistQueryClientProviderProps['persistOptions']['dehydrateOptions'] =
|
||||
{
|
||||
shouldDehydrateMutation: (_: any) => false,
|
||||
shouldDehydrateQuery: query => {
|
||||
return STORED_CACHE_QUERY_KEY_ROOTS.includes(String(query.queryKey[0]))
|
||||
},
|
||||
}
|
||||
|
||||
export function QueryProvider({
|
||||
children,
|
||||
currentDid,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
currentDid: string | undefined
|
||||
}) {
|
||||
return (
|
||||
<QueryProviderInner
|
||||
// Enforce we never reuse cache between users.
|
||||
// These two props MUST stay in sync.
|
||||
key={currentDid}
|
||||
currentDid={currentDid}>
|
||||
{children}
|
||||
</QueryProviderInner>
|
||||
)
|
||||
}
|
||||
|
||||
function QueryProviderInner({
|
||||
children,
|
||||
currentDid,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
currentDid: string | undefined
|
||||
}) {
|
||||
const initialDid = useRef(currentDid)
|
||||
if (currentDid !== initialDid.current) {
|
||||
throw Error(
|
||||
'Something is very wrong. Expected did to be stable due to key above.',
|
||||
)
|
||||
}
|
||||
// We create the query client here so that it's scoped to a specific DID.
|
||||
// Do not move the query client creation outside of this component.
|
||||
const [queryClient, _setQueryClient] = useState(() => createQueryClient())
|
||||
const [persistOptions, _setPersistOptions] = useState(() => {
|
||||
const asyncPersister = createAsyncStoragePersister({
|
||||
storage: AsyncStorage,
|
||||
key: 'queryClient-' + (currentDid ?? 'logged-out'),
|
||||
})
|
||||
return {
|
||||
persister: asyncPersister,
|
||||
dehydrateOptions,
|
||||
}
|
||||
})
|
||||
return (
|
||||
<PersistQueryClientProvider
|
||||
client={queryClient}
|
||||
persistOptions={persistOptions}>
|
||||
{children}
|
||||
</PersistQueryClientProvider>
|
||||
)
|
||||
}
|
||||
+3
-3
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import {Platform} from 'react-native'
|
||||
import app from 'react-native-version-number'
|
||||
import {nativeApplicationVersion, nativeBuildVersion} from 'expo-application'
|
||||
import * as info from 'expo-updates'
|
||||
import {init} from 'sentry-expo'
|
||||
|
||||
@@ -21,7 +21,7 @@ const buildChannel = (info.channel || 'development') as
|
||||
* - `dev`
|
||||
* - `1.57.0`
|
||||
*/
|
||||
const release = app.appVersion ?? 'dev'
|
||||
const release = nativeApplicationVersion ?? 'dev'
|
||||
|
||||
/**
|
||||
* Examples:
|
||||
@@ -33,7 +33,7 @@ const release = app.appVersion ?? 'dev'
|
||||
* - `android.1.57.0.46`
|
||||
*/
|
||||
const dist = `${Platform.OS}.${release}${
|
||||
app.buildVersion ? `.${app.buildVersion}` : ''
|
||||
nativeBuildVersion ? `.${nativeBuildVersion}` : ''
|
||||
}`
|
||||
|
||||
init({
|
||||
|
||||
+6
-5
@@ -1,8 +1,9 @@
|
||||
import {isIOS, isAndroid} from 'platform/detection'
|
||||
// import * as Sharing from 'expo-sharing'
|
||||
import Clipboard from '@react-native-clipboard/clipboard'
|
||||
import * as Toast from '../view/com/util/Toast'
|
||||
import {Share} from 'react-native'
|
||||
// import * as Sharing from 'expo-sharing'
|
||||
import {setStringAsync} from 'expo-clipboard'
|
||||
|
||||
import {isAndroid, isIOS} from 'platform/detection'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
|
||||
/**
|
||||
* This function shares a URL using the native Share API if available, or copies it to the clipboard
|
||||
@@ -18,7 +19,7 @@ export async function shareUrl(url: string) {
|
||||
} else {
|
||||
// React Native Share is not supported by web. Web Share API
|
||||
// has increasing but not full support, so default to clipboard
|
||||
Clipboard.setString(url)
|
||||
setStringAsync(url)
|
||||
Toast.show('Copied to clipboard')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,69 @@
|
||||
export type LogEvents = {
|
||||
// App events
|
||||
init: {
|
||||
initMs: number
|
||||
}
|
||||
'account:loggedIn': {
|
||||
logContext: 'LoginForm' | 'SwitchAccount' | 'ChooseAccountForm' | 'Settings'
|
||||
withPassword: boolean
|
||||
}
|
||||
'account:loggedOut': {
|
||||
logContext: 'SwitchAccount' | 'Settings' | 'Deactivated'
|
||||
}
|
||||
'notifications:openApp': {}
|
||||
'state:background': {}
|
||||
'state:background': {
|
||||
secondsActive: number
|
||||
}
|
||||
'state:foreground': {}
|
||||
'router:navigate': {}
|
||||
|
||||
// Screen events
|
||||
'splash:signInPressed': {}
|
||||
'splash:createAccountPressed': {}
|
||||
'signup:nextPressed': {
|
||||
activeStep: number
|
||||
}
|
||||
'onboarding:interests:nextPressed': {
|
||||
selectedInterests: string[]
|
||||
selectedInterestsLength: number
|
||||
}
|
||||
'onboarding:suggestedAccounts:nextPressed': {
|
||||
selectedAccountsLength: number
|
||||
skipped: boolean
|
||||
}
|
||||
'onboarding:followingFeed:nextPressed': {}
|
||||
'onboarding:algoFeeds:nextPressed': {
|
||||
selectedPrimaryFeeds: string[]
|
||||
selectedPrimaryFeedsLength: number
|
||||
selectedSecondaryFeeds: string[]
|
||||
selectedSecondaryFeedsLength: number
|
||||
}
|
||||
'onboarding:topicalFeeds:nextPressed': {
|
||||
selectedFeeds: string[]
|
||||
selectedFeedsLength: number
|
||||
}
|
||||
'onboarding:moderation:nextPressed': {}
|
||||
'onboarding:finished:nextPressed': {}
|
||||
'home:feedDisplayed': {
|
||||
feedUrl: string
|
||||
feedType: string
|
||||
index: number
|
||||
reason: 'focus' | 'tabbar-click' | 'pager-swipe' | 'desktop-sidebar-click'
|
||||
}
|
||||
'feed:endReached': {
|
||||
feedUrl: string
|
||||
feedType: string
|
||||
itemCount: number
|
||||
}
|
||||
'feed:refresh': {
|
||||
feedUrl: string
|
||||
feedType: string
|
||||
reason: 'pull-to-refresh' | 'soft-reset' | 'load-latest'
|
||||
}
|
||||
|
||||
// Data events
|
||||
'account:create:begin': {}
|
||||
'account:create:success': {}
|
||||
'post:create': {
|
||||
imageCount: number
|
||||
isReply: boolean
|
||||
@@ -18,6 +73,10 @@ export type LogEvents = {
|
||||
logContext: 'Composer'
|
||||
}
|
||||
'post:like': {
|
||||
doesLikerFollowPoster: boolean | undefined
|
||||
doesPosterFollowLiker: boolean | undefined
|
||||
likerClout: number | undefined
|
||||
postClout: number | undefined
|
||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
||||
}
|
||||
'post:repost': {
|
||||
@@ -30,6 +89,9 @@ export type LogEvents = {
|
||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
||||
}
|
||||
'profile:follow': {
|
||||
didBecomeMutual: boolean | undefined
|
||||
followeeClout: number | undefined
|
||||
followerClout: number | undefined
|
||||
logContext:
|
||||
| 'RecommendedFollowsItem'
|
||||
| 'PostThreadItem'
|
||||
@@ -37,6 +99,7 @@ export type LogEvents = {
|
||||
| 'ProfileHeader'
|
||||
| 'ProfileHeaderSuggestedFollows'
|
||||
| 'ProfileMenu'
|
||||
| 'ProfileHoverCard'
|
||||
}
|
||||
'profile:unfollow': {
|
||||
logContext:
|
||||
@@ -46,5 +109,6 @@ export type LogEvents = {
|
||||
| 'ProfileHeader'
|
||||
| 'ProfileHeaderSuggestedFollows'
|
||||
| 'ProfileMenu'
|
||||
| 'ProfileHoverCard'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export type Gate =
|
||||
// Keep this alphabetic please.
|
||||
| 'autoexpand_suggestions_on_profile_follow'
|
||||
| 'disable_min_shell_on_foregrounding'
|
||||
| 'disable_poll_on_discover'
|
||||
| 'hide_vertical_scroll_indicators'
|
||||
| 'new_profile_scroll_component'
|
||||
| 'new_search'
|
||||
| 'receive_updates'
|
||||
| 'show_follow_back_label'
|
||||
| 'start_session_with_following'
|
||||
| 'use_new_suggestions_endpoint'
|
||||
+69
-12
@@ -1,20 +1,38 @@
|
||||
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 {AppState, AppStateStatus} from 'react-native'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {IS_TESTFLIGHT} from 'lib/app-info'
|
||||
import {useSession} from '../../state/session'
|
||||
import {sha256} from 'js-sha256'
|
||||
import {LogEvents} from './events'
|
||||
import {Gate} from './gates'
|
||||
|
||||
let refSrc: string | undefined
|
||||
let refUrl: string | undefined
|
||||
if (isWeb && typeof window !== 'undefined') {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
refSrc = params.get('ref_src') ?? undefined
|
||||
refUrl = params.get('ref_url') ?? undefined
|
||||
}
|
||||
|
||||
export type {LogEvents}
|
||||
|
||||
const statsigOptions = {
|
||||
environment: {
|
||||
tier: process.env.NODE_ENV === 'development' ? 'development' : 'production',
|
||||
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.
|
||||
@@ -24,7 +42,13 @@ const statsigOptions = {
|
||||
|
||||
type FlatJSONRecord = Record<
|
||||
string,
|
||||
string | number | boolean | null | undefined
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| undefined
|
||||
// Technically not scalar but Statsig will stringify it which works for us:
|
||||
| string[]
|
||||
>
|
||||
|
||||
let getCurrentRouteName: () => string | null | undefined = () => null
|
||||
@@ -35,24 +59,42 @@ export function attachRouteToLogEvents(
|
||||
getCurrentRouteName = getRouteName
|
||||
}
|
||||
|
||||
export function toClout(n: number | null | undefined): number | undefined {
|
||||
if (n == null) {
|
||||
return undefined
|
||||
} else {
|
||||
return Math.max(0, Math.round(Math.log(n)))
|
||||
}
|
||||
}
|
||||
|
||||
export function logEvent<E extends keyof LogEvents>(
|
||||
eventName: E & string,
|
||||
rawMetadata: LogEvents[E] & FlatJSONRecord,
|
||||
) {
|
||||
const fullMetadata = {
|
||||
...rawMetadata,
|
||||
} as Record<string, string> // Statsig typings are unnecessarily strict here.
|
||||
fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)'
|
||||
Statsig.logEvent(eventName, null, fullMetadata)
|
||||
try {
|
||||
const fullMetadata = {
|
||||
...rawMetadata,
|
||||
} as Record<string, string> // Statsig typings are unnecessarily strict here.
|
||||
fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)'
|
||||
if (Statsig.initializeCalled()) {
|
||||
Statsig.logEvent(eventName, null, fullMetadata)
|
||||
}
|
||||
} catch (e) {
|
||||
// A log should never interrupt the calling code, whatever happens.
|
||||
logger.error('Failed to log an event', {message: e})
|
||||
}
|
||||
}
|
||||
|
||||
export function useGate(gateName: string) {
|
||||
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.')
|
||||
}
|
||||
return value
|
||||
// This shouldn't technically be necessary but let's get a strong
|
||||
// guarantee that a gate value can never change while mounted.
|
||||
const [initialValue] = React.useState(value)
|
||||
return initialValue
|
||||
}
|
||||
|
||||
function toStatsigUser(did: string | undefined) {
|
||||
@@ -63,19 +105,34 @@ function toStatsigUser(did: string | undefined) {
|
||||
return {
|
||||
userID,
|
||||
platform: Platform.OS,
|
||||
custom: {
|
||||
refSrc,
|
||||
refUrl,
|
||||
// Need to specify here too for gating.
|
||||
platform: Platform.OS,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let lastState: AppStateStatus = AppState.currentState
|
||||
let lastActive = lastState === 'active' ? performance.now() : null
|
||||
AppState.addEventListener('change', (state: AppStateStatus) => {
|
||||
if (state === lastState) {
|
||||
return
|
||||
}
|
||||
lastState = state
|
||||
if (state === 'active') {
|
||||
lastActive = performance.now()
|
||||
logEvent('state:foreground', {})
|
||||
} else {
|
||||
logEvent('state:background', {})
|
||||
let secondsActive = 0
|
||||
if (lastActive != null) {
|
||||
secondsActive = Math.round((performance.now() - lastActive) / 1e3)
|
||||
}
|
||||
lastActive = null
|
||||
logEvent('state:background', {
|
||||
secondsActive,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export function sanitizeHandle(handle: string, prefix = ''): string {
|
||||
|
||||
export interface IsValidHandle {
|
||||
handleChars: boolean
|
||||
hyphenStartOrEnd: boolean
|
||||
frontLength: boolean
|
||||
totalLength: boolean
|
||||
overall: boolean
|
||||
@@ -39,6 +40,7 @@ export function validateHandle(str: string, userDomain: string): IsValidHandle {
|
||||
const results = {
|
||||
handleChars:
|
||||
!str || (VALIDATE_REGEX.test(fullHandle) && !str.includes('.')),
|
||||
hyphenStartOrEnd: !str.startsWith('-') && !str.endsWith('-'),
|
||||
frontLength: str.length >= 3,
|
||||
totalLength: fullHandle.length <= 253,
|
||||
}
|
||||
|
||||
+13
-6
@@ -1,7 +1,8 @@
|
||||
import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api'
|
||||
import lande from 'lande'
|
||||
import {hasProp} from 'lib/type-guards'
|
||||
import * as bcp47Match from 'bcp-47-match'
|
||||
import lande from 'lande'
|
||||
|
||||
import {hasProp} from 'lib/type-guards'
|
||||
import {
|
||||
AppLanguage,
|
||||
LANGUAGES_MAP_CODE2,
|
||||
@@ -118,6 +119,8 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
|
||||
switch (lang) {
|
||||
case 'en':
|
||||
return AppLanguage.en
|
||||
case 'ca':
|
||||
return AppLanguage.ca
|
||||
case 'de':
|
||||
return AppLanguage.de
|
||||
case 'es':
|
||||
@@ -126,24 +129,28 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
|
||||
return AppLanguage.fi
|
||||
case 'fr':
|
||||
return AppLanguage.fr
|
||||
case 'ga':
|
||||
return AppLanguage.ga
|
||||
case 'hi':
|
||||
return AppLanguage.hi
|
||||
case 'id':
|
||||
return AppLanguage.id
|
||||
case 'it':
|
||||
return AppLanguage.it
|
||||
case 'ja':
|
||||
return AppLanguage.ja
|
||||
case 'ko':
|
||||
return AppLanguage.ko
|
||||
case 'pt-BR':
|
||||
return AppLanguage.pt_BR
|
||||
case 'tr':
|
||||
return AppLanguage.tr
|
||||
case 'uk':
|
||||
return AppLanguage.uk
|
||||
case 'ca':
|
||||
return AppLanguage.ca
|
||||
case 'zh-CN':
|
||||
return AppLanguage.zh_CN
|
||||
case 'it':
|
||||
return AppLanguage.it
|
||||
case 'zh-TW':
|
||||
return AppLanguage.zh_TW
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
+28
-14
@@ -1,30 +1,36 @@
|
||||
import {useEffect} from 'react'
|
||||
import {i18n} from '@lingui/core'
|
||||
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
import {messages as messagesEn} from '#/locale/locales/en/messages'
|
||||
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
|
||||
import {AppLanguage} from '#/locale/languages'
|
||||
import {messages as messagesCa} from '#/locale/locales/ca/messages'
|
||||
import {messages as messagesDe} from '#/locale/locales/de/messages'
|
||||
import {messages as messagesId} from '#/locale/locales/id/messages'
|
||||
import {messages as messagesEn} from '#/locale/locales/en/messages'
|
||||
import {messages as messagesEs} from '#/locale/locales/es/messages'
|
||||
import {messages as messagesFi} from '#/locale/locales/fi/messages'
|
||||
import {messages as messagesFr} from '#/locale/locales/fr/messages'
|
||||
import {messages as messagesGa} from '#/locale/locales/ga/messages'
|
||||
import {messages as messagesHi} from '#/locale/locales/hi/messages'
|
||||
import {messages as messagesId} from '#/locale/locales/id/messages'
|
||||
import {messages as messagesIt} from '#/locale/locales/it/messages'
|
||||
import {messages as messagesJa} from '#/locale/locales/ja/messages'
|
||||
import {messages as messagesKo} from '#/locale/locales/ko/messages'
|
||||
import {messages as messagesPt_BR} from '#/locale/locales/pt-BR/messages'
|
||||
import {messages as messagesTr} from '#/locale/locales/tr/messages'
|
||||
import {messages as messagesUk} from '#/locale/locales/uk/messages'
|
||||
import {messages as messagesCa} from '#/locale/locales/ca/messages'
|
||||
import {messages as messagesZh_CN} from '#/locale/locales/zh-CN/messages'
|
||||
import {messages as messagesIt} from '#/locale/locales/it/messages'
|
||||
|
||||
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
|
||||
import {AppLanguage} from '#/locale/languages'
|
||||
import {messages as messagesZh_TW} from '#/locale/locales/zh-TW/messages'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
|
||||
/**
|
||||
* We do a dynamic import of just the catalog that we need
|
||||
*/
|
||||
export async function dynamicActivate(locale: AppLanguage) {
|
||||
switch (locale) {
|
||||
case AppLanguage.ca: {
|
||||
i18n.loadAndActivate({locale, messages: messagesCa})
|
||||
break
|
||||
}
|
||||
case AppLanguage.de: {
|
||||
i18n.loadAndActivate({locale, messages: messagesDe})
|
||||
break
|
||||
@@ -41,6 +47,10 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
i18n.loadAndActivate({locale, messages: messagesFr})
|
||||
break
|
||||
}
|
||||
case AppLanguage.ga: {
|
||||
i18n.loadAndActivate({locale, messages: messagesGa})
|
||||
break
|
||||
}
|
||||
case AppLanguage.hi: {
|
||||
i18n.loadAndActivate({locale, messages: messagesHi})
|
||||
break
|
||||
@@ -49,6 +59,10 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
i18n.loadAndActivate({locale, messages: messagesId})
|
||||
break
|
||||
}
|
||||
case AppLanguage.it: {
|
||||
i18n.loadAndActivate({locale, messages: messagesIt})
|
||||
break
|
||||
}
|
||||
case AppLanguage.ja: {
|
||||
i18n.loadAndActivate({locale, messages: messagesJa})
|
||||
break
|
||||
@@ -61,20 +75,20 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
i18n.loadAndActivate({locale, messages: messagesPt_BR})
|
||||
break
|
||||
}
|
||||
case AppLanguage.uk: {
|
||||
i18n.loadAndActivate({locale, messages: messagesUk})
|
||||
case AppLanguage.tr: {
|
||||
i18n.loadAndActivate({locale, messages: messagesTr})
|
||||
break
|
||||
}
|
||||
case AppLanguage.ca: {
|
||||
i18n.loadAndActivate({locale, messages: messagesCa})
|
||||
case AppLanguage.uk: {
|
||||
i18n.loadAndActivate({locale, messages: messagesUk})
|
||||
break
|
||||
}
|
||||
case AppLanguage.zh_CN: {
|
||||
i18n.loadAndActivate({locale, messages: messagesZh_CN})
|
||||
break
|
||||
}
|
||||
case AppLanguage.it: {
|
||||
i18n.loadAndActivate({locale, messages: messagesIt})
|
||||
case AppLanguage.zh_TW: {
|
||||
i18n.loadAndActivate({locale, messages: messagesZh_TW})
|
||||
break
|
||||
}
|
||||
default: {
|
||||
|
||||
+19
-7
@@ -1,9 +1,9 @@
|
||||
import {useEffect} from 'react'
|
||||
import {i18n} from '@lingui/core'
|
||||
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
|
||||
import {AppLanguage} from '#/locale/languages'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
|
||||
/**
|
||||
* We do a dynamic import of just the catalog that we need
|
||||
@@ -12,6 +12,10 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
let mod: any
|
||||
|
||||
switch (locale) {
|
||||
case AppLanguage.ca: {
|
||||
mod = await import(`./locales/ca/messages`)
|
||||
break
|
||||
}
|
||||
case AppLanguage.de: {
|
||||
mod = await import(`./locales/de/messages`)
|
||||
break
|
||||
@@ -28,6 +32,10 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
mod = await import(`./locales/fr/messages`)
|
||||
break
|
||||
}
|
||||
case AppLanguage.ga: {
|
||||
mod = await import(`./locales/ga/messages`)
|
||||
break
|
||||
}
|
||||
case AppLanguage.hi: {
|
||||
mod = await import(`./locales/hi/messages`)
|
||||
break
|
||||
@@ -36,6 +44,10 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
mod = await import(`./locales/id/messages`)
|
||||
break
|
||||
}
|
||||
case AppLanguage.it: {
|
||||
mod = await import(`./locales/it/messages`)
|
||||
break
|
||||
}
|
||||
case AppLanguage.ja: {
|
||||
mod = await import(`./locales/ja/messages`)
|
||||
break
|
||||
@@ -48,20 +60,20 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||
mod = await import(`./locales/pt-BR/messages`)
|
||||
break
|
||||
}
|
||||
case AppLanguage.uk: {
|
||||
mod = await import(`./locales/uk/messages`)
|
||||
case AppLanguage.tr: {
|
||||
mod = await import(`./locales/tr/messages`)
|
||||
break
|
||||
}
|
||||
case AppLanguage.ca: {
|
||||
mod = await import(`./locales/ca/messages`)
|
||||
case AppLanguage.uk: {
|
||||
mod = await import(`./locales/uk/messages`)
|
||||
break
|
||||
}
|
||||
case AppLanguage.zh_CN: {
|
||||
mod = await import(`./locales/zh-CN/messages`)
|
||||
break
|
||||
}
|
||||
case AppLanguage.it: {
|
||||
mod = await import(`./locales/it/messages`)
|
||||
case AppLanguage.zh_TW: {
|
||||
mod = await import(`./locales/zh-TW/messages`)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
|
||||
+11
-5
@@ -6,19 +6,22 @@ interface Language {
|
||||
|
||||
export enum AppLanguage {
|
||||
en = 'en',
|
||||
ca = 'ca',
|
||||
de = 'de',
|
||||
es = 'es',
|
||||
fi = 'fi',
|
||||
fr = 'fr',
|
||||
ga = 'ga',
|
||||
hi = 'hi',
|
||||
id = 'id',
|
||||
it = 'it',
|
||||
ja = 'ja',
|
||||
ko = 'ko',
|
||||
pt_BR = 'pt-BR',
|
||||
tr = 'tr',
|
||||
uk = 'uk',
|
||||
ca = 'ca',
|
||||
zh_CN = 'zh-CN',
|
||||
it = 'it',
|
||||
zh_TW = 'zh-TW',
|
||||
}
|
||||
|
||||
interface AppLanguageConfig {
|
||||
@@ -28,19 +31,22 @@ interface AppLanguageConfig {
|
||||
|
||||
export const APP_LANGUAGES: AppLanguageConfig[] = [
|
||||
{code2: AppLanguage.en, name: 'English'},
|
||||
{code2: AppLanguage.ca, name: 'Català – Catalan'},
|
||||
{code2: AppLanguage.de, name: 'Deutsch – German'},
|
||||
{code2: AppLanguage.es, name: 'Español – Spanish'},
|
||||
{code2: AppLanguage.fi, name: 'Suomi – Finnish'},
|
||||
{code2: AppLanguage.fr, name: 'Français – French'},
|
||||
{code2: AppLanguage.ga, name: 'Gaeilge – Irish'},
|
||||
{code2: AppLanguage.hi, name: 'हिंदी – Hindi'},
|
||||
{code2: AppLanguage.id, name: 'Bahasa Indonesia – Indonesian'},
|
||||
{code2: AppLanguage.it, name: 'Italiano – Italian'},
|
||||
{code2: AppLanguage.ja, name: '日本語 – Japanese'},
|
||||
{code2: AppLanguage.ko, name: '한국어 – Korean'},
|
||||
{code2: AppLanguage.pt_BR, name: 'Português (BR) – Portuguese (BR)'},
|
||||
{code2: AppLanguage.tr, name: 'Türkçe – Turkish'},
|
||||
{code2: AppLanguage.uk, name: 'Українська – Ukrainian'},
|
||||
{code2: AppLanguage.ca, name: 'Català – Catalan'},
|
||||
{code2: AppLanguage.zh_CN, name: '简体中文(中国) – Chinese (Simplified)'},
|
||||
{code2: AppLanguage.it, name: 'Italiano - Italian'},
|
||||
{code2: AppLanguage.zh_CN, name: '简体中文(中国)– Chinese (Simplified)'},
|
||||
{code2: AppLanguage.zh_TW, name: '繁體中文(臺灣)– Chinese (Traditional)'},
|
||||
]
|
||||
|
||||
export const LANGUAGES: Language[] = [
|
||||
|
||||
+2666
-1408
File diff suppressed because it is too large
Load Diff
+2526
-1264
File diff suppressed because it is too large
Load Diff
+2189
-1624
File diff suppressed because it is too large
Load Diff
+2491
-1229
File diff suppressed because it is too large
Load Diff
+2712
-1375
File diff suppressed because it is too large
Load Diff
+2507
-1245
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2487
-1225
File diff suppressed because it is too large
Load Diff
+2635
-1376
File diff suppressed because it is too large
Load Diff
+2724
-1635
File diff suppressed because it is too large
Load Diff
+2626
-1368
File diff suppressed because it is too large
Load Diff
+1523
-1204
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user