Merge branch 'main' into hailey/add-license-info
This commit is contained in:
+108
-74
@@ -4,6 +4,7 @@ import 'view/icons'
|
||||
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import {GestureHandlerRootView} from 'react-native-gesture-handler'
|
||||
import {KeyboardProvider} from 'react-native-keyboard-controller'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import {
|
||||
initialWindowMetrics,
|
||||
@@ -13,43 +14,54 @@ import * as SplashScreen from 'expo-splash-screen'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
|
||||
import {QueryProvider} from '#/lib/react-query'
|
||||
import {
|
||||
initialize,
|
||||
Provider as StatsigProvider,
|
||||
tryFetchGates,
|
||||
} from '#/lib/statsig/statsig'
|
||||
import {s} from '#/lib/styles'
|
||||
import {ThemeProvider} from '#/lib/ThemeContext'
|
||||
import I18nProvider from '#/locale/i18nProvider'
|
||||
import {logger} from '#/logger'
|
||||
import {Provider as A11yProvider} from '#/state/a11y'
|
||||
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
|
||||
import {Provider as DialogStateProvider} from '#/state/dialogs'
|
||||
import {listenSessionDropped} from '#/state/events'
|
||||
import {Provider as InvitesStateProvider} from '#/state/invites'
|
||||
import {Provider as LightboxStateProvider} from '#/state/lightbox'
|
||||
import {MessagesProvider} from '#/state/messages'
|
||||
import {Provider as ModalStateProvider} from '#/state/modals'
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
import {Provider as PrefsStateProvider} from '#/state/preferences'
|
||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
|
||||
import {readLastActiveAccount} from '#/state/session/util'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
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 UnreadNotifsProvider} from '#/state/queries/notifications/unread'
|
||||
import {
|
||||
Provider as SessionProvider,
|
||||
SessionAccount,
|
||||
useSession,
|
||||
useSessionApi,
|
||||
} from 'state/session'
|
||||
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'
|
||||
} from '#/state/session'
|
||||
import {readLastActiveAccount} from '#/state/session/util'
|
||||
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
||||
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||
import {TestCtrls} from '#/view/com/testing/TestCtrls'
|
||||
import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
|
||||
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 {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Splash} from '#/Splash'
|
||||
import {Provider as TourProvider} from '#/tours'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
import I18nProvider from './locale/i18nProvider'
|
||||
import {listenSessionDropped} from './state/events'
|
||||
import {AudioCategory, PlatformInfo} from '../modules/expo-bluesky-swiss-army'
|
||||
|
||||
SplashScreen.preventAutoHideAsync()
|
||||
|
||||
@@ -61,6 +73,7 @@ function InnerApp() {
|
||||
const {_} = useLingui()
|
||||
|
||||
useIntentHandler()
|
||||
const hasCheckedReferrer = useStarterPackEntry()
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
@@ -68,6 +81,9 @@ function InnerApp() {
|
||||
try {
|
||||
if (account) {
|
||||
await resumeSession(account)
|
||||
} else {
|
||||
await initialize()
|
||||
await tryFetchGates(undefined, 'prefer-fresh-gates')
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`session: resume failed`, {message: e})
|
||||
@@ -81,7 +97,10 @@ function InnerApp() {
|
||||
|
||||
useEffect(() => {
|
||||
return listenSessionDropped(() => {
|
||||
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
|
||||
Toast.show(
|
||||
_(msg`Sorry! Your session expired. Please log in again.`),
|
||||
'info',
|
||||
)
|
||||
})
|
||||
}, [_])
|
||||
|
||||
@@ -89,36 +108,45 @@ function InnerApp() {
|
||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||
<Alf theme={theme}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<Splash isReady={isReady}>
|
||||
<RootSiblingParent>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<StatsigProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<GestureHandlerRootView style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</StatsigProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
</RootSiblingParent>
|
||||
<Splash isReady={isReady && hasCheckedReferrer}>
|
||||
<ActiveVideoProvider>
|
||||
<RootSiblingParent>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<StatsigProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<TourProvider>
|
||||
<ProgressGuideProvider>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</ProgressGuideProvider>
|
||||
</TourProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</StatsigProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
</RootSiblingParent>
|
||||
</ActiveVideoProvider>
|
||||
</Splash>
|
||||
</ThemeProvider>
|
||||
</Alf>
|
||||
@@ -130,6 +158,8 @@ function App() {
|
||||
const [isReady, setReady] = useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
|
||||
PlatformInfo.setAudioActive(false)
|
||||
initPersistedState().then(() => setReady(true))
|
||||
}, [])
|
||||
|
||||
@@ -142,27 +172,31 @@ function App() {
|
||||
* that is set up in the InnerApp component above.
|
||||
*/
|
||||
return (
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<MutedThreadsProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PortalProvider>
|
||||
<InnerApp />
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</MutedThreadsProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
<A11yProvider>
|
||||
<KeyboardProvider enabled={false} statusBarTranslucent={true}>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PortalProvider>
|
||||
<StarterPackProvider>
|
||||
<InnerApp />
|
||||
</StarterPackProvider>
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
</KeyboardProvider>
|
||||
</A11yProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+87
-64
@@ -2,46 +2,53 @@ import 'lib/sentry' // must be near top
|
||||
import 'view/icons'
|
||||
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import {KeyboardProvider} from 'react-native-keyboard-controller'
|
||||
import {RootSiblingParent} from 'react-native-root-siblings'
|
||||
import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
|
||||
import {QueryProvider} from '#/lib/react-query'
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
import {ThemeProvider} from '#/lib/ThemeContext'
|
||||
import I18nProvider from '#/locale/i18nProvider'
|
||||
import {logger} from '#/logger'
|
||||
import {Provider as A11yProvider} from '#/state/a11y'
|
||||
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
|
||||
import {Provider as DialogStateProvider} from '#/state/dialogs'
|
||||
import {listenSessionDropped} from '#/state/events'
|
||||
import {Provider as InvitesStateProvider} from '#/state/invites'
|
||||
import {Provider as LightboxStateProvider} from '#/state/lightbox'
|
||||
import {MessagesProvider} from '#/state/messages'
|
||||
import {Provider as ModalStateProvider} from '#/state/modals'
|
||||
import {init as initPersistedState} from '#/state/persisted'
|
||||
import {Provider as PrefsStateProvider} from '#/state/preferences'
|
||||
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
|
||||
import {readLastActiveAccount} from '#/state/session/util'
|
||||
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 UnreadNotifsProvider} from '#/state/queries/notifications/unread'
|
||||
import {
|
||||
Provider as SessionProvider,
|
||||
SessionAccount,
|
||||
useSession,
|
||||
useSessionApi,
|
||||
} from 'state/session'
|
||||
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 * as Toast from 'view/com/util/Toast'
|
||||
import {ToastContainer} from 'view/com/util/Toast.web'
|
||||
import {Shell} from 'view/shell/index'
|
||||
} from '#/state/session'
|
||||
import {readLastActiveAccount} from '#/state/session/util'
|
||||
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
||||
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||
import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
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 {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Provider as TourProvider} from '#/tours'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
import I18nProvider from './locale/i18nProvider'
|
||||
import {listenSessionDropped} from './state/events'
|
||||
|
||||
function InnerApp() {
|
||||
const [isReady, setIsReady] = React.useState(false)
|
||||
@@ -50,6 +57,7 @@ function InnerApp() {
|
||||
const theme = useColorModeTheme()
|
||||
const {_} = useLingui()
|
||||
useIntentHandler()
|
||||
const hasCheckedReferrer = useStarterPackEntry()
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
@@ -70,47 +78,60 @@ function InnerApp() {
|
||||
|
||||
useEffect(() => {
|
||||
return listenSessionDropped(() => {
|
||||
Toast.show(_(msg`Sorry! Your session expired. Please log in again.`))
|
||||
Toast.show(
|
||||
_(msg`Sorry! Your session expired. Please log in again.`),
|
||||
'info',
|
||||
)
|
||||
})
|
||||
}, [_])
|
||||
|
||||
// wait for session to resume
|
||||
if (!isReady) return null
|
||||
if (!isReady || !hasCheckedReferrer) return null
|
||||
|
||||
return (
|
||||
<Alf theme={theme}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<RootSiblingParent>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<StatsigProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<SafeAreaProvider>
|
||||
<Shell />
|
||||
</SafeAreaProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</StatsigProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
<ToastContainer />
|
||||
</RootSiblingParent>
|
||||
</ThemeProvider>
|
||||
</Alf>
|
||||
<KeyboardProvider enabled={false}>
|
||||
<Alf theme={theme}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<RootSiblingParent>
|
||||
<ActiveVideoProvider>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<StatsigProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<SafeAreaProvider>
|
||||
<TourProvider>
|
||||
<ProgressGuideProvider>
|
||||
<Shell />
|
||||
</ProgressGuideProvider>
|
||||
</TourProvider>
|
||||
</SafeAreaProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</StatsigProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
<ToastContainer />
|
||||
</ActiveVideoProvider>
|
||||
</RootSiblingParent>
|
||||
</ThemeProvider>
|
||||
</Alf>
|
||||
</KeyboardProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -130,27 +151,29 @@ function App() {
|
||||
* that is set up in the InnerApp component above.
|
||||
*/
|
||||
return (
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<MutedThreadsProvider>
|
||||
<A11yProvider>
|
||||
<SessionProvider>
|
||||
<ShellStateProvider>
|
||||
<PrefsStateProvider>
|
||||
<InvitesStateProvider>
|
||||
<ModalStateProvider>
|
||||
<DialogStateProvider>
|
||||
<LightboxStateProvider>
|
||||
<I18nProvider>
|
||||
<PortalProvider>
|
||||
<InnerApp />
|
||||
<StarterPackProvider>
|
||||
<InnerApp />
|
||||
</StarterPackProvider>
|
||||
</PortalProvider>
|
||||
</I18nProvider>
|
||||
</LightboxStateProvider>
|
||||
</DialogStateProvider>
|
||||
</ModalStateProvider>
|
||||
</InvitesStateProvider>
|
||||
</MutedThreadsProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
</PrefsStateProvider>
|
||||
</ShellStateProvider>
|
||||
</SessionProvider>
|
||||
</A11yProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+104
-43
@@ -22,7 +22,6 @@ import {buildStateObject} from 'lib/routes/helpers'
|
||||
import {
|
||||
AllNavigatorParams,
|
||||
BottomTabNavigatorParams,
|
||||
FeedsTabNavigatorParams,
|
||||
FlatNavigatorParams,
|
||||
HomeTabNavigatorParams,
|
||||
MessagesTabNavigatorParams,
|
||||
@@ -32,7 +31,7 @@ import {
|
||||
} from 'lib/routes/types'
|
||||
import {RouteParams, State} from 'lib/routes/types'
|
||||
import {bskyTitle} from 'lib/strings/headings'
|
||||
import {isAndroid, isNative} from 'platform/detection'
|
||||
import {isAndroid, isNative, isWeb} from 'platform/detection'
|
||||
import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds'
|
||||
import {AppPasswords} from 'view/screens/AppPasswords'
|
||||
import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
|
||||
@@ -40,9 +39,19 @@ import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
|
||||
import {PreferencesFollowingFeed} from 'view/screens/PreferencesFollowingFeed'
|
||||
import {PreferencesThreads} from 'view/screens/PreferencesThreads'
|
||||
import {SavedFeeds} from 'view/screens/SavedFeeds'
|
||||
import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
|
||||
import HashtagScreen from '#/screens/Hashtag'
|
||||
import {ModerationScreen} from '#/screens/Moderation'
|
||||
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
|
||||
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
||||
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
|
||||
import {
|
||||
StarterPackScreen,
|
||||
StarterPackScreenShort,
|
||||
} from '#/screens/StarterPack/StarterPackScreen'
|
||||
import {Wizard} from '#/screens/StarterPack/Wizard'
|
||||
import {VideoDownloadScreen} from '#/components/VideoDownloadScreen'
|
||||
import {Referrer} from '../modules/expo-bluesky-swiss-army'
|
||||
import {init as initAnalytics} from './lib/analytics/analytics'
|
||||
import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
|
||||
import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig'
|
||||
@@ -54,8 +63,8 @@ import {useModalControls} from './state/modals'
|
||||
import {useUnreadNotifications} from './state/queries/notifications/unread'
|
||||
import {useSession} from './state/session'
|
||||
import {
|
||||
setEmailConfirmationRequested,
|
||||
shouldRequestEmailConfirmation,
|
||||
snoozeEmailConfirmationPrompt,
|
||||
} from './state/shell/reminders'
|
||||
import {AccessibilitySettingsScreen} from './view/screens/AccessibilitySettings'
|
||||
import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
|
||||
@@ -69,6 +78,7 @@ 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 {NotificationsSettingsScreen} from './view/screens/NotificationsSettings'
|
||||
import {PostLikedByScreen} from './view/screens/PostLikedBy'
|
||||
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
|
||||
import {PostThreadScreen} from './view/screens/PostThread'
|
||||
@@ -91,7 +101,6 @@ const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
||||
|
||||
const HomeTab = createNativeStackNavigatorWithAuth<HomeTabNavigatorParams>()
|
||||
const SearchTab = createNativeStackNavigatorWithAuth<SearchTabNavigatorParams>()
|
||||
const FeedsTab = createNativeStackNavigatorWithAuth<FeedsTabNavigatorParams>()
|
||||
const NotificationsTab =
|
||||
createNativeStackNavigatorWithAuth<NotificationsTabNavigatorParams>()
|
||||
const MyProfileTab =
|
||||
@@ -171,6 +180,13 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
title: title(msg`People followed by @${route.params.name}`),
|
||||
})}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ProfileKnownFollowers"
|
||||
getComponent={() => ProfileKnownFollowersScreen}
|
||||
options={({route}) => ({
|
||||
title: title(msg`Followers of @${route.params.name} that you know`),
|
||||
})}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ProfileList"
|
||||
getComponent={() => ProfileListScreen}
|
||||
@@ -222,6 +238,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
getComponent={() => DebugModScreen}
|
||||
options={{title: title(msg`Moderation states`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="SharedPreferencesTester"
|
||||
getComponent={() => SharedPreferencesTesterScreen}
|
||||
options={{title: title(msg`Shared Preferences Tester`)}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Log"
|
||||
getComponent={() => LogScreen}
|
||||
@@ -291,6 +312,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="AppearanceSettings"
|
||||
getComponent={() => AppearanceSettingsScreen}
|
||||
options={{
|
||||
title: title(msg`Appearance Settings`),
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Hashtag"
|
||||
getComponent={() => HashtagScreen}
|
||||
@@ -306,6 +335,41 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
getComponent={() => MessagesSettingsScreen}
|
||||
options={{title: title(msg`Chat settings`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="NotificationsSettings"
|
||||
getComponent={() => NotificationsSettingsScreen}
|
||||
options={{title: title(msg`Notification settings`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Feeds"
|
||||
getComponent={() => FeedsScreen}
|
||||
options={{title: title(msg`Feeds`)}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="StarterPack"
|
||||
getComponent={() => StarterPackScreen}
|
||||
options={{title: title(msg`Starter Pack`)}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="StarterPackShort"
|
||||
getComponent={() => StarterPackScreenShort}
|
||||
options={{title: title(msg`Starter Pack`)}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="StarterPackWizard"
|
||||
getComponent={() => Wizard}
|
||||
options={{title: title(msg`Create a starter pack`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="StarterPackEdit"
|
||||
getComponent={() => Wizard}
|
||||
options={{title: title(msg`Edit your starter pack`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="VideoDownload"
|
||||
getComponent={() => VideoDownloadScreen}
|
||||
options={{title: title(msg`Download video`)}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -330,7 +394,6 @@ function TabsNavigator() {
|
||||
tabBar={tabBar}>
|
||||
<Tab.Screen name="HomeTab" getComponent={() => HomeTabNavigator} />
|
||||
<Tab.Screen name="SearchTab" getComponent={() => SearchTabNavigator} />
|
||||
<Tab.Screen name="FeedsTab" getComponent={() => FeedsTabNavigator} />
|
||||
<Tab.Screen
|
||||
name="NotificationsTab"
|
||||
getComponent={() => NotificationsTabNavigator}
|
||||
@@ -353,14 +416,15 @@ function HomeTabNavigator() {
|
||||
return (
|
||||
<HomeTab.Navigator
|
||||
screenOptions={{
|
||||
animation: isAndroid ? 'none' : undefined,
|
||||
animation: isAndroid ? 'ios' : undefined,
|
||||
animationDuration: 285,
|
||||
gestureEnabled: true,
|
||||
fullScreenGestureEnabled: true,
|
||||
headerShown: false,
|
||||
animationDuration: 250,
|
||||
contentStyle: pal.view,
|
||||
}}>
|
||||
<HomeTab.Screen name="Home" getComponent={() => HomeScreen} />
|
||||
<HomeTab.Screen name="Start" getComponent={() => HomeScreen} />
|
||||
{commonScreens(HomeTab)}
|
||||
</HomeTab.Navigator>
|
||||
)
|
||||
@@ -371,11 +435,11 @@ function SearchTabNavigator() {
|
||||
return (
|
||||
<SearchTab.Navigator
|
||||
screenOptions={{
|
||||
animation: isAndroid ? 'none' : undefined,
|
||||
animation: isAndroid ? 'ios' : undefined,
|
||||
animationDuration: 285,
|
||||
gestureEnabled: true,
|
||||
fullScreenGestureEnabled: true,
|
||||
headerShown: false,
|
||||
animationDuration: 250,
|
||||
contentStyle: pal.view,
|
||||
}}>
|
||||
<SearchTab.Screen name="Search" getComponent={() => SearchScreen} />
|
||||
@@ -384,34 +448,16 @@ function SearchTabNavigator() {
|
||||
)
|
||||
}
|
||||
|
||||
function FeedsTabNavigator() {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<FeedsTab.Navigator
|
||||
screenOptions={{
|
||||
animation: isAndroid ? 'none' : undefined,
|
||||
gestureEnabled: true,
|
||||
fullScreenGestureEnabled: true,
|
||||
headerShown: false,
|
||||
animationDuration: 250,
|
||||
contentStyle: pal.view,
|
||||
}}>
|
||||
<FeedsTab.Screen name="Feeds" getComponent={() => FeedsScreen} />
|
||||
{commonScreens(FeedsTab as typeof HomeTab)}
|
||||
</FeedsTab.Navigator>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationsTabNavigator() {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<NotificationsTab.Navigator
|
||||
screenOptions={{
|
||||
animation: isAndroid ? 'none' : undefined,
|
||||
animation: isAndroid ? 'ios' : undefined,
|
||||
animationDuration: 285,
|
||||
gestureEnabled: true,
|
||||
fullScreenGestureEnabled: true,
|
||||
headerShown: false,
|
||||
animationDuration: 250,
|
||||
contentStyle: pal.view,
|
||||
}}>
|
||||
<NotificationsTab.Screen
|
||||
@@ -429,11 +475,11 @@ function MyProfileTabNavigator() {
|
||||
return (
|
||||
<MyProfileTab.Navigator
|
||||
screenOptions={{
|
||||
animation: isAndroid ? 'none' : undefined,
|
||||
animation: isAndroid ? 'ios' : undefined,
|
||||
animationDuration: 285,
|
||||
gestureEnabled: true,
|
||||
fullScreenGestureEnabled: true,
|
||||
headerShown: false,
|
||||
animationDuration: 250,
|
||||
contentStyle: pal.view,
|
||||
}}>
|
||||
<MyProfileTab.Screen
|
||||
@@ -454,17 +500,20 @@ function MessagesTabNavigator() {
|
||||
return (
|
||||
<MessagesTab.Navigator
|
||||
screenOptions={{
|
||||
animation: isAndroid ? 'none' : undefined,
|
||||
animation: isAndroid ? 'ios' : undefined,
|
||||
animationDuration: 285,
|
||||
gestureEnabled: true,
|
||||
fullScreenGestureEnabled: true,
|
||||
headerShown: false,
|
||||
animationDuration: 250,
|
||||
contentStyle: pal.view,
|
||||
}}>
|
||||
<MessagesTab.Screen
|
||||
name="Messages"
|
||||
getComponent={() => MessagesScreen}
|
||||
options={{requireAuth: true}}
|
||||
options={({route}) => ({
|
||||
requireAuth: true,
|
||||
animationTypeForReplace: route.params?.animation ?? 'push',
|
||||
})}
|
||||
/>
|
||||
{commonScreens(MessagesTab as typeof HomeTab)}
|
||||
</MessagesTab.Navigator>
|
||||
@@ -485,10 +534,11 @@ const FlatNavigator = () => {
|
||||
<Flat.Navigator
|
||||
screenListeners={screenListeners}
|
||||
screenOptions={{
|
||||
animation: isAndroid ? 'ios' : undefined,
|
||||
animationDuration: 285,
|
||||
gestureEnabled: true,
|
||||
fullScreenGestureEnabled: true,
|
||||
headerShown: false,
|
||||
animationDuration: 250,
|
||||
contentStyle: pal.view,
|
||||
}}>
|
||||
<Flat.Screen
|
||||
@@ -501,11 +551,6 @@ const FlatNavigator = () => {
|
||||
getComponent={() => SearchScreen}
|
||||
options={{title: title(msg`Search`)}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="Feeds"
|
||||
getComponent={() => FeedsScreen}
|
||||
options={{title: title(msg`Feeds`)}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="Notifications"
|
||||
getComponent={() => NotificationsScreen}
|
||||
@@ -516,6 +561,11 @@ const FlatNavigator = () => {
|
||||
getComponent={() => MessagesScreen}
|
||||
options={{title: title(msg`Messages`), requireAuth: true}}
|
||||
/>
|
||||
<Flat.Screen
|
||||
name="Start"
|
||||
getComponent={() => HomeScreen}
|
||||
options={{title: title(msg`Home`)}}
|
||||
/>
|
||||
{commonScreens(Flat as typeof HomeTab, numUnread)}
|
||||
</Flat.Navigator>
|
||||
)
|
||||
@@ -598,7 +648,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
|
||||
if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) {
|
||||
openModal({name: 'verify-email', showReminder: true})
|
||||
setEmailConfirmationRequested()
|
||||
snoozeEmailConfirmationPrompt()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -608,7 +658,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
linking={LINKING}
|
||||
theme={theme}
|
||||
onStateChange={() => {
|
||||
logEvent('router:navigate', {
|
||||
logEvent('router:navigate:sampled', {
|
||||
from: prevLoggedRouteName.current,
|
||||
})
|
||||
prevLoggedRouteName.current = getCurrentRouteName()
|
||||
@@ -617,7 +667,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
attachRouteToLogEvents(getCurrentRouteName)
|
||||
logModuleInitTime()
|
||||
onReady()
|
||||
logEvent('router:navigate', {})
|
||||
logEvent('router:navigate:sampled', {})
|
||||
}}>
|
||||
{children}
|
||||
</NavigationContainer>
|
||||
@@ -741,6 +791,17 @@ function logModuleInitTime() {
|
||||
initMs,
|
||||
})
|
||||
|
||||
if (isWeb) {
|
||||
const referrerInfo = Referrer.getReferrerInfo()
|
||||
if (referrerInfo && referrerInfo.hostname !== 'bsky.app') {
|
||||
logEvent('deepLink:referrerReceived', {
|
||||
to: window.location.href,
|
||||
referrer: referrerInfo?.referrer,
|
||||
hostname: referrerInfo?.hostname,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (__DEV__) {
|
||||
// This log is noisy, so keep false committed
|
||||
const shouldLog = false
|
||||
|
||||
+29
-7
@@ -1,4 +1,4 @@
|
||||
import {Platform} from 'react-native'
|
||||
import {Platform, StyleSheet} from 'react-native'
|
||||
|
||||
import * as tokens from '#/alf/tokens'
|
||||
import {native, web} from '#/alf/util/platform'
|
||||
@@ -55,6 +55,13 @@ export const atoms = {
|
||||
height: '100vh',
|
||||
}),
|
||||
|
||||
/*
|
||||
* Theme-independent bg colors
|
||||
*/
|
||||
bg_transparent: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
|
||||
/*
|
||||
* Border radius
|
||||
*/
|
||||
@@ -137,6 +144,9 @@ export const atoms = {
|
||||
flex_shrink: {
|
||||
flexShrink: 1,
|
||||
},
|
||||
flex_shrink_0: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
justify_start: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
@@ -259,6 +269,9 @@ export const atoms = {
|
||||
font_bold: {
|
||||
fontWeight: tokens.fontWeight.bold,
|
||||
},
|
||||
font_heavy: {
|
||||
fontWeight: tokens.fontWeight.heavy,
|
||||
},
|
||||
italic: {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
@@ -270,19 +283,19 @@ export const atoms = {
|
||||
borderWidth: 0,
|
||||
},
|
||||
border: {
|
||||
borderWidth: 1,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
border_t: {
|
||||
borderTopWidth: 1,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
border_b: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
border_l: {
|
||||
borderLeftWidth: 1,
|
||||
borderLeftWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
border_r: {
|
||||
borderRightWidth: 1,
|
||||
borderRightWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
|
||||
/*
|
||||
@@ -841,7 +854,7 @@ export const atoms = {
|
||||
marginRight: 'auto',
|
||||
},
|
||||
/*
|
||||
* Pointer events
|
||||
* Pointer events & user select
|
||||
*/
|
||||
pointer_events_none: {
|
||||
pointerEvents: 'none',
|
||||
@@ -849,6 +862,15 @@ export const atoms = {
|
||||
pointer_events_auto: {
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
user_select_none: {
|
||||
userSelect: 'none',
|
||||
},
|
||||
user_select_text: {
|
||||
userSelect: 'text',
|
||||
},
|
||||
user_select_all: {
|
||||
userSelect: 'all',
|
||||
},
|
||||
/*
|
||||
* Text decoration
|
||||
*/
|
||||
|
||||
+21
-8
@@ -1,12 +1,16 @@
|
||||
import React from 'react'
|
||||
import {Dimensions} from 'react-native'
|
||||
import * as themes from '#/alf/themes'
|
||||
|
||||
export * from '#/alf/types'
|
||||
export * as tokens from '#/alf/tokens'
|
||||
import {createThemes, defaultTheme} from '#/alf/themes'
|
||||
import {Theme, ThemeName} from '#/alf/types'
|
||||
import {BLUE_HUE, GREEN_HUE, RED_HUE} from '#/alf/util/colorGeneration'
|
||||
|
||||
export {atoms} from '#/alf/atoms'
|
||||
export * from '#/alf/util/platform'
|
||||
export * as tokens from '#/alf/tokens'
|
||||
export * from '#/alf/types'
|
||||
export * from '#/alf/util/flatten'
|
||||
export * from '#/alf/util/platform'
|
||||
export * from '#/alf/util/themeSelector'
|
||||
|
||||
type BreakpointName = keyof typeof breakpoints
|
||||
|
||||
@@ -37,8 +41,8 @@ function getActiveBreakpoints({width}: {width: number}) {
|
||||
* Context
|
||||
*/
|
||||
export const Context = React.createContext<{
|
||||
themeName: themes.ThemeName
|
||||
theme: themes.Theme
|
||||
themeName: ThemeName
|
||||
theme: Theme
|
||||
breakpoints: {
|
||||
active: BreakpointName | undefined
|
||||
gtPhone: boolean
|
||||
@@ -47,7 +51,7 @@ export const Context = React.createContext<{
|
||||
}
|
||||
}>({
|
||||
themeName: 'light',
|
||||
theme: themes.light,
|
||||
theme: defaultTheme,
|
||||
breakpoints: {
|
||||
active: undefined,
|
||||
gtPhone: false,
|
||||
@@ -59,7 +63,16 @@ export const Context = React.createContext<{
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
theme: themeName,
|
||||
}: React.PropsWithChildren<{theme: themes.ThemeName}>) {
|
||||
}: React.PropsWithChildren<{theme: ThemeName}>) {
|
||||
const themes = React.useMemo(() => {
|
||||
return createThemes({
|
||||
hues: {
|
||||
primary: BLUE_HUE,
|
||||
negative: RED_HUE,
|
||||
positive: GREEN_HUE,
|
||||
},
|
||||
})
|
||||
}, [])
|
||||
const theme = themes[themeName]
|
||||
const [breakpoints, setBreakpoints] = React.useState(() =>
|
||||
getActiveBreakpoints({width: Dimensions.get('window').width}),
|
||||
|
||||
+569
-447
File diff suppressed because it is too large
Load Diff
+1
-78
@@ -1,77 +1,6 @@
|
||||
import {
|
||||
BLUE_HUE,
|
||||
generateScale,
|
||||
GREEN_HUE,
|
||||
RED_HUE,
|
||||
} from '#/alf/util/colorGeneration'
|
||||
|
||||
export const scale = generateScale(6, 100)
|
||||
// dim shifted 6% lighter
|
||||
export const dimScale = generateScale(12, 100)
|
||||
|
||||
export const color = {
|
||||
trueBlack: '#000000',
|
||||
|
||||
temp_purple: 'rgb(105 0 255)',
|
||||
temp_purple_dark: 'rgb(83 0 202)',
|
||||
|
||||
gray_0: `hsl(${BLUE_HUE}, 20%, ${scale[14]}%)`,
|
||||
gray_25: `hsl(${BLUE_HUE}, 20%, ${scale[13]}%)`,
|
||||
gray_50: `hsl(${BLUE_HUE}, 20%, ${scale[12]}%)`,
|
||||
gray_100: `hsl(${BLUE_HUE}, 20%, ${scale[11]}%)`,
|
||||
gray_200: `hsl(${BLUE_HUE}, 20%, ${scale[10]}%)`,
|
||||
gray_300: `hsl(${BLUE_HUE}, 20%, ${scale[9]}%)`,
|
||||
gray_400: `hsl(${BLUE_HUE}, 20%, ${scale[8]}%)`,
|
||||
gray_500: `hsl(${BLUE_HUE}, 20%, ${scale[7]}%)`,
|
||||
gray_600: `hsl(${BLUE_HUE}, 24%, ${scale[6]}%)`,
|
||||
gray_700: `hsl(${BLUE_HUE}, 24%, ${scale[5]}%)`,
|
||||
gray_800: `hsl(${BLUE_HUE}, 28%, ${scale[4]}%)`,
|
||||
gray_900: `hsl(${BLUE_HUE}, 28%, ${scale[3]}%)`,
|
||||
gray_950: `hsl(${BLUE_HUE}, 28%, ${scale[2]}%)`,
|
||||
gray_975: `hsl(${BLUE_HUE}, 28%, ${scale[1]}%)`,
|
||||
gray_1000: `hsl(${BLUE_HUE}, 28%, ${scale[0]}%)`,
|
||||
|
||||
blue_25: `hsl(${BLUE_HUE}, 99%, 97%)`,
|
||||
blue_50: `hsl(${BLUE_HUE}, 99%, 95%)`,
|
||||
blue_100: `hsl(${BLUE_HUE}, 99%, 90%)`,
|
||||
blue_200: `hsl(${BLUE_HUE}, 99%, 80%)`,
|
||||
blue_300: `hsl(${BLUE_HUE}, 99%, 70%)`,
|
||||
blue_400: `hsl(${BLUE_HUE}, 99%, 60%)`,
|
||||
blue_500: `hsl(${BLUE_HUE}, 99%, 53%)`,
|
||||
blue_600: `hsl(${BLUE_HUE}, 99%, 42%)`,
|
||||
blue_700: `hsl(${BLUE_HUE}, 99%, 34%)`,
|
||||
blue_800: `hsl(${BLUE_HUE}, 99%, 26%)`,
|
||||
blue_900: `hsl(${BLUE_HUE}, 99%, 18%)`,
|
||||
blue_950: `hsl(${BLUE_HUE}, 99%, 10%)`,
|
||||
blue_975: `hsl(${BLUE_HUE}, 99%, 7%)`,
|
||||
|
||||
green_25: `hsl(${GREEN_HUE}, 82%, 97%)`,
|
||||
green_50: `hsl(${GREEN_HUE}, 82%, 95%)`,
|
||||
green_100: `hsl(${GREEN_HUE}, 82%, 90%)`,
|
||||
green_200: `hsl(${GREEN_HUE}, 82%, 80%)`,
|
||||
green_300: `hsl(${GREEN_HUE}, 82%, 70%)`,
|
||||
green_400: `hsl(${GREEN_HUE}, 82%, 60%)`,
|
||||
green_500: `hsl(${GREEN_HUE}, 82%, 50%)`,
|
||||
green_600: `hsl(${GREEN_HUE}, 82%, 42%)`,
|
||||
green_700: `hsl(${GREEN_HUE}, 82%, 34%)`,
|
||||
green_800: `hsl(${GREEN_HUE}, 82%, 26%)`,
|
||||
green_900: `hsl(${GREEN_HUE}, 82%, 18%)`,
|
||||
green_950: `hsl(${GREEN_HUE}, 82%, 10%)`,
|
||||
green_975: `hsl(${GREEN_HUE}, 82%, 7%)`,
|
||||
|
||||
red_25: `hsl(${RED_HUE}, 91%, 97%)`,
|
||||
red_50: `hsl(${RED_HUE}, 91%, 95%)`,
|
||||
red_100: `hsl(${RED_HUE}, 91%, 90%)`,
|
||||
red_200: `hsl(${RED_HUE}, 91%, 80%)`,
|
||||
red_300: `hsl(${RED_HUE}, 91%, 70%)`,
|
||||
red_400: `hsl(${RED_HUE}, 91%, 60%)`,
|
||||
red_500: `hsl(${RED_HUE}, 91%, 50%)`,
|
||||
red_600: `hsl(${RED_HUE}, 91%, 42%)`,
|
||||
red_700: `hsl(${RED_HUE}, 91%, 34%)`,
|
||||
red_800: `hsl(${RED_HUE}, 91%, 26%)`,
|
||||
red_900: `hsl(${RED_HUE}, 91%, 18%)`,
|
||||
red_950: `hsl(${RED_HUE}, 91%, 10%)`,
|
||||
red_975: `hsl(${RED_HUE}, 91%, 7%)`,
|
||||
} as const
|
||||
|
||||
export const space = {
|
||||
@@ -118,6 +47,7 @@ export const fontWeight = {
|
||||
normal: '400',
|
||||
semibold: '500',
|
||||
bold: '600',
|
||||
heavy: '700',
|
||||
} as const
|
||||
|
||||
export const gradients = {
|
||||
@@ -177,10 +107,3 @@ export const gradients = {
|
||||
hover_value: '#755B62',
|
||||
},
|
||||
} as const
|
||||
|
||||
export type Color = keyof typeof color
|
||||
export type Space = keyof typeof space
|
||||
export type FontSize = keyof typeof fontSize
|
||||
export type LineHeight = keyof typeof lineHeight
|
||||
export type BorderRadius = keyof typeof borderRadius
|
||||
export type FontWeight = keyof typeof fontWeight
|
||||
|
||||
+154
-18
@@ -1,21 +1,4 @@
|
||||
import {StyleProp, ViewStyle, TextStyle} from 'react-native'
|
||||
|
||||
type LiteralToCommon<T extends PropertyKey> = T extends number
|
||||
? number
|
||||
: T extends string
|
||||
? string
|
||||
: T extends symbol
|
||||
? symbol
|
||||
: never
|
||||
|
||||
/**
|
||||
* @see https://stackoverflow.com/questions/68249999/use-as-const-in-typescript-without-adding-readonly-modifiers
|
||||
*/
|
||||
export type Mutable<T> = {
|
||||
-readonly [K in keyof T]: T[K] extends PropertyKey
|
||||
? LiteralToCommon<T[K]>
|
||||
: Mutable<T[K]>
|
||||
}
|
||||
import {StyleProp, TextStyle, ViewStyle} from 'react-native'
|
||||
|
||||
export type TextStyleProp = {
|
||||
style?: StyleProp<TextStyle>
|
||||
@@ -24,3 +7,156 @@ export type TextStyleProp = {
|
||||
export type ViewStyleProp = {
|
||||
style?: StyleProp<ViewStyle>
|
||||
}
|
||||
|
||||
export type ThemeName = 'light' | 'dim' | 'dark'
|
||||
export type Palette = {
|
||||
white: string
|
||||
black: string
|
||||
|
||||
contrast_25: string
|
||||
contrast_50: string
|
||||
contrast_100: string
|
||||
contrast_200: string
|
||||
contrast_300: string
|
||||
contrast_400: string
|
||||
contrast_500: string
|
||||
contrast_600: string
|
||||
contrast_700: string
|
||||
contrast_800: string
|
||||
contrast_900: string
|
||||
contrast_950: string
|
||||
contrast_975: string
|
||||
|
||||
primary_25: string
|
||||
primary_50: string
|
||||
primary_100: string
|
||||
primary_200: string
|
||||
primary_300: string
|
||||
primary_400: string
|
||||
primary_500: string
|
||||
primary_600: string
|
||||
primary_700: string
|
||||
primary_800: string
|
||||
primary_900: string
|
||||
primary_950: string
|
||||
primary_975: string
|
||||
|
||||
positive_25: string
|
||||
positive_50: string
|
||||
positive_100: string
|
||||
positive_200: string
|
||||
positive_300: string
|
||||
positive_400: string
|
||||
positive_500: string
|
||||
positive_600: string
|
||||
positive_700: string
|
||||
positive_800: string
|
||||
positive_900: string
|
||||
positive_950: string
|
||||
positive_975: string
|
||||
|
||||
negative_25: string
|
||||
negative_50: string
|
||||
negative_100: string
|
||||
negative_200: string
|
||||
negative_300: string
|
||||
negative_400: string
|
||||
negative_500: string
|
||||
negative_600: string
|
||||
negative_700: string
|
||||
negative_800: string
|
||||
negative_900: string
|
||||
negative_950: string
|
||||
negative_975: string
|
||||
}
|
||||
export type ThemedAtoms = {
|
||||
text: {
|
||||
color: string
|
||||
}
|
||||
text_contrast_low: {
|
||||
color: string
|
||||
}
|
||||
text_contrast_medium: {
|
||||
color: string
|
||||
}
|
||||
text_contrast_high: {
|
||||
color: string
|
||||
}
|
||||
text_inverted: {
|
||||
color: string
|
||||
}
|
||||
bg: {
|
||||
backgroundColor: string
|
||||
}
|
||||
bg_contrast_25: {
|
||||
backgroundColor: string
|
||||
}
|
||||
bg_contrast_50: {
|
||||
backgroundColor: string
|
||||
}
|
||||
bg_contrast_100: {
|
||||
backgroundColor: string
|
||||
}
|
||||
bg_contrast_200: {
|
||||
backgroundColor: string
|
||||
}
|
||||
bg_contrast_300: {
|
||||
backgroundColor: string
|
||||
}
|
||||
bg_contrast_400: {
|
||||
backgroundColor: string
|
||||
}
|
||||
bg_contrast_500: {
|
||||
backgroundColor: string
|
||||
}
|
||||
bg_contrast_600: {
|
||||
backgroundColor: string
|
||||
}
|
||||
bg_contrast_700: {
|
||||
backgroundColor: string
|
||||
}
|
||||
bg_contrast_800: {
|
||||
backgroundColor: string
|
||||
}
|
||||
bg_contrast_900: {
|
||||
backgroundColor: string
|
||||
}
|
||||
bg_contrast_950: {
|
||||
backgroundColor: string
|
||||
}
|
||||
bg_contrast_975: {
|
||||
backgroundColor: string
|
||||
}
|
||||
border_contrast_low: {
|
||||
borderColor: string
|
||||
}
|
||||
border_contrast_medium: {
|
||||
borderColor: string
|
||||
}
|
||||
border_contrast_high: {
|
||||
borderColor: string
|
||||
}
|
||||
shadow_sm: {
|
||||
shadowRadius: number
|
||||
shadowOpacity: number
|
||||
elevation: number
|
||||
shadowColor: string
|
||||
}
|
||||
shadow_md: {
|
||||
shadowRadius: number
|
||||
shadowOpacity: number
|
||||
elevation: number
|
||||
shadowColor: string
|
||||
}
|
||||
shadow_lg: {
|
||||
shadowRadius: number
|
||||
shadowOpacity: number
|
||||
elevation: number
|
||||
shadowColor: string
|
||||
}
|
||||
}
|
||||
export type Theme = {
|
||||
name: ThemeName
|
||||
palette: Palette
|
||||
atoms: ThemedAtoms
|
||||
}
|
||||
|
||||
@@ -15,3 +15,7 @@ export function generateScale(start: number, end: number) {
|
||||
return start + range * stop
|
||||
})
|
||||
}
|
||||
|
||||
export const defaultScale = generateScale(6, 100)
|
||||
// dim shifted 6% lighter
|
||||
export const dimScale = generateScale(12, 100)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import {ThemeName} from '#/alf/types'
|
||||
|
||||
export function select<T>(name: ThemeName, options: Record<ThemeName, T>) {
|
||||
switch (name) {
|
||||
case 'light':
|
||||
return options.light
|
||||
case 'dark':
|
||||
return options.dark || options.dim
|
||||
case 'dim':
|
||||
return options.dim || options.dark
|
||||
default:
|
||||
throw new Error(`select(theme, options) received unknown theme ${name}`)
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,28 @@
|
||||
import React from 'react'
|
||||
import {ColorSchemeName, useColorScheme} from 'react-native'
|
||||
|
||||
import {useThemePrefs} from 'state/shell'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {ThemeName, light, dark, dim} from '#/alf/themes'
|
||||
import * as SystemUI from 'expo-system-ui'
|
||||
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {useThemePrefs} from 'state/shell'
|
||||
import {dark, dim, light} from '#/alf/themes'
|
||||
import {ThemeName} from '#/alf/types'
|
||||
|
||||
export function useColorModeTheme(): ThemeName {
|
||||
const theme = useThemeName()
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
updateDocument(theme)
|
||||
SystemUI.setBackgroundColorAsync(getBackgroundColor(theme))
|
||||
}, [theme])
|
||||
|
||||
return theme
|
||||
}
|
||||
|
||||
export function useThemeName(): ThemeName {
|
||||
const colorScheme = useColorScheme()
|
||||
const {colorMode, darkTheme} = useThemePrefs()
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const theme = getThemeName(colorScheme, colorMode, darkTheme)
|
||||
updateDocument(theme)
|
||||
SystemUI.setBackgroundColorAsync(getBackgroundColor(theme))
|
||||
}, [colorMode, colorScheme, darkTheme])
|
||||
|
||||
return React.useMemo(
|
||||
() => getThemeName(colorScheme, colorMode, darkTheme),
|
||||
[colorScheme, colorMode, darkTheme],
|
||||
)
|
||||
return getThemeName(colorScheme, colorMode, darkTheme)
|
||||
}
|
||||
|
||||
function getThemeName(
|
||||
@@ -53,7 +56,7 @@ function updateDocument(theme: ThemeName) {
|
||||
}
|
||||
}
|
||||
|
||||
function getBackgroundColor(theme: ThemeName): string {
|
||||
export function getBackgroundColor(theme: ThemeName): string {
|
||||
switch (theme) {
|
||||
case 'light':
|
||||
return light.atoms.bg.backgroundColor
|
||||
|
||||
@@ -37,7 +37,7 @@ export function AccountList({
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
{borderWidth: 1},
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
{accounts.map(account => (
|
||||
@@ -48,7 +48,7 @@ export function AccountList({
|
||||
isCurrentAccount={account.did === currentAccount?.did}
|
||||
isPendingAccount={account.did === pendingDid}
|
||||
/>
|
||||
<View style={[a.border_b, t.atoms.border_contrast_low]} />
|
||||
<View style={[{borderBottomWidth: 1}, t.atoms.border_contrast_low]} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
<Button
|
||||
|
||||
+411
-322
@@ -13,7 +13,7 @@ import {
|
||||
} from 'react-native'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
|
||||
import {android, atoms as a, flatten, tokens, useTheme} from '#/alf'
|
||||
import {android, atoms as a, flatten, select, tokens, useTheme} from '#/alf'
|
||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {normalizeTextStyles} from '#/components/Typography'
|
||||
|
||||
@@ -21,6 +21,7 @@ export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
|
||||
export type ButtonColor =
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'secondary_inverted'
|
||||
| 'negative'
|
||||
| 'gradient_sky'
|
||||
| 'gradient_midnight'
|
||||
@@ -28,7 +29,7 @@ export type ButtonColor =
|
||||
| 'gradient_sunset'
|
||||
| 'gradient_nordic'
|
||||
| 'gradient_bonfire'
|
||||
export type ButtonSize = 'tiny' | 'small' | 'medium' | 'large'
|
||||
export type ButtonSize = 'tiny' | 'xsmall' | 'small' | 'medium' | 'large'
|
||||
export type ButtonShape = 'round' | 'square' | 'default'
|
||||
export type VariantProps = {
|
||||
/**
|
||||
@@ -64,13 +65,17 @@ type NonTextElements =
|
||||
|
||||
export type ButtonProps = Pick<
|
||||
PressableProps,
|
||||
'disabled' | 'onPress' | 'testID' | 'onLongPress'
|
||||
'disabled' | 'onPress' | 'testID' | 'onLongPress' | 'hitSlop'
|
||||
> &
|
||||
AccessibilityProps &
|
||||
VariantProps & {
|
||||
testID?: string
|
||||
/**
|
||||
* For a11y, try to make this descriptive and clear
|
||||
*/
|
||||
label: string
|
||||
style?: StyleProp<ViewStyle>
|
||||
hoverStyle?: StyleProp<ViewStyle>
|
||||
children: NonTextElements | ((context: ButtonContext) => NonTextElements)
|
||||
}
|
||||
|
||||
@@ -87,336 +92,388 @@ export function useButtonContext() {
|
||||
return React.useContext(Context)
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant,
|
||||
color,
|
||||
size,
|
||||
shape = 'default',
|
||||
label,
|
||||
disabled = false,
|
||||
style,
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
const t = useTheme()
|
||||
const [state, setState] = React.useState({
|
||||
pressed: false,
|
||||
hovered: false,
|
||||
focused: false,
|
||||
})
|
||||
|
||||
const onPressIn = React.useCallback(() => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
pressed: true,
|
||||
}))
|
||||
}, [setState])
|
||||
const onPressOut = React.useCallback(() => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
pressed: false,
|
||||
}))
|
||||
}, [setState])
|
||||
const onHoverIn = React.useCallback(() => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
hovered: true,
|
||||
}))
|
||||
}, [setState])
|
||||
const onHoverOut = React.useCallback(() => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
hovered: false,
|
||||
}))
|
||||
}, [setState])
|
||||
const onFocus = React.useCallback(() => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
focused: true,
|
||||
}))
|
||||
}, [setState])
|
||||
const onBlur = React.useCallback(() => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
focused: false,
|
||||
}))
|
||||
}, [setState])
|
||||
|
||||
const {baseStyles, hoverStyles} = React.useMemo(() => {
|
||||
const baseStyles: ViewStyle[] = []
|
||||
const hoverStyles: ViewStyle[] = []
|
||||
const light = t.name === 'light'
|
||||
|
||||
if (color === 'primary') {
|
||||
if (variant === 'solid') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
backgroundColor: t.palette.primary_500,
|
||||
})
|
||||
hoverStyles.push({
|
||||
backgroundColor: t.palette.primary_600,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push({
|
||||
backgroundColor: t.palette.primary_700,
|
||||
})
|
||||
}
|
||||
} else if (variant === 'outline') {
|
||||
baseStyles.push(a.border, t.atoms.bg, {
|
||||
borderWidth: 1,
|
||||
})
|
||||
|
||||
if (!disabled) {
|
||||
baseStyles.push(a.border, {
|
||||
borderColor: t.palette.primary_500,
|
||||
})
|
||||
hoverStyles.push(a.border, {
|
||||
backgroundColor: light
|
||||
? t.palette.primary_50
|
||||
: t.palette.primary_950,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push(a.border, {
|
||||
borderColor: light ? t.palette.primary_200 : t.palette.primary_900,
|
||||
})
|
||||
}
|
||||
} else if (variant === 'ghost') {
|
||||
if (!disabled) {
|
||||
baseStyles.push(t.atoms.bg)
|
||||
hoverStyles.push({
|
||||
backgroundColor: light
|
||||
? t.palette.primary_100
|
||||
: t.palette.primary_900,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (color === 'secondary') {
|
||||
if (variant === 'solid') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
backgroundColor: t.palette.contrast_25,
|
||||
})
|
||||
hoverStyles.push({
|
||||
backgroundColor: t.palette.contrast_50,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push({
|
||||
backgroundColor: t.palette.contrast_100,
|
||||
})
|
||||
}
|
||||
} else if (variant === 'outline') {
|
||||
baseStyles.push(a.border, t.atoms.bg, {
|
||||
borderWidth: 1,
|
||||
})
|
||||
|
||||
if (!disabled) {
|
||||
baseStyles.push(a.border, {
|
||||
borderColor: t.palette.contrast_300,
|
||||
})
|
||||
hoverStyles.push(t.atoms.bg_contrast_50)
|
||||
} else {
|
||||
baseStyles.push(a.border, {
|
||||
borderColor: t.palette.contrast_200,
|
||||
})
|
||||
}
|
||||
} else if (variant === 'ghost') {
|
||||
if (!disabled) {
|
||||
baseStyles.push(t.atoms.bg)
|
||||
hoverStyles.push({
|
||||
backgroundColor: t.palette.contrast_100,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (color === 'negative') {
|
||||
if (variant === 'solid') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
backgroundColor: t.palette.negative_500,
|
||||
})
|
||||
hoverStyles.push({
|
||||
backgroundColor: t.palette.negative_600,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push({
|
||||
backgroundColor: t.palette.negative_700,
|
||||
})
|
||||
}
|
||||
} else if (variant === 'outline') {
|
||||
baseStyles.push(a.border, t.atoms.bg, {
|
||||
borderWidth: 1,
|
||||
})
|
||||
|
||||
if (!disabled) {
|
||||
baseStyles.push(a.border, {
|
||||
borderColor: t.palette.negative_500,
|
||||
})
|
||||
hoverStyles.push(a.border, {
|
||||
backgroundColor: light
|
||||
? t.palette.negative_50
|
||||
: t.palette.negative_975,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push(a.border, {
|
||||
borderColor: light
|
||||
? t.palette.negative_200
|
||||
: t.palette.negative_900,
|
||||
})
|
||||
}
|
||||
} else if (variant === 'ghost') {
|
||||
if (!disabled) {
|
||||
baseStyles.push(t.atoms.bg)
|
||||
hoverStyles.push({
|
||||
backgroundColor: light
|
||||
? t.palette.negative_100
|
||||
: t.palette.negative_975,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shape === 'default') {
|
||||
if (size === 'large') {
|
||||
baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_md)
|
||||
} else if (size === 'medium') {
|
||||
baseStyles.push({paddingVertical: 12}, a.px_2xl, a.rounded_sm, a.gap_md)
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm)
|
||||
} else if (size === 'tiny') {
|
||||
baseStyles.push({paddingVertical: 4}, a.px_sm, a.rounded_xs, a.gap_xs)
|
||||
}
|
||||
} else if (shape === 'round' || shape === 'square') {
|
||||
if (size === 'large') {
|
||||
if (shape === 'round') {
|
||||
baseStyles.push({height: 54, width: 54})
|
||||
} else {
|
||||
baseStyles.push({height: 50, width: 50})
|
||||
}
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push({height: 40, width: 40})
|
||||
} else if (size === 'tiny') {
|
||||
baseStyles.push({height: 20, width: 20})
|
||||
}
|
||||
|
||||
if (shape === 'round') {
|
||||
baseStyles.push(a.rounded_full)
|
||||
} else if (shape === 'square') {
|
||||
if (size === 'tiny') {
|
||||
baseStyles.push(a.rounded_xs)
|
||||
} else {
|
||||
baseStyles.push(a.rounded_sm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
baseStyles,
|
||||
hoverStyles,
|
||||
}
|
||||
}, [t, variant, color, size, shape, disabled])
|
||||
|
||||
const {gradientColors, gradientHoverColors, gradientLocations} =
|
||||
React.useMemo(() => {
|
||||
const colors: string[] = []
|
||||
const hoverColors: string[] = []
|
||||
const locations: number[] = []
|
||||
const gradient = {
|
||||
primary: tokens.gradients.sky,
|
||||
secondary: tokens.gradients.sky,
|
||||
negative: tokens.gradients.sky,
|
||||
gradient_sky: tokens.gradients.sky,
|
||||
gradient_midnight: tokens.gradients.midnight,
|
||||
gradient_sunrise: tokens.gradients.sunrise,
|
||||
gradient_sunset: tokens.gradients.sunset,
|
||||
gradient_nordic: tokens.gradients.nordic,
|
||||
gradient_bonfire: tokens.gradients.bonfire,
|
||||
}[color || 'primary']
|
||||
|
||||
if (variant === 'gradient') {
|
||||
colors.push(...gradient.values.map(([_, color]) => color))
|
||||
hoverColors.push(...gradient.values.map(_ => gradient.hover_value))
|
||||
locations.push(...gradient.values.map(([location, _]) => location))
|
||||
}
|
||||
|
||||
return {
|
||||
gradientColors: colors,
|
||||
gradientHoverColors: hoverColors,
|
||||
gradientLocations: locations,
|
||||
}
|
||||
}, [variant, color])
|
||||
|
||||
const context = React.useMemo<ButtonContext>(
|
||||
() => ({
|
||||
...state,
|
||||
export const Button = React.forwardRef<View, ButtonProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
variant,
|
||||
color,
|
||||
size,
|
||||
disabled: disabled || false,
|
||||
}),
|
||||
[state, variant, color, size, disabled],
|
||||
)
|
||||
shape = 'default',
|
||||
label,
|
||||
disabled = false,
|
||||
style,
|
||||
hoverStyle: hoverStyleProp,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const t = useTheme()
|
||||
const [state, setState] = React.useState({
|
||||
pressed: false,
|
||||
hovered: false,
|
||||
focused: false,
|
||||
})
|
||||
|
||||
const flattenedBaseStyles = flatten(baseStyles)
|
||||
const onPressIn = React.useCallback(() => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
pressed: true,
|
||||
}))
|
||||
}, [setState])
|
||||
const onPressOut = React.useCallback(() => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
pressed: false,
|
||||
}))
|
||||
}, [setState])
|
||||
const onHoverIn = React.useCallback(() => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
hovered: true,
|
||||
}))
|
||||
}, [setState])
|
||||
const onHoverOut = React.useCallback(() => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
hovered: false,
|
||||
}))
|
||||
}, [setState])
|
||||
const onFocus = React.useCallback(() => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
focused: true,
|
||||
}))
|
||||
}, [setState])
|
||||
const onBlur = React.useCallback(() => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
focused: false,
|
||||
}))
|
||||
}, [setState])
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
role="button"
|
||||
accessibilityHint={undefined} // optional
|
||||
{...rest}
|
||||
aria-label={label}
|
||||
aria-pressed={state.pressed}
|
||||
accessibilityLabel={label}
|
||||
disabled={disabled || false}
|
||||
accessibilityState={{
|
||||
const {baseStyles, hoverStyles} = React.useMemo(() => {
|
||||
const baseStyles: ViewStyle[] = []
|
||||
const hoverStyles: ViewStyle[] = []
|
||||
|
||||
if (color === 'primary') {
|
||||
if (variant === 'solid') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
backgroundColor: t.palette.primary_500,
|
||||
})
|
||||
hoverStyles.push({
|
||||
backgroundColor: t.palette.primary_600,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push({
|
||||
backgroundColor: select(t.name, {
|
||||
light: t.palette.primary_700,
|
||||
dim: t.palette.primary_300,
|
||||
dark: t.palette.primary_300,
|
||||
}),
|
||||
})
|
||||
}
|
||||
} else if (variant === 'outline') {
|
||||
baseStyles.push(a.border, t.atoms.bg, {
|
||||
borderWidth: 1,
|
||||
})
|
||||
|
||||
if (!disabled) {
|
||||
baseStyles.push(a.border, {
|
||||
borderColor: t.palette.primary_500,
|
||||
})
|
||||
hoverStyles.push(a.border, {
|
||||
backgroundColor: t.palette.primary_50,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push(a.border, {
|
||||
borderColor: t.palette.primary_200,
|
||||
})
|
||||
}
|
||||
} else if (variant === 'ghost') {
|
||||
if (!disabled) {
|
||||
baseStyles.push(t.atoms.bg)
|
||||
hoverStyles.push({
|
||||
backgroundColor: t.palette.primary_100,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (color === 'secondary') {
|
||||
if (variant === 'solid') {
|
||||
if (!disabled) {
|
||||
baseStyles.push(t.atoms.bg_contrast_25)
|
||||
hoverStyles.push(t.atoms.bg_contrast_50)
|
||||
} else {
|
||||
baseStyles.push(t.atoms.bg_contrast_100)
|
||||
}
|
||||
} else if (variant === 'outline') {
|
||||
baseStyles.push(a.border, t.atoms.bg, {
|
||||
borderWidth: 1,
|
||||
})
|
||||
|
||||
if (!disabled) {
|
||||
baseStyles.push(a.border, {
|
||||
borderColor: t.palette.contrast_300,
|
||||
})
|
||||
hoverStyles.push(t.atoms.bg_contrast_50)
|
||||
} else {
|
||||
baseStyles.push(a.border, {
|
||||
borderColor: t.palette.contrast_200,
|
||||
})
|
||||
}
|
||||
} else if (variant === 'ghost') {
|
||||
if (!disabled) {
|
||||
baseStyles.push(t.atoms.bg)
|
||||
hoverStyles.push({
|
||||
backgroundColor: t.palette.contrast_25,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (color === 'secondary_inverted') {
|
||||
if (variant === 'solid') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
backgroundColor: t.palette.contrast_900,
|
||||
})
|
||||
hoverStyles.push({
|
||||
backgroundColor: t.palette.contrast_950,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push({
|
||||
backgroundColor: t.palette.contrast_600,
|
||||
})
|
||||
}
|
||||
} else if (variant === 'outline') {
|
||||
baseStyles.push(a.border, t.atoms.bg, {
|
||||
borderWidth: 1,
|
||||
})
|
||||
|
||||
if (!disabled) {
|
||||
baseStyles.push(a.border, {
|
||||
borderColor: t.palette.contrast_300,
|
||||
})
|
||||
hoverStyles.push(t.atoms.bg_contrast_50)
|
||||
} else {
|
||||
baseStyles.push(a.border, {
|
||||
borderColor: t.palette.contrast_200,
|
||||
})
|
||||
}
|
||||
} else if (variant === 'ghost') {
|
||||
if (!disabled) {
|
||||
baseStyles.push(t.atoms.bg)
|
||||
hoverStyles.push({
|
||||
backgroundColor: t.palette.contrast_25,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (color === 'negative') {
|
||||
if (variant === 'solid') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
backgroundColor: t.palette.negative_500,
|
||||
})
|
||||
hoverStyles.push({
|
||||
backgroundColor: t.palette.negative_600,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push({
|
||||
backgroundColor: select(t.name, {
|
||||
light: t.palette.negative_700,
|
||||
dim: t.palette.negative_300,
|
||||
dark: t.palette.negative_300,
|
||||
}),
|
||||
})
|
||||
}
|
||||
} else if (variant === 'outline') {
|
||||
baseStyles.push(a.border, t.atoms.bg, {
|
||||
borderWidth: 1,
|
||||
})
|
||||
|
||||
if (!disabled) {
|
||||
baseStyles.push(a.border, {
|
||||
borderColor: t.palette.negative_500,
|
||||
})
|
||||
hoverStyles.push(a.border, {
|
||||
backgroundColor: t.palette.negative_50,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push(a.border, {
|
||||
borderColor: t.palette.negative_200,
|
||||
})
|
||||
}
|
||||
} else if (variant === 'ghost') {
|
||||
if (!disabled) {
|
||||
baseStyles.push(t.atoms.bg)
|
||||
hoverStyles.push({
|
||||
backgroundColor: t.palette.negative_100,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shape === 'default') {
|
||||
if (size === 'large') {
|
||||
baseStyles.push(
|
||||
{paddingVertical: 15},
|
||||
a.px_2xl,
|
||||
a.rounded_sm,
|
||||
a.gap_md,
|
||||
)
|
||||
} else if (size === 'medium') {
|
||||
baseStyles.push(
|
||||
{paddingVertical: 12},
|
||||
a.px_2xl,
|
||||
a.rounded_sm,
|
||||
a.gap_md,
|
||||
)
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm)
|
||||
} else if (size === 'xsmall') {
|
||||
baseStyles.push({paddingVertical: 6}, a.px_sm, a.rounded_sm, a.gap_sm)
|
||||
} else if (size === 'tiny') {
|
||||
baseStyles.push({paddingVertical: 4}, a.px_sm, a.rounded_xs, a.gap_xs)
|
||||
}
|
||||
} else if (shape === 'round' || shape === 'square') {
|
||||
if (size === 'large') {
|
||||
if (shape === 'round') {
|
||||
baseStyles.push({height: 54, width: 54})
|
||||
} else {
|
||||
baseStyles.push({height: 50, width: 50})
|
||||
}
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push({height: 34, width: 34})
|
||||
} else if (size === 'xsmall') {
|
||||
baseStyles.push({height: 28, width: 28})
|
||||
} else if (size === 'tiny') {
|
||||
baseStyles.push({height: 20, width: 20})
|
||||
}
|
||||
|
||||
if (shape === 'round') {
|
||||
baseStyles.push(a.rounded_full)
|
||||
} else if (shape === 'square') {
|
||||
if (size === 'tiny') {
|
||||
baseStyles.push(a.rounded_xs)
|
||||
} else {
|
||||
baseStyles.push(a.rounded_sm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
baseStyles,
|
||||
hoverStyles,
|
||||
}
|
||||
}, [t, variant, color, size, shape, disabled])
|
||||
|
||||
const {gradientColors, gradientHoverColors, gradientLocations} =
|
||||
React.useMemo(() => {
|
||||
const colors: string[] = []
|
||||
const hoverColors: string[] = []
|
||||
const locations: number[] = []
|
||||
const gradient = {
|
||||
primary: tokens.gradients.sky,
|
||||
secondary: tokens.gradients.sky,
|
||||
secondary_inverted: tokens.gradients.sky,
|
||||
negative: tokens.gradients.sky,
|
||||
gradient_sky: tokens.gradients.sky,
|
||||
gradient_midnight: tokens.gradients.midnight,
|
||||
gradient_sunrise: tokens.gradients.sunrise,
|
||||
gradient_sunset: tokens.gradients.sunset,
|
||||
gradient_nordic: tokens.gradients.nordic,
|
||||
gradient_bonfire: tokens.gradients.bonfire,
|
||||
}[color || 'primary']
|
||||
|
||||
if (variant === 'gradient') {
|
||||
colors.push(...gradient.values.map(([_, color]) => color))
|
||||
hoverColors.push(...gradient.values.map(_ => gradient.hover_value))
|
||||
locations.push(...gradient.values.map(([location, _]) => location))
|
||||
}
|
||||
|
||||
return {
|
||||
gradientColors: colors,
|
||||
gradientHoverColors: hoverColors,
|
||||
gradientLocations: locations,
|
||||
}
|
||||
}, [variant, color])
|
||||
|
||||
const context = React.useMemo<ButtonContext>(
|
||||
() => ({
|
||||
...state,
|
||||
variant,
|
||||
color,
|
||||
size,
|
||||
disabled: disabled || false,
|
||||
}}
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
flattenedBaseStyles,
|
||||
...(state.hovered || state.pressed ? hoverStyles : []),
|
||||
flatten(style),
|
||||
]}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
onHoverIn={onHoverIn}
|
||||
onHoverOut={onHoverOut}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}>
|
||||
{variant === 'gradient' && (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.overflow_hidden,
|
||||
{borderRadius: flattenedBaseStyles.borderRadius},
|
||||
]}>
|
||||
<LinearGradient
|
||||
colors={
|
||||
state.hovered || state.pressed
|
||||
? gradientHoverColors
|
||||
: gradientColors
|
||||
}
|
||||
locations={gradientLocations}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 1}}
|
||||
style={[a.absolute, a.inset_0]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<Context.Provider value={context}>
|
||||
{typeof children === 'function' ? children(context) : children}
|
||||
</Context.Provider>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
}),
|
||||
[state, variant, color, size, disabled],
|
||||
)
|
||||
|
||||
const flattenedBaseStyles = flatten(baseStyles)
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
role="button"
|
||||
accessibilityHint={undefined} // optional
|
||||
{...rest}
|
||||
ref={ref}
|
||||
aria-label={label}
|
||||
aria-pressed={state.pressed}
|
||||
accessibilityLabel={label}
|
||||
disabled={disabled || false}
|
||||
accessibilityState={{
|
||||
disabled: disabled || false,
|
||||
}}
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
flattenedBaseStyles,
|
||||
flatten(style),
|
||||
...(state.hovered || state.pressed
|
||||
? [hoverStyles, flatten(hoverStyleProp)]
|
||||
: []),
|
||||
]}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
onHoverIn={onHoverIn}
|
||||
onHoverOut={onHoverOut}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}>
|
||||
{variant === 'gradient' && (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.overflow_hidden,
|
||||
{borderRadius: flattenedBaseStyles.borderRadius},
|
||||
]}>
|
||||
<LinearGradient
|
||||
colors={
|
||||
state.hovered || state.pressed
|
||||
? gradientHoverColors
|
||||
: gradientColors
|
||||
}
|
||||
locations={gradientLocations}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 1}}
|
||||
style={[a.absolute, a.inset_0]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<Context.Provider value={context}>
|
||||
{typeof children === 'function' ? children(context) : children}
|
||||
</Context.Provider>
|
||||
</Pressable>
|
||||
)
|
||||
},
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export function useSharedButtonTextStyles() {
|
||||
const t = useTheme()
|
||||
const {color, variant, disabled, size} = useButtonContext()
|
||||
return React.useMemo(() => {
|
||||
const baseStyles: TextStyle[] = []
|
||||
const light = t.name === 'light'
|
||||
|
||||
if (color === 'primary') {
|
||||
if (variant === 'solid') {
|
||||
@@ -428,7 +485,7 @@ export function useSharedButtonTextStyles() {
|
||||
} else if (variant === 'outline') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
color: light ? t.palette.primary_600 : t.palette.primary_500,
|
||||
color: t.palette.primary_600,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push({color: t.palette.primary_600, opacity: 0.5})
|
||||
@@ -472,6 +529,38 @@ export function useSharedButtonTextStyles() {
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (color === 'secondary_inverted') {
|
||||
if (variant === 'solid' || variant === 'gradient') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
color: t.palette.contrast_100,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push({
|
||||
color: t.palette.contrast_400,
|
||||
})
|
||||
}
|
||||
} else if (variant === 'outline') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
color: t.palette.contrast_600,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push({
|
||||
color: t.palette.contrast_300,
|
||||
})
|
||||
}
|
||||
} else if (variant === 'ghost') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
color: t.palette.contrast_600,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push({
|
||||
color: t.palette.contrast_300,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (color === 'negative') {
|
||||
if (variant === 'solid' || variant === 'gradient') {
|
||||
if (!disabled) {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import React, {useImperativeHandle} from 'react'
|
||||
import {Dimensions, Pressable, View} from 'react-native'
|
||||
import {
|
||||
Dimensions,
|
||||
Keyboard,
|
||||
Pressable,
|
||||
StyleProp,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import Animated, {useAnimatedStyle} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import BottomSheet, {
|
||||
@@ -25,6 +32,7 @@ import {
|
||||
DialogOuterProps,
|
||||
} from '#/components/Dialog/types'
|
||||
import {createInput} from '#/components/forms/TextField'
|
||||
import {FullWindowOverlay} from '#/components/FullWindowOverlay'
|
||||
import {Portal} from '#/components/Portal'
|
||||
|
||||
export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
|
||||
@@ -163,46 +171,49 @@ export function Outer({
|
||||
return (
|
||||
isOpen && (
|
||||
<Portal>
|
||||
<View
|
||||
// iOS
|
||||
accessibilityViewIsModal
|
||||
// Android
|
||||
importantForAccessibility="yes"
|
||||
style={[a.absolute, a.inset_0]}
|
||||
testID={testID}>
|
||||
<BottomSheet
|
||||
enableDynamicSizing={!hasSnapPoints}
|
||||
enablePanDownToClose
|
||||
keyboardBehavior="interactive"
|
||||
android_keyboardInputMode="adjustResize"
|
||||
keyboardBlurBehavior="restore"
|
||||
topInset={insets.top}
|
||||
{...sheetOptions}
|
||||
snapPoints={sheetOptions.snapPoints || ['100%']}
|
||||
ref={sheet}
|
||||
index={openIndex}
|
||||
backgroundStyle={{backgroundColor: 'transparent'}}
|
||||
backdropComponent={Backdrop}
|
||||
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
|
||||
handleStyle={{display: 'none'}}
|
||||
onClose={onCloseAnimationComplete}>
|
||||
<Context.Provider value={context}>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
t.atoms.bg,
|
||||
{
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
height: Dimensions.get('window').height * 2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{children}
|
||||
</Context.Provider>
|
||||
</BottomSheet>
|
||||
</View>
|
||||
<FullWindowOverlay>
|
||||
<View
|
||||
// iOS
|
||||
accessibilityViewIsModal
|
||||
// Android
|
||||
importantForAccessibility="yes"
|
||||
style={[a.absolute, a.inset_0]}
|
||||
testID={testID}
|
||||
onTouchMove={() => Keyboard.dismiss()}>
|
||||
<BottomSheet
|
||||
enableDynamicSizing={!hasSnapPoints}
|
||||
enablePanDownToClose
|
||||
keyboardBehavior="interactive"
|
||||
android_keyboardInputMode="adjustResize"
|
||||
keyboardBlurBehavior="restore"
|
||||
topInset={insets.top}
|
||||
{...sheetOptions}
|
||||
snapPoints={sheetOptions.snapPoints || ['100%']}
|
||||
ref={sheet}
|
||||
index={openIndex}
|
||||
backgroundStyle={{backgroundColor: 'transparent'}}
|
||||
backdropComponent={Backdrop}
|
||||
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
|
||||
handleStyle={{display: 'none'}}
|
||||
onClose={onCloseAnimationComplete}>
|
||||
<Context.Provider value={context}>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
t.atoms.bg,
|
||||
{
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
height: Dimensions.get('window').height * 2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{children}
|
||||
</Context.Provider>
|
||||
</BottomSheet>
|
||||
</View>
|
||||
</FullWindowOverlay>
|
||||
</Portal>
|
||||
)
|
||||
)
|
||||
@@ -257,9 +268,10 @@ export const ScrollableInner = React.forwardRef<
|
||||
|
||||
export const InnerFlatList = React.forwardRef<
|
||||
BottomSheetFlatListMethods,
|
||||
BottomSheetFlatListProps<any>
|
||||
BottomSheetFlatListProps<any> & {webInnerStyle?: StyleProp<ViewStyle>}
|
||||
>(function InnerFlatList({style, contentContainerStyle, ...props}, ref) {
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
return (
|
||||
<BottomSheetFlatList
|
||||
keyboardShouldPersistTaps="handled"
|
||||
|
||||
@@ -2,8 +2,10 @@ import React, {useImperativeHandle} from 'react'
|
||||
import {
|
||||
FlatList,
|
||||
FlatListProps,
|
||||
StyleProp,
|
||||
TouchableWithoutFeedback,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import Animated, {FadeIn, FadeInDown} from 'react-native-reanimated'
|
||||
import {msg} from '@lingui/macro'
|
||||
@@ -86,7 +88,10 @@ export function Outer({
|
||||
if (!isOpen) return
|
||||
|
||||
function handler(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') close()
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation()
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handler)
|
||||
@@ -199,18 +204,21 @@ export const ScrollableInner = Inner
|
||||
|
||||
export const InnerFlatList = React.forwardRef<
|
||||
FlatList,
|
||||
FlatListProps<any> & {label: string}
|
||||
>(function InnerFlatList({label, style, ...props}, ref) {
|
||||
FlatListProps<any> & {label: string} & {webInnerStyle?: StyleProp<ViewStyle>}
|
||||
>(function InnerFlatList({label, style, webInnerStyle, ...props}, ref) {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
return (
|
||||
<Inner
|
||||
label={label}
|
||||
// @ts-ignore web only -sfn
|
||||
style={{
|
||||
paddingHorizontal: 0,
|
||||
maxHeight: 'calc(-36px + 100vh)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
style={[
|
||||
// @ts-ignore web only -sfn
|
||||
{
|
||||
paddingHorizontal: 0,
|
||||
maxHeight: 'calc(-36px + 100vh)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
webInnerStyle,
|
||||
]}>
|
||||
<FlatList
|
||||
ref={ref}
|
||||
style={[gtMobile ? a.px_2xl : a.px_xl, flatten(style)]}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import React from 'react'
|
||||
import {GestureResponderEvent, View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyGraphDefs,
|
||||
AtUri,
|
||||
RichText as RichTextApi,
|
||||
} from '@atproto/api'
|
||||
import {msg, plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {
|
||||
useAddSavedFeedsMutation,
|
||||
usePreferencesQuery,
|
||||
useRemoveFeedMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {precacheFeedFromGeneratorView} from 'state/queries/feed'
|
||||
import {useSession} from 'state/session'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {RichText, RichTextProps} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
type Props = {
|
||||
view: AppBskyFeedDefs.GeneratorView
|
||||
}
|
||||
|
||||
export function Default(props: Props) {
|
||||
const {view} = props
|
||||
return (
|
||||
<Link {...props}>
|
||||
<Outer>
|
||||
<Header>
|
||||
<Avatar src={view.avatar} />
|
||||
<TitleAndByline title={view.displayName} creator={view.creator} />
|
||||
<SaveButton view={view} pin />
|
||||
</Header>
|
||||
<Description description={view.description} />
|
||||
<Likes count={view.likeCount || 0} />
|
||||
</Outer>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function Link({
|
||||
view,
|
||||
children,
|
||||
...props
|
||||
}: Props & Omit<LinkProps, 'to' | 'label'>) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const href = React.useMemo(() => {
|
||||
return createProfileFeedHref({feed: view})
|
||||
}, [view])
|
||||
|
||||
React.useEffect(() => {
|
||||
precacheFeedFromGeneratorView(queryClient, view)
|
||||
}, [view, queryClient])
|
||||
|
||||
return (
|
||||
<InternalLink
|
||||
label={view.displayName}
|
||||
to={href}
|
||||
style={[a.flex_col]}
|
||||
{...props}>
|
||||
{children}
|
||||
</InternalLink>
|
||||
)
|
||||
}
|
||||
|
||||
export function Outer({children}: {children: React.ReactNode}) {
|
||||
return <View style={[a.w_full, a.gap_md]}>{children}</View>
|
||||
}
|
||||
|
||||
export function Header({children}: {children: React.ReactNode}) {
|
||||
return <View style={[a.flex_row, a.align_center, a.gap_md]}>{children}</View>
|
||||
}
|
||||
|
||||
export type AvatarProps = {src: string | undefined; size?: number}
|
||||
|
||||
export function Avatar({src, size = 40}: AvatarProps) {
|
||||
return <UserAvatar type="algo" size={size} avatar={src} />
|
||||
}
|
||||
|
||||
export function AvatarPlaceholder({size = 40}: Omit<AvatarProps, 'src'>) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: 8,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function TitleAndByline({
|
||||
title,
|
||||
creator,
|
||||
}: {
|
||||
title: string
|
||||
creator?: AppBskyActorDefs.ProfileViewBasic
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1]}>
|
||||
<Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{creator && (
|
||||
<Text
|
||||
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}>
|
||||
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function TitleAndBylinePlaceholder({creator}: {creator?: boolean}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_50,
|
||||
{
|
||||
width: '60%',
|
||||
height: 14,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{creator && (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
width: '40%',
|
||||
height: 10,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Description({
|
||||
description,
|
||||
...rest
|
||||
}: {description?: string} & Partial<RichTextProps>) {
|
||||
const rt = React.useMemo(() => {
|
||||
if (!description) return
|
||||
const rt = new RichTextApi({text: description || ''})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
return rt
|
||||
}, [description])
|
||||
if (!rt) return null
|
||||
return <RichText value={rt} style={[a.leading_snug]} disableLinks {...rest} />
|
||||
}
|
||||
|
||||
export function DescriptionPlaceholder() {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View style={[a.gap_xs]}>
|
||||
<View
|
||||
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
|
||||
/>
|
||||
<View
|
||||
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
a.w_full,
|
||||
t.atoms.bg_contrast_50,
|
||||
{height: 12, width: 100},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Likes({count}: {count: number}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||
{plural(count || 0, {
|
||||
one: 'Liked by # user',
|
||||
other: 'Liked by # users',
|
||||
})}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export function SaveButton({
|
||||
view,
|
||||
pin,
|
||||
}: {
|
||||
view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
|
||||
pin?: boolean
|
||||
}) {
|
||||
const {hasSession} = useSession()
|
||||
if (!hasSession) return null
|
||||
return <SaveButtonInner view={view} pin={pin} />
|
||||
}
|
||||
|
||||
function SaveButtonInner({
|
||||
view,
|
||||
pin,
|
||||
}: {
|
||||
view: AppBskyFeedDefs.GeneratorView | AppBskyGraphDefs.ListView
|
||||
pin?: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} =
|
||||
useAddSavedFeedsMutation()
|
||||
const {isPending: isRemovePending, mutateAsync: removeFeed} =
|
||||
useRemoveFeedMutation()
|
||||
|
||||
const uri = view.uri
|
||||
const type = view.uri.includes('app.bsky.feed.generator') ? 'feed' : 'list'
|
||||
|
||||
const savedFeedConfig = React.useMemo(() => {
|
||||
return preferences?.savedFeeds?.find(feed => feed.value === uri)
|
||||
}, [preferences?.savedFeeds, uri])
|
||||
const removePromptControl = Prompt.usePromptControl()
|
||||
const isPending = isAddSavedFeedPending || isRemovePending
|
||||
|
||||
const toggleSave = React.useCallback(
|
||||
async (e: GestureResponderEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
try {
|
||||
if (savedFeedConfig) {
|
||||
await removeFeed(savedFeedConfig)
|
||||
} else {
|
||||
await saveFeeds([
|
||||
{
|
||||
type,
|
||||
value: uri,
|
||||
pinned: pin || false,
|
||||
},
|
||||
])
|
||||
}
|
||||
Toast.show(_(msg`Feeds updated!`))
|
||||
} catch (err: any) {
|
||||
logger.error(err, {context: `FeedCard: failed to update feeds`, pin})
|
||||
Toast.show(_(msg`Failed to update feeds`), 'xmark')
|
||||
}
|
||||
},
|
||||
[_, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type],
|
||||
)
|
||||
|
||||
const onPrompRemoveFeed = React.useCallback(
|
||||
async (e: GestureResponderEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
removePromptControl.open()
|
||||
},
|
||||
[removePromptControl],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
disabled={isPending}
|
||||
label={_(msg`Add this feed to your feeds`)}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="square"
|
||||
onPress={savedFeedConfig ? onPrompRemoveFeed : toggleSave}>
|
||||
{savedFeedConfig ? (
|
||||
<ButtonIcon size="md" icon={isPending ? Loader : Trash} />
|
||||
) : (
|
||||
<ButtonIcon size="md" icon={isPending ? Loader : Plus} />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Prompt.Basic
|
||||
control={removePromptControl}
|
||||
title={_(msg`Remove from my feeds?`)}
|
||||
description={_(
|
||||
msg`Are you sure you want to remove this from your feeds?`,
|
||||
)}
|
||||
onConfirm={toggleSave}
|
||||
confirmButtonCta={_(msg`Remove`)}
|
||||
confirmButtonColor="negative"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function createProfileFeedHref({
|
||||
feed,
|
||||
}: {
|
||||
feed: AppBskyFeedDefs.GeneratorView
|
||||
}) {
|
||||
const urip = new AtUri(feed.uri)
|
||||
const handleOrDid = feed.creator.handle || feed.creator.did
|
||||
return `/profile/${handleOrDid}/feed/${urip.rkey}`
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {ScrollView} from 'react-native-gesture-handler'
|
||||
import {AppBskyFeedDefs, AtUri} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
|
||||
import {useProfilesQuery} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useProgressGuide} from '#/state/shell/progress-guide'
|
||||
import * as userActionHistory from '#/state/userActionHistory'
|
||||
import {SeenPost} from '#/state/userActionHistory'
|
||||
import {atoms as a, useBreakpoints, useTheme, ViewStyleProp, web} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import * as FeedCard from '#/components/FeedCard'
|
||||
import {ArrowRight_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arrow'
|
||||
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
||||
import {PersonPlus_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ProgressGuideList} from './ProgressGuide/List'
|
||||
|
||||
const MOBILE_CARD_WIDTH = 300
|
||||
|
||||
function CardOuter({
|
||||
children,
|
||||
style,
|
||||
}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) {
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.p_lg,
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_low,
|
||||
!gtMobile && {
|
||||
width: MOBILE_CARD_WIDTH,
|
||||
},
|
||||
style,
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function SuggestedFollowPlaceholder() {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
|
||||
<ProfileCard.Header>
|
||||
<ProfileCard.AvatarPlaceholder />
|
||||
</ProfileCard.Header>
|
||||
|
||||
<View style={[a.py_xs]}>
|
||||
<ProfileCard.NameAndHandlePlaceholder />
|
||||
</View>
|
||||
|
||||
<ProfileCard.DescriptionPlaceholder />
|
||||
</CardOuter>
|
||||
)
|
||||
}
|
||||
|
||||
export function SuggestedFeedsCardPlaceholder() {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
|
||||
<FeedCard.Header>
|
||||
<FeedCard.AvatarPlaceholder />
|
||||
<FeedCard.TitleAndBylinePlaceholder creator />
|
||||
</FeedCard.Header>
|
||||
|
||||
<FeedCard.DescriptionPlaceholder />
|
||||
</CardOuter>
|
||||
)
|
||||
}
|
||||
|
||||
function getRank(seenPost: SeenPost): string {
|
||||
let tier: string
|
||||
if (seenPost.feedContext === 'popfriends') {
|
||||
tier = 'a'
|
||||
} else if (seenPost.feedContext?.startsWith('cluster')) {
|
||||
tier = 'b'
|
||||
} else if (seenPost.feedContext === 'popcluster') {
|
||||
tier = 'c'
|
||||
} else if (seenPost.feedContext?.startsWith('ntpc')) {
|
||||
tier = 'd'
|
||||
} else if (seenPost.feedContext?.startsWith('t-')) {
|
||||
tier = 'e'
|
||||
} else if (seenPost.feedContext === 'nettop') {
|
||||
tier = 'f'
|
||||
} else {
|
||||
tier = 'g'
|
||||
}
|
||||
let score = Math.round(
|
||||
Math.log(
|
||||
1 + seenPost.likeCount + seenPost.repostCount + seenPost.replyCount,
|
||||
),
|
||||
)
|
||||
if (seenPost.isFollowedBy || Math.random() > 0.9) {
|
||||
score *= 2
|
||||
}
|
||||
const rank = 100 - score
|
||||
return `${tier}-${rank}`
|
||||
}
|
||||
|
||||
function sortSeenPosts(postA: SeenPost, postB: SeenPost): 0 | 1 | -1 {
|
||||
const rankA = getRank(postA)
|
||||
const rankB = getRank(postB)
|
||||
// Yes, we're comparing strings here.
|
||||
// The "larger" string means a worse rank.
|
||||
if (rankA > rankB) {
|
||||
return 1
|
||||
} else if (rankA < rankB) {
|
||||
return -1
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function useExperimentalSuggestedUsersQuery() {
|
||||
const {currentAccount} = useSession()
|
||||
const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
|
||||
const dids = React.useMemo(() => {
|
||||
const {likes, follows, followSuggestions, seen} = userActionSnapshot
|
||||
const likeDids = likes
|
||||
.map(l => new AtUri(l))
|
||||
.map(uri => uri.host)
|
||||
.filter(did => !follows.includes(did))
|
||||
let suggestedDids: string[] = []
|
||||
if (followSuggestions.length > 0) {
|
||||
suggestedDids = [
|
||||
// It's ok if these will pick the same item (weighed by its frequency)
|
||||
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
|
||||
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
|
||||
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
|
||||
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
|
||||
]
|
||||
}
|
||||
const seenDids = seen
|
||||
.sort(sortSeenPosts)
|
||||
.map(l => new AtUri(l.uri))
|
||||
.map(uri => uri.host)
|
||||
return [...new Set([...suggestedDids, ...likeDids, ...seenDids])].filter(
|
||||
did => did !== currentAccount?.did,
|
||||
)
|
||||
}, [userActionSnapshot, currentAccount])
|
||||
const {data, isLoading, error} = useProfilesQuery({
|
||||
handles: dids.slice(0, 16),
|
||||
})
|
||||
|
||||
const profiles = data
|
||||
? data.profiles.filter(profile => {
|
||||
return !profile.viewer?.following
|
||||
})
|
||||
: []
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
error,
|
||||
profiles: profiles.slice(0, 6),
|
||||
}
|
||||
}
|
||||
|
||||
export function SuggestedFollows() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {
|
||||
isLoading: isSuggestionsLoading,
|
||||
profiles,
|
||||
error,
|
||||
} = useExperimentalSuggestedUsersQuery()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const isLoading = isSuggestionsLoading || !moderationOpts
|
||||
const maxLength = gtMobile ? 4 : 6
|
||||
|
||||
const content = isLoading ? (
|
||||
Array(maxLength)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={[gtMobile && web([a.flex_0, {width: 'calc(50% - 6px)'}])]}>
|
||||
<SuggestedFollowPlaceholder />
|
||||
</View>
|
||||
))
|
||||
) : error || !profiles.length ? null : (
|
||||
<>
|
||||
{profiles.slice(0, maxLength).map(profile => (
|
||||
<ProfileCard.Link
|
||||
key={profile.did}
|
||||
profile={profile}
|
||||
onPress={() => {
|
||||
logEvent('feed:interstitial:profileCard:press', {})
|
||||
}}
|
||||
style={[
|
||||
a.flex_1,
|
||||
gtMobile && web([a.flex_0, {width: 'calc(50% - 6px)'}]),
|
||||
]}>
|
||||
{({hovered, pressed}) => (
|
||||
<CardOuter
|
||||
style={[
|
||||
a.flex_1,
|
||||
(hovered || pressed) && t.atoms.border_contrast_high,
|
||||
]}>
|
||||
<ProfileCard.Outer>
|
||||
<ProfileCard.Header>
|
||||
<ProfileCard.Avatar
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
<ProfileCard.NameAndHandle
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
<ProfileCard.FollowButton
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
logContext="FeedInterstitial"
|
||||
color="secondary_inverted"
|
||||
shape="round"
|
||||
/>
|
||||
</ProfileCard.Header>
|
||||
<ProfileCard.Description profile={profile} />
|
||||
</ProfileCard.Outer>
|
||||
</CardOuter>
|
||||
)}
|
||||
</ProfileCard.Link>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
if (error || (!isLoading && profiles.length < 4)) {
|
||||
logger.debug(`Not enough profiles to show suggested follows`)
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
|
||||
<View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}>
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_lg,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>Suggested for you</Trans>
|
||||
</Text>
|
||||
<Person fill={t.atoms.text_contrast_low.color} />
|
||||
</View>
|
||||
|
||||
{gtMobile ? (
|
||||
<View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}>
|
||||
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
|
||||
{content}
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_end,
|
||||
a.align_center,
|
||||
a.pt_xs,
|
||||
a.gap_md,
|
||||
]}>
|
||||
<InlineLinkText
|
||||
label={_(msg`Browse more suggestions`)}
|
||||
to="/search"
|
||||
style={[t.atoms.text_contrast_medium]}>
|
||||
<Trans>Browse more suggestions</Trans>
|
||||
</InlineLinkText>
|
||||
<Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
|
||||
decelerationRate="fast">
|
||||
<View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
|
||||
{content}
|
||||
|
||||
<Button
|
||||
label={_(msg`Browse more accounts on the Explore page`)}
|
||||
onPress={() => {
|
||||
navigation.navigate('SearchTab')
|
||||
}}>
|
||||
<CardOuter style={[a.flex_1, {borderWidth: 0}]}>
|
||||
<View style={[a.flex_1, a.justify_center]}>
|
||||
<View style={[a.flex_row, a.px_lg]}>
|
||||
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
|
||||
<Trans>Browse more suggestions on the Explore page</Trans>
|
||||
</Text>
|
||||
|
||||
<Arrow size="xl" />
|
||||
</View>
|
||||
</View>
|
||||
</CardOuter>
|
||||
</Button>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function SuggestedFeeds() {
|
||||
const numFeedsToDisplay = 3
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {data, isLoading, error} = useGetPopularFeedsQuery({
|
||||
limit: numFeedsToDisplay,
|
||||
})
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
const feeds = React.useMemo(() => {
|
||||
const items: AppBskyFeedDefs.GeneratorView[] = []
|
||||
|
||||
if (!data) return items
|
||||
|
||||
for (const page of data.pages) {
|
||||
for (const feed of page.feeds) {
|
||||
items.push(feed)
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}, [data])
|
||||
|
||||
const content = isLoading ? (
|
||||
Array(numFeedsToDisplay)
|
||||
.fill(0)
|
||||
.map((_, i) => <SuggestedFeedsCardPlaceholder key={i} />)
|
||||
) : error || !feeds ? null : (
|
||||
<>
|
||||
{feeds.slice(0, numFeedsToDisplay).map(feed => (
|
||||
<FeedCard.Link
|
||||
key={feed.uri}
|
||||
view={feed}
|
||||
onPress={() => {
|
||||
logEvent('feed:interstitial:feedCard:press', {})
|
||||
}}>
|
||||
{({hovered, pressed}) => (
|
||||
<CardOuter
|
||||
style={[
|
||||
a.flex_1,
|
||||
(hovered || pressed) && t.atoms.border_contrast_high,
|
||||
]}>
|
||||
<FeedCard.Outer>
|
||||
<FeedCard.Header>
|
||||
<FeedCard.Avatar src={feed.avatar} />
|
||||
<FeedCard.TitleAndByline
|
||||
title={feed.displayName}
|
||||
creator={feed.creator}
|
||||
/>
|
||||
</FeedCard.Header>
|
||||
<FeedCard.Description
|
||||
description={feed.description}
|
||||
numberOfLines={3}
|
||||
/>
|
||||
</FeedCard.Outer>
|
||||
</CardOuter>
|
||||
)}
|
||||
</FeedCard.Link>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
return error ? null : (
|
||||
<View
|
||||
style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
|
||||
<View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}>
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_lg,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>Some other feeds you might like</Trans>
|
||||
</Text>
|
||||
<Hashtag fill={t.atoms.text_contrast_low.color} />
|
||||
</View>
|
||||
|
||||
{gtMobile ? (
|
||||
<View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}>
|
||||
{content}
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_end,
|
||||
a.align_center,
|
||||
a.pt_xs,
|
||||
a.gap_md,
|
||||
]}>
|
||||
<InlineLinkText
|
||||
label={_(msg`Browse more suggestions`)}
|
||||
to="/search"
|
||||
style={[t.atoms.text_contrast_medium]}>
|
||||
<Trans>Browse more suggestions</Trans>
|
||||
</InlineLinkText>
|
||||
<Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
|
||||
decelerationRate="fast">
|
||||
<View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}>
|
||||
{content}
|
||||
|
||||
<Button
|
||||
label={_(msg`Browse more feeds on the Explore page`)}
|
||||
onPress={() => {
|
||||
navigation.navigate('SearchTab')
|
||||
}}
|
||||
style={[a.flex_col]}>
|
||||
<CardOuter style={[a.flex_1]}>
|
||||
<View style={[a.flex_1, a.justify_center]}>
|
||||
<View style={[a.flex_row, a.px_lg]}>
|
||||
<Text style={[a.pr_xl, a.flex_1, a.leading_snug]}>
|
||||
<Trans>Browse more suggestions on the Explore page</Trans>
|
||||
</Text>
|
||||
|
||||
<Arrow size="xl" />
|
||||
</View>
|
||||
</View>
|
||||
</CardOuter>
|
||||
</Button>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProgressGuide() {
|
||||
const t = useTheme()
|
||||
const {isDesktop} = useWebMediaQueries()
|
||||
const guide = useProgressGuide('like-10-and-follow-7')
|
||||
|
||||
if (isDesktop) {
|
||||
return null
|
||||
}
|
||||
|
||||
return guide ? (
|
||||
<View
|
||||
style={[
|
||||
a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
a.px_lg,
|
||||
a.py_lg,
|
||||
a.pb_lg,
|
||||
]}>
|
||||
<ProgressGuideList />
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {FullWindowOverlay} from 'react-native-screens'
|
||||
@@ -0,0 +1 @@
|
||||
export {Fragment as FullWindowOverlay} from 'react'
|
||||
@@ -2,14 +2,14 @@ import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {
|
||||
useTheme,
|
||||
atoms as a,
|
||||
ViewStyleProp,
|
||||
TextStyleProp,
|
||||
flatten,
|
||||
TextStyleProp,
|
||||
useTheme,
|
||||
ViewStyleProp,
|
||||
} from '#/alf'
|
||||
import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
|
||||
import {Props} from '#/components/icons/common'
|
||||
import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
|
||||
|
||||
export function IconCircle({
|
||||
icon: Icon,
|
||||
@@ -32,8 +32,7 @@ export function IconCircle({
|
||||
{
|
||||
width: size === 'lg' ? 52 : 64,
|
||||
height: size === 'lg' ? 52 : 64,
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950,
|
||||
backgroundColor: t.palette.primary_50,
|
||||
},
|
||||
flatten(style),
|
||||
]}>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react'
|
||||
import {useKeyboardHandler} from 'react-native-keyboard-controller'
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
} from 'react-native-reanimated'
|
||||
|
||||
export function KeyboardControllerPadding({maxHeight}: {maxHeight?: number}) {
|
||||
const keyboardHeight = useSharedValue(0)
|
||||
|
||||
useKeyboardHandler(
|
||||
{
|
||||
onMove: e => {
|
||||
'worklet'
|
||||
|
||||
if (maxHeight && e.height > maxHeight) {
|
||||
keyboardHeight.value = maxHeight
|
||||
} else {
|
||||
keyboardHeight.value = e.height
|
||||
}
|
||||
},
|
||||
},
|
||||
[maxHeight],
|
||||
)
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
height: keyboardHeight.value,
|
||||
}))
|
||||
|
||||
return <Animated.View style={animatedStyle} />
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function KeyboardControllerPadding({
|
||||
maxHeight: _,
|
||||
}: {
|
||||
maxHeight?: number
|
||||
}) {
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
|
||||
import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Link, LinkProps} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const AVI_SIZE = 30
|
||||
const AVI_SIZE_SMALL = 20
|
||||
const AVI_BORDER = 1
|
||||
|
||||
/**
|
||||
* Shared logic to determine if `KnownFollowers` should be shown.
|
||||
*
|
||||
* Checks the # of actual returned users instead of the `count` value, because
|
||||
* `count` includes blocked users and `followers` does not.
|
||||
*/
|
||||
export function shouldShowKnownFollowers(
|
||||
knownFollowers?: AppBskyActorDefs.KnownFollowers,
|
||||
) {
|
||||
return knownFollowers && knownFollowers.followers.length > 0
|
||||
}
|
||||
|
||||
export function KnownFollowers({
|
||||
profile,
|
||||
moderationOpts,
|
||||
onLinkPress,
|
||||
minimal,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
moderationOpts: ModerationOpts
|
||||
onLinkPress?: LinkProps['onPress']
|
||||
minimal?: boolean
|
||||
}) {
|
||||
const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>(
|
||||
new Map(),
|
||||
)
|
||||
|
||||
/*
|
||||
* Results for `knownFollowers` are not sorted consistently, so when
|
||||
* revalidating we can see a flash of this data updating. This cache prevents
|
||||
* this happening for screens that remain in memory. When pushing a new
|
||||
* screen, or once this one is popped, this cache is empty, so new data is
|
||||
* displayed.
|
||||
*/
|
||||
if (profile.viewer?.knownFollowers && !cache.current.has(profile.did)) {
|
||||
cache.current.set(profile.did, profile.viewer.knownFollowers)
|
||||
}
|
||||
|
||||
const cachedKnownFollowers = cache.current.get(profile.did)
|
||||
|
||||
if (cachedKnownFollowers && shouldShowKnownFollowers(cachedKnownFollowers)) {
|
||||
return (
|
||||
<KnownFollowersInner
|
||||
profile={profile}
|
||||
cachedKnownFollowers={cachedKnownFollowers}
|
||||
moderationOpts={moderationOpts}
|
||||
onLinkPress={onLinkPress}
|
||||
minimal={minimal}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function KnownFollowersInner({
|
||||
profile,
|
||||
moderationOpts,
|
||||
cachedKnownFollowers,
|
||||
onLinkPress,
|
||||
minimal,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
moderationOpts: ModerationOpts
|
||||
cachedKnownFollowers: AppBskyActorDefs.KnownFollowers
|
||||
onLinkPress?: LinkProps['onPress']
|
||||
minimal?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const textStyle = [
|
||||
a.flex_1,
|
||||
a.text_sm,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]
|
||||
|
||||
const slice = cachedKnownFollowers.followers.slice(0, 3).map(f => {
|
||||
const moderation = moderateProfile(f, moderationOpts)
|
||||
return {
|
||||
profile: {
|
||||
...f,
|
||||
displayName: sanitizeDisplayName(
|
||||
f.displayName || f.handle,
|
||||
moderation.ui('displayName'),
|
||||
),
|
||||
},
|
||||
moderation,
|
||||
}
|
||||
})
|
||||
|
||||
// Does not have blocks applied. Always >= slices.length
|
||||
const serverCount = cachedKnownFollowers.count
|
||||
|
||||
/*
|
||||
* We check above too, but here for clarity and a reminder to _check for
|
||||
* valid indices_
|
||||
*/
|
||||
if (slice.length === 0) return null
|
||||
|
||||
const SIZE = minimal ? AVI_SIZE_SMALL : AVI_SIZE
|
||||
|
||||
return (
|
||||
<Link
|
||||
label={_(
|
||||
msg`Press to view followers of this account that you also follow`,
|
||||
)}
|
||||
onPress={onLinkPress}
|
||||
to={makeProfileLink(profile, 'known-followers')}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
minimal ? a.gap_sm : a.gap_md,
|
||||
a.align_center,
|
||||
{marginLeft: -AVI_BORDER},
|
||||
]}>
|
||||
{({hovered, pressed}) => (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
height: SIZE,
|
||||
width: SIZE + (slice.length - 1) * a.gap_md.gap,
|
||||
},
|
||||
pressed && {
|
||||
opacity: 0.5,
|
||||
},
|
||||
]}>
|
||||
{slice.map(({profile: prof, moderation}, i) => (
|
||||
<View
|
||||
key={prof.did}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.rounded_full,
|
||||
{
|
||||
borderWidth: AVI_BORDER,
|
||||
borderColor: t.atoms.bg.backgroundColor,
|
||||
width: SIZE + AVI_BORDER * 2,
|
||||
height: SIZE + AVI_BORDER * 2,
|
||||
left: i * a.gap_md.gap,
|
||||
zIndex: AVI_BORDER - i,
|
||||
},
|
||||
]}>
|
||||
<UserAvatar
|
||||
size={SIZE}
|
||||
avatar={prof.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
textStyle,
|
||||
hovered && {
|
||||
textDecorationLine: 'underline',
|
||||
textDecorationColor: t.atoms.text_contrast_medium.color,
|
||||
},
|
||||
pressed && {
|
||||
opacity: 0.5,
|
||||
},
|
||||
]}
|
||||
numberOfLines={2}>
|
||||
{slice.length >= 2 ? (
|
||||
// 2-n followers, including blocks
|
||||
serverCount > 2 ? (
|
||||
<Trans>
|
||||
Followed by{' '}
|
||||
<Text key={slice[0].profile.did} style={textStyle}>
|
||||
{slice[0].profile.displayName}
|
||||
</Text>
|
||||
,{' '}
|
||||
<Text key={slice[1].profile.did} style={textStyle}>
|
||||
{slice[1].profile.displayName}
|
||||
</Text>
|
||||
, and{' '}
|
||||
<Plural
|
||||
value={serverCount - 2}
|
||||
one="# other"
|
||||
other="# others"
|
||||
/>
|
||||
</Trans>
|
||||
) : (
|
||||
// only 2
|
||||
<Trans>
|
||||
Followed by{' '}
|
||||
<Text key={slice[0].profile.did} style={textStyle}>
|
||||
{slice[0].profile.displayName}
|
||||
</Text>{' '}
|
||||
and{' '}
|
||||
<Text key={slice[1].profile.did} style={textStyle}>
|
||||
{slice[1].profile.displayName}
|
||||
</Text>
|
||||
</Trans>
|
||||
)
|
||||
) : serverCount > 1 ? (
|
||||
// 1-n followers, including blocks
|
||||
<Trans>
|
||||
Followed by{' '}
|
||||
<Text key={slice[0].profile.did} style={textStyle}>
|
||||
{slice[0].profile.displayName}
|
||||
</Text>{' '}
|
||||
and{' '}
|
||||
<Plural
|
||||
value={serverCount - 1}
|
||||
one="# other"
|
||||
other="# others"
|
||||
/>
|
||||
</Trans>
|
||||
) : (
|
||||
// only 1
|
||||
<Trans>
|
||||
Followed by{' '}
|
||||
<Text key={slice[0].profile.did} style={textStyle}>
|
||||
{slice[0].profile.displayName}
|
||||
</Text>
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, ViewStyle} from 'react-native'
|
||||
import {LinearGradient} from 'expo-linear-gradient'
|
||||
|
||||
import {gradients} from '#/alf/tokens'
|
||||
|
||||
export function LinearGradientBackground({
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
style: StyleProp<ViewStyle>
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const gradient = gradients.sky.values.map(([_, color]) => {
|
||||
return color
|
||||
})
|
||||
|
||||
return (
|
||||
<LinearGradient colors={gradient} style={style}>
|
||||
{children}
|
||||
</LinearGradient>
|
||||
)
|
||||
}
|
||||
+55
-21
@@ -1,5 +1,10 @@
|
||||
import React from 'react'
|
||||
import {GestureResponderEvent} from 'react-native'
|
||||
import {
|
||||
GestureResponderEvent,
|
||||
Pressable,
|
||||
StyleProp,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import {sanitizeUrl} from '@braintree/sanitize-url'
|
||||
import {StackActions, useLinkProps} from '@react-navigation/native'
|
||||
|
||||
@@ -12,7 +17,8 @@ import {
|
||||
isExternalUrl,
|
||||
linkRequiresWarning,
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {shouldClickOpenNewTab} from '#/platform/urls'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useOpenLink} from '#/state/preferences/in-app-browser'
|
||||
import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
|
||||
@@ -34,11 +40,6 @@ type BaseLinkProps = Pick<
|
||||
> & {
|
||||
testID?: string
|
||||
|
||||
/**
|
||||
* Label for a11y. Defaults to the href.
|
||||
*/
|
||||
label?: string
|
||||
|
||||
/**
|
||||
* The React Navigation `StackAction` to perform when the link is pressed.
|
||||
*/
|
||||
@@ -116,16 +117,7 @@ export function useLink({
|
||||
if (isExternal) {
|
||||
openLink(href)
|
||||
} else {
|
||||
/**
|
||||
* A `GestureResponderEvent`, but cast to `any` to avoid using a bunch
|
||||
* of @ts-ignore below.
|
||||
*/
|
||||
const event = e as any
|
||||
const isMiddleClick = isWeb && event.button === 1
|
||||
const isMetaKey =
|
||||
isWeb &&
|
||||
(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey)
|
||||
const shouldOpenInNewTab = isMetaKey || isMiddleClick
|
||||
const shouldOpenInNewTab = shouldClickOpenNewTab(e)
|
||||
|
||||
if (isBskyDownloadUrl(href)) {
|
||||
shareUrl(BSKY_DOWNLOAD_URL)
|
||||
@@ -200,7 +192,7 @@ export function useLink({
|
||||
}
|
||||
|
||||
export type LinkProps = Omit<BaseLinkProps, 'disableMismatchWarning'> &
|
||||
Omit<ButtonProps, 'onPress' | 'disabled' | 'label'>
|
||||
Omit<ButtonProps, 'onPress' | 'disabled'>
|
||||
|
||||
/**
|
||||
* A interactive element that renders as a `<a>` tag on the web. On mobile it
|
||||
@@ -227,7 +219,6 @@ export function Link({
|
||||
|
||||
return (
|
||||
<Button
|
||||
label={href}
|
||||
{...rest}
|
||||
style={[a.justify_start, flatten(rest.style)]}
|
||||
role="link"
|
||||
@@ -252,7 +243,8 @@ export function Link({
|
||||
|
||||
export type InlineLinkProps = React.PropsWithChildren<
|
||||
BaseLinkProps & TextStyleProp & Pick<TextProps, 'selectable'>
|
||||
>
|
||||
> &
|
||||
Pick<ButtonProps, 'label'>
|
||||
|
||||
export function InlineLinkText({
|
||||
children,
|
||||
@@ -294,7 +286,7 @@ export function InlineLinkText({
|
||||
<Text
|
||||
selectable={selectable}
|
||||
accessibilityHint=""
|
||||
accessibilityLabel={label || href}
|
||||
accessibilityLabel={label}
|
||||
{...rest}
|
||||
style={[
|
||||
{color: t.palette.primary_500},
|
||||
@@ -331,3 +323,45 @@ export function InlineLinkText({
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A Pressable that uses useLink to handle navigation. It is unstyled, so can be used in cases where the Button styles
|
||||
* in Link are not desired.
|
||||
* @param displayText
|
||||
* @param style
|
||||
* @param children
|
||||
* @param rest
|
||||
* @constructor
|
||||
*/
|
||||
export function BaseLink({
|
||||
displayText,
|
||||
onPress: onPressOuter,
|
||||
style,
|
||||
children,
|
||||
...rest
|
||||
}: {
|
||||
style?: StyleProp<ViewStyle>
|
||||
children: React.ReactNode
|
||||
to: string
|
||||
action: 'push' | 'replace' | 'navigate'
|
||||
onPress?: () => false | void
|
||||
shareOnLongPress?: boolean
|
||||
label: string
|
||||
displayText?: string
|
||||
}) {
|
||||
const {onPress, ...btnProps} = useLink({
|
||||
displayText: displayText ?? rest.to,
|
||||
...rest,
|
||||
})
|
||||
return (
|
||||
<Pressable
|
||||
style={style}
|
||||
onPress={e => {
|
||||
onPressOuter?.()
|
||||
onPress(e)
|
||||
}}
|
||||
{...btnProps}>
|
||||
{children}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs, AppBskyGraphDefs, AtUri} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {precacheList} from 'state/queries/feed'
|
||||
import {useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {
|
||||
Avatar,
|
||||
Description,
|
||||
Header,
|
||||
Outer,
|
||||
SaveButton,
|
||||
} from '#/components/FeedCard'
|
||||
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
/*
|
||||
* This component is based on `FeedCard` and is tightly coupled with that
|
||||
* component. Please refer to `FeedCard` for more context.
|
||||
*/
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarPlaceholder,
|
||||
Description,
|
||||
Header,
|
||||
Outer,
|
||||
SaveButton,
|
||||
TitleAndBylinePlaceholder,
|
||||
} from '#/components/FeedCard'
|
||||
|
||||
const CURATELIST = 'app.bsky.graph.defs#curatelist'
|
||||
const MODLIST = 'app.bsky.graph.defs#modlist'
|
||||
|
||||
type Props = {
|
||||
view: AppBskyGraphDefs.ListView
|
||||
showPinButton?: boolean
|
||||
}
|
||||
|
||||
export function Default(props: Props) {
|
||||
const {view, showPinButton} = props
|
||||
return (
|
||||
<Link {...props}>
|
||||
<Outer>
|
||||
<Header>
|
||||
<Avatar src={view.avatar} />
|
||||
<TitleAndByline
|
||||
title={view.name}
|
||||
creator={view.creator}
|
||||
purpose={view.purpose}
|
||||
/>
|
||||
{showPinButton && view.purpose === CURATELIST && (
|
||||
<SaveButton view={view} pin />
|
||||
)}
|
||||
</Header>
|
||||
<Description description={view.description} />
|
||||
</Outer>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function Link({
|
||||
view,
|
||||
children,
|
||||
...props
|
||||
}: Props & Omit<LinkProps, 'to' | 'label'>) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const href = React.useMemo(() => {
|
||||
return createProfileListHref({list: view})
|
||||
}, [view])
|
||||
|
||||
React.useEffect(() => {
|
||||
precacheList(queryClient, view)
|
||||
}, [view, queryClient])
|
||||
|
||||
return (
|
||||
<InternalLink label={view.name} to={href} {...props}>
|
||||
{children}
|
||||
</InternalLink>
|
||||
)
|
||||
}
|
||||
|
||||
export function TitleAndByline({
|
||||
title,
|
||||
creator,
|
||||
purpose = CURATELIST,
|
||||
}: {
|
||||
title: string
|
||||
creator?: AppBskyActorDefs.ProfileViewBasic
|
||||
purpose?: AppBskyGraphDefs.ListView['purpose']
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1]}>
|
||||
<Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{creator && (
|
||||
<Text
|
||||
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}>
|
||||
{purpose === MODLIST ? (
|
||||
<Trans>
|
||||
Moderation list by {sanitizeHandle(creator.handle, '@')}
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>List by {sanitizeHandle(creator.handle, '@')}</Trans>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function createProfileListHref({
|
||||
list,
|
||||
}: {
|
||||
list: AppBskyGraphDefs.ListView
|
||||
}) {
|
||||
const urip = new AtUri(list.uri)
|
||||
const handleOrDid = list.creator.handle || list.creator.did
|
||||
return `/profile/${handleOrDid}/lists/${urip.rkey}`
|
||||
}
|
||||
@@ -13,16 +13,22 @@ import {Text} from '#/components/Typography'
|
||||
|
||||
export function ListFooter({
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
error,
|
||||
onRetry,
|
||||
height,
|
||||
style,
|
||||
showEndMessage = false,
|
||||
endMessageText,
|
||||
}: {
|
||||
isFetchingNextPage?: boolean
|
||||
hasNextPage?: boolean
|
||||
error?: string
|
||||
onRetry?: () => Promise<unknown>
|
||||
height?: number
|
||||
style?: StyleProp<ViewStyle>
|
||||
showEndMessage?: boolean
|
||||
endMessageText?: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
@@ -39,9 +45,13 @@ export function ListFooter({
|
||||
]}>
|
||||
{isFetchingNextPage ? (
|
||||
<Loader size="xl" />
|
||||
) : (
|
||||
) : error ? (
|
||||
<ListFooterMaybeError error={error} onRetry={onRetry} />
|
||||
)}
|
||||
) : !hasNextPage && showEndMessage ? (
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_low]}>
|
||||
{endMessageText ?? <Trans>You have reached the end</Trans>}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -112,8 +122,16 @@ export function ListHeaderDesktop({
|
||||
if (!gtTablet) return null
|
||||
|
||||
return (
|
||||
<View style={[a.w_full, a.py_lg, a.px_xl, a.gap_xs]}>
|
||||
<Text style={[a.text_3xl, a.font_bold]}>{title}</Text>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.py_sm,
|
||||
a.px_xl,
|
||||
a.gap_xs,
|
||||
a.justify_center,
|
||||
{minHeight: 50},
|
||||
]}>
|
||||
<Text style={[a.text_2xl, a.font_bold]}>{title}</Text>
|
||||
{subtitle ? (
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
{subtitle}
|
||||
@@ -179,7 +197,7 @@ let ListMaybePlaceholder = ({
|
||||
return (
|
||||
<Error
|
||||
title={errorTitle ?? _(msg`Oops!`)}
|
||||
message={errorMessage ?? _(`Something went wrong!`)}
|
||||
message={errorMessage ?? _(msg`Something went wrong!`)}
|
||||
onRetry={onRetry}
|
||||
onGoBack={onGoBack}
|
||||
sideBorders={sideBorders}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {differenceInSeconds} from 'date-fns'
|
||||
|
||||
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {HITSLOP_10} from 'lib/constants'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {useSession} from 'state/session'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {Newskie} from '#/components/icons/Newskie'
|
||||
import * as StarterPackCard from '#/components/StarterPack/StarterPackCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function NewskieDialog({
|
||||
profile,
|
||||
disabled,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const {currentAccount} = useSession()
|
||||
const timeAgo = useGetTimeAgo()
|
||||
const control = useDialogControl()
|
||||
|
||||
const isMe = profile.did === currentAccount?.did
|
||||
const createdAt = profile.createdAt as string | undefined
|
||||
|
||||
const profileName = React.useMemo(() => {
|
||||
const name = profile.displayName || profile.handle
|
||||
|
||||
if (isMe) {
|
||||
return _(msg`You`)
|
||||
}
|
||||
|
||||
if (!moderationOpts) return name
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
|
||||
return sanitizeDisplayName(name, moderation.ui('displayName'))
|
||||
}, [_, isMe, moderationOpts, profile])
|
||||
|
||||
const [now] = React.useState(() => Date.now())
|
||||
const daysOld = React.useMemo(() => {
|
||||
if (!createdAt) return Infinity
|
||||
return differenceInSeconds(now, new Date(createdAt)) / 86400
|
||||
}, [createdAt, now])
|
||||
|
||||
if (!createdAt || daysOld > 7) return null
|
||||
|
||||
return (
|
||||
<View style={[a.pr_2xs]}>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
label={_(
|
||||
msg`This user is new here. Press for more info about when they joined.`,
|
||||
)}
|
||||
hitSlop={HITSLOP_10}
|
||||
onPress={control.open}>
|
||||
{({hovered, pressed}) => (
|
||||
<Newskie
|
||||
size="lg"
|
||||
fill="#FFC404"
|
||||
style={{
|
||||
opacity: hovered || pressed ? 0.5 : 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`New user info dialog`)}
|
||||
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
|
||||
<View style={[a.gap_md]}>
|
||||
<View style={[a.align_center]}>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
height: 60,
|
||||
width: 64,
|
||||
},
|
||||
]}>
|
||||
<Newskie
|
||||
width={64}
|
||||
height={64}
|
||||
fill="#FFC404"
|
||||
style={[a.absolute, a.inset_0]}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[a.font_bold, a.text_xl]}>
|
||||
{isMe ? (
|
||||
<Trans>Welcome, friend!</Trans>
|
||||
) : (
|
||||
<Trans>Say hello!</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[a.text_md, a.text_center, a.leading_snug]}>
|
||||
{profile.joinedViaStarterPack ? (
|
||||
<Trans>
|
||||
{profileName} joined Bluesky using a starter pack{' '}
|
||||
{timeAgo(createdAt, now, {format: 'long'})} ago
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
{profileName} joined Bluesky{' '}
|
||||
{timeAgo(createdAt, now, {format: 'long'})} ago
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
{profile.joinedViaStarterPack ? (
|
||||
<StarterPackCard.Link
|
||||
starterPack={profile.joinedViaStarterPack}
|
||||
onPress={() => {
|
||||
control.close()
|
||||
}}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.mt_sm,
|
||||
a.p_lg,
|
||||
a.border,
|
||||
a.rounded_sm,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<StarterPackCard.Card
|
||||
starterPack={profile.joinedViaStarterPack}
|
||||
/>
|
||||
</View>
|
||||
</StarterPackCard.Link>
|
||||
) : null}
|
||||
|
||||
{isNative && (
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
style={[a.mt_sm]}
|
||||
onPress={() => control.close()}>
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {BSKY_LABELER_DID, ModerationCause} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {useModerationCauseDescription} from '#/lib/moderation/useModerationCauseDescription'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {
|
||||
ModerationDetailsDialog,
|
||||
useModerationDetailsDialogControl,
|
||||
} from '#/components/moderation/ModerationDetailsDialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export type CommonProps = {
|
||||
size?: 'sm' | 'lg'
|
||||
}
|
||||
|
||||
export function Row({
|
||||
children,
|
||||
style,
|
||||
size = 'sm',
|
||||
}: {children: React.ReactNode | React.ReactNode[]} & CommonProps &
|
||||
ViewStyleProp) {
|
||||
const styles = React.useMemo(() => {
|
||||
switch (size) {
|
||||
case 'lg':
|
||||
return [{gap: 5}]
|
||||
case 'sm':
|
||||
default:
|
||||
return [{gap: 3}]
|
||||
}
|
||||
}, [size])
|
||||
return (
|
||||
<View style={[a.flex_row, a.flex_wrap, a.gap_xs, styles, style]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export type LabelProps = {
|
||||
cause: ModerationCause
|
||||
disableDetailsDialog?: boolean
|
||||
noBg?: boolean
|
||||
} & CommonProps
|
||||
|
||||
export function Label({
|
||||
cause,
|
||||
size = 'sm',
|
||||
disableDetailsDialog,
|
||||
noBg,
|
||||
}: LabelProps) {
|
||||
const t = useTheme()
|
||||
const control = useModerationDetailsDialogControl()
|
||||
const desc = useModerationCauseDescription(cause)
|
||||
const isLabeler = Boolean(desc.sourceType && desc.sourceDid)
|
||||
const isBlueskyLabel =
|
||||
desc.sourceType === 'labeler' && desc.sourceDid === BSKY_LABELER_DID
|
||||
|
||||
const {outer, avi, text} = React.useMemo(() => {
|
||||
switch (size) {
|
||||
case 'lg': {
|
||||
return {
|
||||
outer: [
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
gap: 5,
|
||||
paddingHorizontal: 5,
|
||||
paddingVertical: 5,
|
||||
},
|
||||
],
|
||||
avi: 16,
|
||||
text: [a.text_sm],
|
||||
}
|
||||
}
|
||||
case 'sm':
|
||||
default: {
|
||||
return {
|
||||
outer: [
|
||||
!noBg && t.atoms.bg_contrast_25,
|
||||
{
|
||||
gap: 3,
|
||||
paddingHorizontal: 3,
|
||||
paddingVertical: 3,
|
||||
},
|
||||
],
|
||||
avi: 12,
|
||||
text: [a.text_xs],
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [t, size, noBg])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
disabled={disableDetailsDialog}
|
||||
label={desc.name}
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
control.open()
|
||||
}}>
|
||||
{({hovered, pressed}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.rounded_full,
|
||||
outer,
|
||||
(hovered || pressed) && t.atoms.bg_contrast_50,
|
||||
]}>
|
||||
{isBlueskyLabel || !isLabeler ? (
|
||||
<desc.icon
|
||||
width={avi}
|
||||
fill={t.atoms.text_contrast_medium.color}
|
||||
/>
|
||||
) : (
|
||||
<UserAvatar avatar={desc.sourceAvi} size={avi} />
|
||||
)}
|
||||
|
||||
<Text
|
||||
style={[
|
||||
text,
|
||||
a.font_semibold,
|
||||
a.leading_tight,
|
||||
t.atoms.text_contrast_medium,
|
||||
{paddingRight: 3},
|
||||
]}>
|
||||
{desc.name}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{!disableDetailsDialog && (
|
||||
<ModerationDetailsDialog control={control} modcause={cause} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function FollowsYou({size = 'sm'}: CommonProps) {
|
||||
const t = useTheme()
|
||||
|
||||
const variantStyles = React.useMemo(() => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
case 'lg':
|
||||
default:
|
||||
return [
|
||||
{
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 4,
|
||||
},
|
||||
]
|
||||
}
|
||||
}, [size])
|
||||
|
||||
return (
|
||||
<View style={[variantStyles, a.justify_center, t.atoms.bg_contrast_25]}>
|
||||
<Text style={[a.text_xs, a.leading_tight]}>
|
||||
<Trans>Follows You</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import React from 'react'
|
||||
import {GestureResponderEvent, View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
moderateProfile,
|
||||
ModerationOpts,
|
||||
RichText as RichTextApi,
|
||||
} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {LogEvents} from '#/lib/statsig/statsig'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||
import {useSession} from 'state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {ProfileCardPills} from 'view/com/profile/ProfileCard'
|
||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonProps, ButtonText} from '#/components/Button'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function Default({
|
||||
profile,
|
||||
moderationOpts,
|
||||
logContext = 'ProfileCard',
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
moderationOpts: ModerationOpts
|
||||
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
|
||||
}) {
|
||||
return (
|
||||
<Link profile={profile}>
|
||||
<Card
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
logContext={logContext}
|
||||
/>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function Card({
|
||||
profile,
|
||||
moderationOpts,
|
||||
logContext = 'ProfileCard',
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
moderationOpts: ModerationOpts
|
||||
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
|
||||
}) {
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
|
||||
return (
|
||||
<Outer>
|
||||
<Header>
|
||||
<Avatar profile={profile} moderationOpts={moderationOpts} />
|
||||
<NameAndHandle profile={profile} moderationOpts={moderationOpts} />
|
||||
<FollowButton
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
logContext={logContext}
|
||||
/>
|
||||
</Header>
|
||||
|
||||
<ProfileCardPills
|
||||
followedBy={Boolean(profile.viewer?.followedBy)}
|
||||
moderation={moderation}
|
||||
/>
|
||||
|
||||
<Description profile={profile} />
|
||||
</Outer>
|
||||
)
|
||||
}
|
||||
|
||||
export function Outer({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactElement | React.ReactElement[]
|
||||
}) {
|
||||
return <View style={[a.w_full, a.flex_1, a.gap_xs]}>{children}</View>
|
||||
}
|
||||
|
||||
export function Header({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactElement | React.ReactElement[]
|
||||
}) {
|
||||
return <View style={[a.flex_row, a.align_center, a.gap_sm]}>{children}</View>
|
||||
}
|
||||
|
||||
export function Link({
|
||||
profile,
|
||||
children,
|
||||
style,
|
||||
...rest
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
} & Omit<LinkProps, 'to' | 'label'>) {
|
||||
const {_} = useLingui()
|
||||
return (
|
||||
<InternalLink
|
||||
label={_(
|
||||
msg`View ${
|
||||
profile.displayName || sanitizeHandle(profile.handle)
|
||||
}'s profile`,
|
||||
)}
|
||||
to={{
|
||||
screen: 'Profile',
|
||||
params: {name: profile.did},
|
||||
}}
|
||||
style={[a.flex_col, style]}
|
||||
{...rest}>
|
||||
{children}
|
||||
</InternalLink>
|
||||
)
|
||||
}
|
||||
|
||||
export function Avatar({
|
||||
profile,
|
||||
moderationOpts,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
|
||||
return (
|
||||
<UserAvatar
|
||||
size={42}
|
||||
avatar={profile.avatar}
|
||||
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function AvatarPlaceholder() {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
t.atoms.bg_contrast_50,
|
||||
{
|
||||
width: 42,
|
||||
height: 42,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function NameAndHandle({
|
||||
profile,
|
||||
moderationOpts,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
const name = sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
)
|
||||
const handle = sanitizeHandle(profile.handle, '@')
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1]}>
|
||||
<Text
|
||||
style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]}
|
||||
numberOfLines={1}>
|
||||
{name}
|
||||
</Text>
|
||||
<Text
|
||||
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}>
|
||||
{handle}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function NameAndHandlePlaceholder() {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_50,
|
||||
{
|
||||
width: '60%',
|
||||
height: 14,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_50,
|
||||
{
|
||||
width: '40%',
|
||||
height: 10,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Description({
|
||||
profile: profileUnshadowed,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
}) {
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
const {description} = profile
|
||||
const rt = React.useMemo(() => {
|
||||
if (!description) return
|
||||
const rt = new RichTextApi({text: description || ''})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
return rt
|
||||
}, [description])
|
||||
if (!rt) return null
|
||||
if (
|
||||
profile.viewer &&
|
||||
(profile.viewer.blockedBy ||
|
||||
profile.viewer.blocking ||
|
||||
profile.viewer.blockingByList)
|
||||
)
|
||||
return null
|
||||
return (
|
||||
<View style={[a.pt_xs]}>
|
||||
<RichText
|
||||
value={rt}
|
||||
style={[a.leading_snug]}
|
||||
numberOfLines={3}
|
||||
disableLinks
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function DescriptionPlaceholder() {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View style={[a.gap_xs]}>
|
||||
<View
|
||||
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
|
||||
/>
|
||||
<View
|
||||
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
a.w_full,
|
||||
t.atoms.bg_contrast_50,
|
||||
{height: 12, width: 100},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export type FollowButtonProps = {
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
moderationOpts: ModerationOpts
|
||||
logContext: LogEvents['profile:follow']['logContext'] &
|
||||
LogEvents['profile:unfollow']['logContext']
|
||||
} & Partial<ButtonProps>
|
||||
|
||||
export function FollowButton(props: FollowButtonProps) {
|
||||
const {currentAccount, hasSession} = useSession()
|
||||
const isMe = props.profile.did === currentAccount?.did
|
||||
return hasSession && !isMe ? <FollowButtonInner {...props} /> : null
|
||||
}
|
||||
|
||||
export function FollowButtonInner({
|
||||
profile: profileUnshadowed,
|
||||
moderationOpts,
|
||||
logContext,
|
||||
...rest
|
||||
}: FollowButtonProps) {
|
||||
const {_} = useLingui()
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
|
||||
profile,
|
||||
logContext,
|
||||
)
|
||||
const isRound = Boolean(rest.shape && rest.shape === 'round')
|
||||
|
||||
const onPressFollow = async (e: GestureResponderEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
try {
|
||||
await queueFollow()
|
||||
Toast.show(
|
||||
_(
|
||||
msg`Following ${sanitizeDisplayName(
|
||||
profile.displayName || profile.handle,
|
||||
moderation.ui('displayName'),
|
||||
)}`,
|
||||
),
|
||||
)
|
||||
} catch (err: any) {
|
||||
if (err?.name !== 'AbortError') {
|
||||
Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onPressUnfollow = async (e: GestureResponderEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
try {
|
||||
await queueUnfollow()
|
||||
Toast.show(
|
||||
_(
|
||||
msg`No longer following ${sanitizeDisplayName(
|
||||
profile.displayName || profile.handle,
|
||||
moderation.ui('displayName'),
|
||||
)}`,
|
||||
),
|
||||
)
|
||||
} catch (err: any) {
|
||||
if (err?.name !== 'AbortError') {
|
||||
Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unfollowLabel = _(
|
||||
msg({
|
||||
message: 'Following',
|
||||
comment: 'User is following this account, click to unfollow',
|
||||
}),
|
||||
)
|
||||
const followLabel = _(
|
||||
msg({
|
||||
message: 'Follow',
|
||||
comment: 'User is not following this account, click to follow',
|
||||
}),
|
||||
)
|
||||
|
||||
if (!profile.viewer) return null
|
||||
if (
|
||||
profile.viewer.blockedBy ||
|
||||
profile.viewer.blocking ||
|
||||
profile.viewer.blockingByList
|
||||
)
|
||||
return null
|
||||
|
||||
return (
|
||||
<View>
|
||||
{profile.viewer.following ? (
|
||||
<Button
|
||||
label={unfollowLabel}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
{...rest}
|
||||
onPress={onPressUnfollow}>
|
||||
<ButtonIcon icon={Check} position={isRound ? undefined : 'left'} />
|
||||
{isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
label={followLabel}
|
||||
size="small"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
{...rest}
|
||||
onPress={onPressFollow}>
|
||||
<ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} />
|
||||
{isRound ? null : <ButtonText>{followLabel}</ButtonText>}
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -5,12 +5,14 @@ import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom'
|
||||
import {msg, plural} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {getModerationCauseKey} from '#/lib/moderation'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {usePrefetchProfileQuery, useProfileQuery} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import {isTouchDevice} from 'lib/browser'
|
||||
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||
import {formatCount} from '#/view/com/util/numeric/format'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
@@ -21,8 +23,13 @@ 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 {
|
||||
KnownFollowers,
|
||||
shouldShowKnownFollowers,
|
||||
} from '#/components/KnownFollowers'
|
||||
import {InlineLinkText, Link} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Pills from '#/components/Pills'
|
||||
import {Portal} from '#/components/Portal'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
@@ -43,10 +50,25 @@ const floatingMiddlewares = [
|
||||
}),
|
||||
]
|
||||
|
||||
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0
|
||||
|
||||
export function ProfileHoverCard(props: ProfileHoverCardProps) {
|
||||
return isTouchDevice ? props.children : <ProfileHoverCardInner {...props} />
|
||||
const prefetchProfileQuery = usePrefetchProfileQuery()
|
||||
const prefetchedProfile = React.useRef(false)
|
||||
const onPointerMove = () => {
|
||||
if (!prefetchedProfile.current) {
|
||||
prefetchedProfile.current = true
|
||||
prefetchProfileQuery(props.did)
|
||||
}
|
||||
}
|
||||
|
||||
if (props.disable || isTouchDevice) {
|
||||
return props.children
|
||||
} else {
|
||||
return (
|
||||
<View onPointerMove={onPointerMove} style={[a.flex_shrink]}>
|
||||
<ProfileHoverCardInner {...props} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type State =
|
||||
@@ -367,7 +389,10 @@ function Inner({
|
||||
profile: profileShadow,
|
||||
logContext: 'ProfileHoverCard',
|
||||
})
|
||||
const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy
|
||||
const isBlockedUser =
|
||||
profile.viewer?.blocking ||
|
||||
profile.viewer?.blockedBy ||
|
||||
profile.viewer?.blockingByList
|
||||
const following = formatCount(profile.followsCount || 0)
|
||||
const followers = formatCount(profile.followersCount || 0)
|
||||
const pluralizedFollowers = plural(profile.followersCount || 0, {
|
||||
@@ -398,45 +423,71 @@ function Inner({
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{!isMe && (
|
||||
<Button
|
||||
size="small"
|
||||
color={profileShadow.viewer?.following ? 'secondary' : 'primary'}
|
||||
variant="solid"
|
||||
label={
|
||||
profileShadow.viewer?.following
|
||||
? _(msg`Following`)
|
||||
: _(msg`Follow`)
|
||||
}
|
||||
style={[a.rounded_full]}
|
||||
onPress={profileShadow.viewer?.following ? unfollow : follow}>
|
||||
<ButtonIcon
|
||||
position="left"
|
||||
icon={profileShadow.viewer?.following ? Check : Plus}
|
||||
/>
|
||||
<ButtonText>
|
||||
{profileShadow.viewer?.following
|
||||
? _(msg`Following`)
|
||||
: _(msg`Follow`)}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
{!isMe &&
|
||||
(isBlockedUser ? (
|
||||
<Link
|
||||
to={profileURL}
|
||||
label={_(msg`View blocked user's profile`)}
|
||||
onPress={hide}
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
style={[a.rounded_full]}>
|
||||
<ButtonText>{_(msg`View profile`)}</ButtonText>
|
||||
</Link>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
color={profileShadow.viewer?.following ? 'secondary' : 'primary'}
|
||||
variant="solid"
|
||||
label={
|
||||
profileShadow.viewer?.following
|
||||
? _(msg`Following`)
|
||||
: _(msg`Follow`)
|
||||
}
|
||||
style={[a.rounded_full]}
|
||||
onPress={profileShadow.viewer?.following ? unfollow : follow}>
|
||||
<ButtonIcon
|
||||
position="left"
|
||||
icon={profileShadow.viewer?.following ? Check : Plus}
|
||||
/>
|
||||
<ButtonText>
|
||||
{profileShadow.viewer?.following
|
||||
? _(msg`Following`)
|
||||
: _(msg`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]}>
|
||||
<Text
|
||||
style={[a.pt_md, a.pb_xs, a.text_lg, a.font_bold, a.self_start]}>
|
||||
{sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<ProfileHeaderHandle profile={profileShadow} />
|
||||
<ProfileHeaderHandle profile={profileShadow} disableTaps />
|
||||
</View>
|
||||
</Link>
|
||||
|
||||
{!blockHide && (
|
||||
{isBlockedUser && (
|
||||
<View style={[a.flex_row, a.flex_wrap, a.gap_xs]}>
|
||||
{moderation.ui('profileView').alerts.map(cause => (
|
||||
<Pills.Label
|
||||
key={getModerationCauseKey(cause)}
|
||||
size="lg"
|
||||
cause={cause}
|
||||
disableDetailsDialog
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!isBlockedUser && (
|
||||
<>
|
||||
<View style={[a.flex_row, a.flex_wrap, a.gap_md, a.pt_xs]}>
|
||||
<InlineLinkText
|
||||
@@ -470,6 +521,17 @@ function Inner({
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
|
||||
{!isMe &&
|
||||
shouldShowKnownFollowers(profile.viewer?.knownFollowers) && (
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm, a.pt_md]}>
|
||||
<KnownFollowers
|
||||
profile={profile}
|
||||
moderationOpts={moderationOpts}
|
||||
onLinkPress={hide}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -4,4 +4,5 @@ export type ProfileHoverCardProps = {
|
||||
children: React.ReactElement
|
||||
did: string
|
||||
inline?: boolean
|
||||
disable?: boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {
|
||||
useProgressGuide,
|
||||
useProgressGuideControls,
|
||||
} from '#/state/shell/progress-guide'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ProgressGuideTask} from './Task'
|
||||
|
||||
export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const guide = useProgressGuide('like-10-and-follow-7')
|
||||
const {endProgressGuide} = useProgressGuideControls()
|
||||
|
||||
if (guide) {
|
||||
return (
|
||||
<View style={[a.flex_col, a.gap_md, style]}>
|
||||
<View style={[a.flex_row, a.align_center, a.justify_between]}>
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.font_semibold,
|
||||
a.text_sm,
|
||||
{textTransform: 'uppercase'},
|
||||
]}>
|
||||
<Trans>Getting started</Trans>
|
||||
</Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="tiny"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
label={_(msg`Dismiss getting started guide`)}
|
||||
onPress={endProgressGuide}>
|
||||
<ButtonIcon icon={Times} size="sm" />
|
||||
</Button>
|
||||
</View>
|
||||
<ProgressGuideTask
|
||||
current={guide.numLikes + 1}
|
||||
total={10 + 1}
|
||||
title={_(msg`Like 10 posts`)}
|
||||
subtitle={_(msg`Teach our algorithm what you like`)}
|
||||
/>
|
||||
<ProgressGuideTask
|
||||
current={guide.numFollows + 1}
|
||||
total={7 + 1}
|
||||
title={_(msg`Follow 7 accounts`)}
|
||||
subtitle={_(msg`Bluesky is better with friends!`)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import * as Progress from 'react-native-progress'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {AnimatedCheck} from '../anim/AnimatedCheck'
|
||||
import {Text} from '../Typography'
|
||||
|
||||
export function ProgressGuideTask({
|
||||
current,
|
||||
total,
|
||||
title,
|
||||
subtitle,
|
||||
}: {
|
||||
current: number
|
||||
total: number
|
||||
title: string
|
||||
subtitle?: string
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.flex_row, a.gap_sm, !subtitle && a.align_center]}>
|
||||
{current === total ? (
|
||||
<AnimatedCheck playOnMount fill={t.palette.primary_500} width={20} />
|
||||
) : (
|
||||
<Progress.Circle
|
||||
progress={current / total}
|
||||
color={t.palette.primary_400}
|
||||
size={20}
|
||||
thickness={3}
|
||||
borderWidth={0}
|
||||
unfilledColor={t.palette.contrast_50}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View style={[a.flex_col, a.gap_2xs, {marginTop: -2}]}>
|
||||
<Text style={[a.text_sm, a.font_semibold, a.leading_tight]}>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text
|
||||
style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_tight]}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import React, {useImperativeHandle} from 'react'
|
||||
import {Pressable, useWindowDimensions, View} from 'react-native'
|
||||
import Animated, {
|
||||
Easing,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Portal} from '#/components/Portal'
|
||||
import {AnimatedCheck, AnimatedCheckRef} from '../anim/AnimatedCheck'
|
||||
import {Text} from '../Typography'
|
||||
|
||||
export interface ProgressGuideToastRef {
|
||||
open(): void
|
||||
close(): void
|
||||
}
|
||||
|
||||
export interface ProgressGuideToastProps {
|
||||
title: string
|
||||
subtitle?: string
|
||||
visibleDuration?: number // default 5s
|
||||
}
|
||||
|
||||
export const ProgressGuideToast = React.forwardRef<
|
||||
ProgressGuideToastRef,
|
||||
ProgressGuideToastProps
|
||||
>(function ProgressGuideToast({title, subtitle, visibleDuration}, ref) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const insets = useSafeAreaInsets()
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
const translateY = useSharedValue(0)
|
||||
const opacity = useSharedValue(0)
|
||||
const animatedCheckRef = React.useRef<AnimatedCheckRef | null>(null)
|
||||
const timeoutRef = React.useRef<NodeJS.Timeout | undefined>()
|
||||
const winDim = useWindowDimensions()
|
||||
|
||||
/**
|
||||
* Methods
|
||||
*/
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
// clear the timeout, in case this was called imperatively
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = undefined
|
||||
}
|
||||
|
||||
// animate the opacity then set isOpen to false when done
|
||||
const setIsntOpen = () => setIsOpen(false)
|
||||
opacity.value = withTiming(
|
||||
0,
|
||||
{
|
||||
duration: 400,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
},
|
||||
() => runOnJS(setIsntOpen)(),
|
||||
)
|
||||
}, [setIsOpen, opacity])
|
||||
|
||||
const open = React.useCallback(() => {
|
||||
// set isOpen=true to render
|
||||
setIsOpen(true)
|
||||
|
||||
// animate the vertical translation, the opacity, and the checkmark
|
||||
const playCheckmark = () => animatedCheckRef.current?.play()
|
||||
opacity.value = 0
|
||||
opacity.value = withTiming(
|
||||
1,
|
||||
{
|
||||
duration: 100,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
},
|
||||
() => runOnJS(playCheckmark)(),
|
||||
)
|
||||
translateY.value = 0
|
||||
translateY.value = withTiming(insets.top + 10, {
|
||||
duration: 500,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
})
|
||||
|
||||
// start the countdown timer to autoclose
|
||||
timeoutRef.current = setTimeout(close, visibleDuration || 5e3)
|
||||
}, [setIsOpen, translateY, opacity, insets, close, visibleDuration])
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
open,
|
||||
close,
|
||||
}),
|
||||
[open, close],
|
||||
)
|
||||
|
||||
const containerStyle = React.useMemo(() => {
|
||||
let left = 10
|
||||
let right = 10
|
||||
if (isWeb && winDim.width > 400) {
|
||||
left = right = (winDim.width - 380) / 2
|
||||
}
|
||||
return {
|
||||
position: isWeb ? 'fixed' : 'absolute',
|
||||
top: 0,
|
||||
left,
|
||||
right,
|
||||
}
|
||||
}, [winDim.width])
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{translateY: translateY.value}],
|
||||
opacity: opacity.value,
|
||||
}))
|
||||
|
||||
return (
|
||||
isOpen && (
|
||||
<Portal>
|
||||
<Animated.View
|
||||
style={[
|
||||
// @ts-ignore position: fixed is web only
|
||||
containerStyle,
|
||||
animatedStyle,
|
||||
]}>
|
||||
<Pressable
|
||||
style={[
|
||||
t.atoms.bg,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_md,
|
||||
a.border,
|
||||
t.atoms.border_contrast_high,
|
||||
a.rounded_md,
|
||||
a.px_lg,
|
||||
a.py_md,
|
||||
a.shadow_sm,
|
||||
{
|
||||
shadowRadius: 8,
|
||||
shadowOpacity: 0.1,
|
||||
shadowOffset: {width: 0, height: 2},
|
||||
elevation: 8,
|
||||
},
|
||||
]}
|
||||
onPress={close}
|
||||
accessibilityLabel={_(msg`Tap to dismiss`)}
|
||||
accessibilityHint="">
|
||||
<AnimatedCheck
|
||||
fill={t.palette.primary_500}
|
||||
ref={animatedCheckRef}
|
||||
/>
|
||||
<View>
|
||||
<Text style={[a.text_md, a.font_semibold]}>{title}</Text>
|
||||
{subtitle && (
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
</Portal>
|
||||
)
|
||||
)
|
||||
})
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {GestureResponderEvent, View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonColor, ButtonText} from '#/components/Button'
|
||||
import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
@@ -136,7 +136,7 @@ export function Action({
|
||||
* 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
|
||||
onPress: ButtonProps['onPress']
|
||||
color?: ButtonColor
|
||||
/**
|
||||
* Optional i18n string. If undefined, it will default to "Confirm".
|
||||
@@ -147,9 +147,12 @@ export function Action({
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {close} = Dialog.useDialogContext()
|
||||
const handleOnPress = React.useCallback(() => {
|
||||
close(onPress)
|
||||
}, [close, onPress])
|
||||
const handleOnPress = React.useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
close(() => onPress?.(e))
|
||||
},
|
||||
[close, onPress],
|
||||
)
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -172,6 +175,7 @@ export function Basic({
|
||||
confirmButtonCta,
|
||||
onConfirm,
|
||||
confirmButtonColor,
|
||||
showCancel = true,
|
||||
}: React.PropsWithChildren<{
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
title: string
|
||||
@@ -185,8 +189,9 @@ export function Basic({
|
||||
* 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
|
||||
onConfirm: ButtonProps['onPress']
|
||||
confirmButtonColor?: ButtonColor
|
||||
showCancel?: boolean
|
||||
}>) {
|
||||
return (
|
||||
<Outer control={control} testID="confirmModal">
|
||||
@@ -199,7 +204,7 @@ export function Basic({
|
||||
color={confirmButtonColor}
|
||||
testID="confirmBtn"
|
||||
/>
|
||||
<Cancel cta={cancelButtonCta} />
|
||||
{showCancel && <Cancel cta={cancelButtonCta} />}
|
||||
</Actions>
|
||||
</Outer>
|
||||
)
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import React from 'react'
|
||||
import {View, ViewProps} from 'react-native'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from './Button'
|
||||
import {Text} from './Typography'
|
||||
|
||||
export function RadioGroup<T extends string | number>({
|
||||
value,
|
||||
onSelect,
|
||||
items,
|
||||
...props
|
||||
}: ViewProps & {
|
||||
value: T
|
||||
onSelect: (value: T) => void
|
||||
items: Array<{label: string; value: T}>
|
||||
}) {
|
||||
return (
|
||||
<View {...props}>
|
||||
{items.map(item => (
|
||||
<Button
|
||||
label={item.label}
|
||||
key={item.value}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="small"
|
||||
onPress={() => onSelect(item.value)}
|
||||
style={[a.justify_between, a.px_sm]}>
|
||||
<Text style={a.text_md}>{item.label}</Text>
|
||||
<RadioIcon selected={value === item.value} />
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioIcon({selected}: {selected: boolean}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderWidth: 2,
|
||||
borderColor: selected
|
||||
? t.palette.primary_500
|
||||
: t.palette.contrast_200,
|
||||
},
|
||||
selected
|
||||
? {
|
||||
backgroundColor:
|
||||
t.name === 'light'
|
||||
? t.palette.primary_100
|
||||
: t.palette.primary_900,
|
||||
}
|
||||
: t.atoms.bg,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.rounded_full,
|
||||
]}>
|
||||
{selected && (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
width: 18,
|
||||
height: 18,
|
||||
backgroundColor: t.palette.primary_500,
|
||||
},
|
||||
a.rounded_full,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -25,12 +25,10 @@ import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ReportDialogProps} from './types'
|
||||
|
||||
type ParamsWithMessages = ReportDialogProps['params'] | {type: 'message'}
|
||||
|
||||
export function SelectReportOptionView({
|
||||
...props
|
||||
}: {
|
||||
params: ParamsWithMessages
|
||||
params: ReportDialogProps['params']
|
||||
labelers: AppBskyLabelerDefs.LabelerViewDetailed[]
|
||||
onSelectReportOption: (reportOption: ReportOption) => void
|
||||
goBack: () => void
|
||||
@@ -57,7 +55,10 @@ export function SelectReportOptionView({
|
||||
} else if (props.params.type === 'feedgen') {
|
||||
title = _(msg`Report this feed`)
|
||||
description = _(msg`Why should this feed be reviewed?`)
|
||||
} else if (props.params.type === 'message') {
|
||||
} else if (props.params.type === 'starterpack') {
|
||||
title = _(msg`Report this starter pack`)
|
||||
description = _(msg`Why should this starter pack be reviewed?`)
|
||||
} else if (props.params.type === 'convoMessage') {
|
||||
title = _(msg`Report this message`)
|
||||
description = _(msg`Why should this message be reviewed?`)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function SubmitView({
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {getAgent} = useAgent()
|
||||
const agent = useAgent()
|
||||
const [details, setDetails] = React.useState<string>('')
|
||||
const [submitting, setSubmitting] = React.useState<boolean>(false)
|
||||
const [selectedServices, setSelectedServices] = React.useState<string[]>([
|
||||
@@ -60,15 +60,19 @@ export function SubmitView({
|
||||
reason: details,
|
||||
}
|
||||
const results = await Promise.all(
|
||||
selectedServices.map(did =>
|
||||
getAgent()
|
||||
.withProxy('atproto_labeler', did)
|
||||
.createModerationReport(report)
|
||||
selectedServices.map(did => {
|
||||
return agent
|
||||
.createModerationReport(report, {
|
||||
encoding: 'application/json',
|
||||
headers: {
|
||||
'atproto-proxy': `${did}#atproto_labeler`,
|
||||
},
|
||||
})
|
||||
.then(
|
||||
_ => true,
|
||||
_ => false,
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
setSubmitting(false)
|
||||
@@ -91,7 +95,7 @@ export function SubmitView({
|
||||
selectedServices,
|
||||
onSubmitComplete,
|
||||
setError,
|
||||
getAgent,
|
||||
agent,
|
||||
])
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,7 +4,7 @@ export type ReportDialogProps = {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
params:
|
||||
| {
|
||||
type: 'post' | 'list' | 'feedgen' | 'other'
|
||||
type: 'post' | 'list' | 'feedgen' | 'starterpack' | 'other'
|
||||
uri: string
|
||||
cid: string
|
||||
}
|
||||
@@ -12,4 +12,5 @@ export type ReportDialogProps = {
|
||||
type: 'account'
|
||||
did: string
|
||||
}
|
||||
| {type: 'convoMessage'}
|
||||
}
|
||||
|
||||
+26
-18
@@ -17,6 +17,19 @@ import {Text, TextProps} from '#/components/Typography'
|
||||
|
||||
const WORD_WRAP = {wordWrap: 1}
|
||||
|
||||
export type RichTextProps = TextStyleProp &
|
||||
Pick<TextProps, 'selectable'> & {
|
||||
value: RichTextAPI | string
|
||||
testID?: string
|
||||
numberOfLines?: number
|
||||
disableLinks?: boolean
|
||||
enableTags?: boolean
|
||||
authorHandle?: string
|
||||
onLinkPress?: LinkProps['onPress']
|
||||
interactiveStyle?: TextStyle
|
||||
emojiMultiplier?: number
|
||||
}
|
||||
|
||||
export function RichText({
|
||||
testID,
|
||||
value,
|
||||
@@ -28,17 +41,8 @@ export function RichText({
|
||||
authorHandle,
|
||||
onLinkPress,
|
||||
interactiveStyle,
|
||||
}: TextStyleProp &
|
||||
Pick<TextProps, 'selectable'> & {
|
||||
value: RichTextAPI | string
|
||||
testID?: string
|
||||
numberOfLines?: number
|
||||
disableLinks?: boolean
|
||||
enableTags?: boolean
|
||||
authorHandle?: string
|
||||
onLinkPress?: LinkProps['onPress']
|
||||
interactiveStyle?: TextStyle
|
||||
}) {
|
||||
emojiMultiplier = 1.85,
|
||||
}: RichTextProps) {
|
||||
const richText = React.useMemo(
|
||||
() =>
|
||||
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
|
||||
@@ -57,17 +61,14 @@ export function RichText({
|
||||
const {text, facets} = richText
|
||||
|
||||
if (!facets?.length) {
|
||||
if (text.length <= 5 && /^\p{Extended_Pictographic}+$/u.test(text)) {
|
||||
if (isOnlyEmoji(text)) {
|
||||
const fontSize =
|
||||
(flattenedStyle.fontSize ?? a.text_sm.fontSize) * emojiMultiplier
|
||||
return (
|
||||
<Text
|
||||
selectable={selectable}
|
||||
testID={testID}
|
||||
style={[
|
||||
{
|
||||
fontSize: 26,
|
||||
lineHeight: 30,
|
||||
},
|
||||
]}
|
||||
style={[plainStyles, {fontSize}]}
|
||||
// @ts-ignore web only -prf
|
||||
dataSet={WORD_WRAP}>
|
||||
{text}
|
||||
@@ -247,3 +248,10 @@ function RichTextTag({
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export function isOnlyEmoji(text: string) {
|
||||
return (
|
||||
text.length <= 15 &&
|
||||
/^[\p{Emoji_Presentation}\p{Extended_Pictographic}]+$/u.test(text)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {ListRenderItemInfo, View} from 'react-native'
|
||||
import {AppBskyFeedDefs} from '@atproto/api'
|
||||
import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
|
||||
|
||||
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
|
||||
import {isNative, isWeb} from 'platform/detection'
|
||||
import {List, ListRef} from 'view/com/util/List'
|
||||
import {SectionRef} from '#/screens/Profile/Sections/types'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import * as FeedCard from '#/components/FeedCard'
|
||||
|
||||
function keyExtractor(item: AppBskyFeedDefs.GeneratorView) {
|
||||
return item.uri
|
||||
}
|
||||
|
||||
interface ProfilesListProps {
|
||||
feeds: AppBskyFeedDefs.GeneratorView[]
|
||||
headerHeight: number
|
||||
scrollElRef: ListRef
|
||||
}
|
||||
|
||||
export const FeedsList = React.forwardRef<SectionRef, ProfilesListProps>(
|
||||
function FeedsListImpl({feeds, headerHeight, scrollElRef}, ref) {
|
||||
const [initialHeaderHeight] = React.useState(headerHeight)
|
||||
const bottomBarOffset = useBottomBarOffset(20)
|
||||
const t = useTheme()
|
||||
|
||||
const onScrollToTop = useCallback(() => {
|
||||
scrollElRef.current?.scrollToOffset({
|
||||
animated: isNative,
|
||||
offset: -headerHeight,
|
||||
})
|
||||
}, [scrollElRef, headerHeight])
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
scrollToTop: onScrollToTop,
|
||||
}))
|
||||
|
||||
const renderItem = ({item, index}: ListRenderItemInfo<GeneratorView>) => {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.p_lg,
|
||||
(isWeb || index !== 0) && a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<FeedCard.Default view={item} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<List
|
||||
data={feeds}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
ref={scrollElRef}
|
||||
headerOffset={headerHeight}
|
||||
ListFooterComponent={
|
||||
<View style={[{height: initialHeaderHeight + bottomBarOffset}]} />
|
||||
}
|
||||
showsVerticalScrollIndicator={false}
|
||||
desktopFixedHeight={true}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {Feed} from 'view/com/posts/Feed'
|
||||
import {EmptyState} from 'view/com/util/EmptyState'
|
||||
import {ListRef} from 'view/com/util/List'
|
||||
import {SectionRef} from '#/screens/Profile/Sections/types'
|
||||
|
||||
interface ProfilesListProps {
|
||||
listUri: string
|
||||
headerHeight: number
|
||||
scrollElRef: ListRef
|
||||
}
|
||||
|
||||
export const PostsList = React.forwardRef<SectionRef, ProfilesListProps>(
|
||||
function PostsListImpl({listUri, headerHeight, scrollElRef}, ref) {
|
||||
const feed: FeedDescriptor = `list|${listUri}|as_following`
|
||||
const {_} = useLingui()
|
||||
|
||||
const onScrollToTop = useCallback(() => {
|
||||
scrollElRef.current?.scrollToOffset({
|
||||
animated: isNative,
|
||||
offset: -headerHeight,
|
||||
})
|
||||
}, [scrollElRef, headerHeight])
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
scrollToTop: onScrollToTop,
|
||||
}))
|
||||
|
||||
const renderPostsEmpty = useCallback(() => {
|
||||
return <EmptyState icon="hashtag" message={_(msg`This feed is empty.`)} />
|
||||
}, [_])
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Feed
|
||||
feed={feed}
|
||||
pollInterval={60e3}
|
||||
scrollElRef={scrollElRef}
|
||||
renderEmptyState={renderPostsEmpty}
|
||||
headerOffset={headerHeight}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,134 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {ListRenderItemInfo, View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyGraphGetList,
|
||||
AtUri,
|
||||
ModerationOpts,
|
||||
} from '@atproto/api'
|
||||
import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
|
||||
|
||||
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
|
||||
import {isBlockedOrBlocking} from 'lib/moderation/blocked-and-muted'
|
||||
import {isNative, isWeb} from 'platform/detection'
|
||||
import {useListMembersQuery} from 'state/queries/list-members'
|
||||
import {useSession} from 'state/session'
|
||||
import {List, ListRef} from 'view/com/util/List'
|
||||
import {SectionRef} from '#/screens/Profile/Sections/types'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ListMaybePlaceholder} from '#/components/Lists'
|
||||
import {Default as ProfileCard} from '#/components/ProfileCard'
|
||||
|
||||
function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) {
|
||||
return `${item.did}-${index}`
|
||||
}
|
||||
|
||||
interface ProfilesListProps {
|
||||
listUri: string
|
||||
listMembersQuery: UseInfiniteQueryResult<
|
||||
InfiniteData<AppBskyGraphGetList.OutputSchema>
|
||||
>
|
||||
moderationOpts: ModerationOpts
|
||||
headerHeight: number
|
||||
scrollElRef: ListRef
|
||||
}
|
||||
|
||||
export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
|
||||
function ProfilesListImpl(
|
||||
{listUri, moderationOpts, headerHeight, scrollElRef},
|
||||
ref,
|
||||
) {
|
||||
const t = useTheme()
|
||||
const [initialHeaderHeight] = React.useState(headerHeight)
|
||||
const bottomBarOffset = useBottomBarOffset(20)
|
||||
const {currentAccount} = useSession()
|
||||
const {data, refetch, isError} = useListMembersQuery(listUri, 50)
|
||||
|
||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
|
||||
// The server returns these sorted by descending creation date, so we want to invert
|
||||
const profiles = data?.pages
|
||||
.flatMap(p => p.items.map(i => i.subject))
|
||||
.filter(p => !isBlockedOrBlocking(p) && !p.associated?.labeler)
|
||||
.reverse()
|
||||
const isOwn = new AtUri(listUri).host === currentAccount?.did
|
||||
|
||||
const getSortedProfiles = () => {
|
||||
if (!profiles) return
|
||||
if (!isOwn) return profiles
|
||||
|
||||
const myIndex = profiles.findIndex(p => p.did === currentAccount?.did)
|
||||
return myIndex !== -1
|
||||
? [
|
||||
profiles[myIndex],
|
||||
...profiles.slice(0, myIndex),
|
||||
...profiles.slice(myIndex + 1),
|
||||
]
|
||||
: profiles
|
||||
}
|
||||
const onScrollToTop = useCallback(() => {
|
||||
scrollElRef.current?.scrollToOffset({
|
||||
animated: isNative,
|
||||
offset: -headerHeight,
|
||||
})
|
||||
}, [scrollElRef, headerHeight])
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
scrollToTop: onScrollToTop,
|
||||
}))
|
||||
|
||||
const renderItem = ({
|
||||
item,
|
||||
index,
|
||||
}: ListRenderItemInfo<AppBskyActorDefs.ProfileViewBasic>) => {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.p_lg,
|
||||
t.atoms.border_contrast_low,
|
||||
(isWeb || index !== 0) && a.border_t,
|
||||
]}>
|
||||
<ProfileCard
|
||||
profile={item}
|
||||
moderationOpts={moderationOpts}
|
||||
logContext="StarterPackProfilesList"
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<View style={{marginTop: headerHeight, marginBottom: bottomBarOffset}}>
|
||||
<ListMaybePlaceholder
|
||||
isLoading={true}
|
||||
isError={isError}
|
||||
onRetry={refetch}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (data)
|
||||
return (
|
||||
<List
|
||||
data={getSortedProfiles()}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
ref={scrollElRef}
|
||||
headerOffset={headerHeight}
|
||||
ListFooterComponent={
|
||||
<View style={[{height: initialHeaderHeight + bottomBarOffset}]} />
|
||||
}
|
||||
showsVerticalScrollIndicator={false}
|
||||
desktopFixedHeight
|
||||
refreshing={isPTRing}
|
||||
onRefresh={async () => {
|
||||
setIsPTRing(true)
|
||||
await refetch()
|
||||
setIsPTRing(false)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,322 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
findNodeHandle,
|
||||
ListRenderItemInfo,
|
||||
StyleProp,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
import {AppBskyGraphDefs, AppBskyGraphGetActorStarterPacks} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {useGenerateStarterPackMutation} from 'lib/generate-starterpack'
|
||||
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {parseStarterPackUri} from 'lib/strings/starter-pack'
|
||||
import {List, ListRef} from 'view/com/util/List'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {LinearGradientBackground} from '#/components/LinearGradientBackground'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Default as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
|
||||
import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '../icons/Plus'
|
||||
|
||||
interface SectionRef {
|
||||
scrollToTop: () => void
|
||||
}
|
||||
|
||||
interface ProfileFeedgensProps {
|
||||
starterPacksQuery: UseInfiniteQueryResult<
|
||||
InfiniteData<AppBskyGraphGetActorStarterPacks.OutputSchema, unknown>,
|
||||
Error
|
||||
>
|
||||
scrollElRef: ListRef
|
||||
headerOffset: number
|
||||
enabled?: boolean
|
||||
style?: StyleProp<ViewStyle>
|
||||
testID?: string
|
||||
setScrollViewTag: (tag: number | null) => void
|
||||
isMe: boolean
|
||||
}
|
||||
|
||||
function keyExtractor(item: AppBskyGraphDefs.StarterPackView) {
|
||||
return item.uri
|
||||
}
|
||||
|
||||
export const ProfileStarterPacks = React.forwardRef<
|
||||
SectionRef,
|
||||
ProfileFeedgensProps
|
||||
>(function ProfileFeedgensImpl(
|
||||
{
|
||||
starterPacksQuery: query,
|
||||
scrollElRef,
|
||||
headerOffset,
|
||||
enabled,
|
||||
style,
|
||||
testID,
|
||||
setScrollViewTag,
|
||||
isMe,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const t = useTheme()
|
||||
const bottomBarOffset = useBottomBarOffset(100)
|
||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
const {data, refetch, isFetching, hasNextPage, fetchNextPage} = query
|
||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||
|
||||
const items = data?.pages.flatMap(page => page.starterPacks)
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
scrollToTop: () => {},
|
||||
}))
|
||||
|
||||
const onRefresh = React.useCallback(async () => {
|
||||
setIsPTRing(true)
|
||||
try {
|
||||
await refetch()
|
||||
} catch (err) {
|
||||
logger.error('Failed to refresh starter packs', {message: err})
|
||||
}
|
||||
setIsPTRing(false)
|
||||
}, [refetch, setIsPTRing])
|
||||
|
||||
const onEndReached = React.useCallback(async () => {
|
||||
if (isFetching || !hasNextPage) return
|
||||
|
||||
try {
|
||||
await fetchNextPage()
|
||||
} catch (err) {
|
||||
logger.error('Failed to load more starter packs', {message: err})
|
||||
}
|
||||
}, [isFetching, hasNextPage, fetchNextPage])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (enabled && scrollElRef.current) {
|
||||
const nativeTag = findNodeHandle(scrollElRef.current)
|
||||
setScrollViewTag(nativeTag)
|
||||
}
|
||||
}, [enabled, scrollElRef, setScrollViewTag])
|
||||
|
||||
const renderItem = ({
|
||||
item,
|
||||
index,
|
||||
}: ListRenderItemInfo<AppBskyGraphDefs.StarterPackView>) => {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.p_lg,
|
||||
(isTabletOrDesktop || index !== 0) && a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<StarterPackCard starterPack={item} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View testID={testID} style={style}>
|
||||
<List
|
||||
testID={testID ? `${testID}-flatlist` : undefined}
|
||||
ref={scrollElRef}
|
||||
data={items}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
refreshing={isPTRing}
|
||||
headerOffset={headerOffset}
|
||||
contentContainerStyle={{paddingBottom: headerOffset + bottomBarOffset}}
|
||||
indicatorStyle={t.name === 'light' ? 'black' : 'white'}
|
||||
removeClippedSubviews={true}
|
||||
desktopFixedHeight
|
||||
onEndReached={onEndReached}
|
||||
onRefresh={onRefresh}
|
||||
ListEmptyComponent={Empty}
|
||||
ListFooterComponent={
|
||||
items?.length !== 0 && isMe ? CreateAnother : undefined
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
function CreateAnother() {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.pr_md,
|
||||
a.pt_lg,
|
||||
a.gap_lg,
|
||||
a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<Button
|
||||
label={_(msg`Create a starter pack`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
style={[a.self_center]}
|
||||
onPress={() => navigation.navigate('StarterPackWizard')}>
|
||||
<ButtonText>
|
||||
<Trans>Create another</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={Plus} position="right" />
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const confirmDialogControl = useDialogControl()
|
||||
const followersDialogControl = useDialogControl()
|
||||
const errorDialogControl = useDialogControl()
|
||||
|
||||
const [isGenerating, setIsGenerating] = React.useState(false)
|
||||
|
||||
const {mutate: generateStarterPack} = useGenerateStarterPackMutation({
|
||||
onSuccess: ({uri}) => {
|
||||
const parsed = parseStarterPackUri(uri)
|
||||
if (parsed) {
|
||||
navigation.push('StarterPack', {
|
||||
name: parsed.name,
|
||||
rkey: parsed.rkey,
|
||||
})
|
||||
}
|
||||
setIsGenerating(false)
|
||||
},
|
||||
onError: e => {
|
||||
logger.error('Failed to generate starter pack', {safeMessage: e})
|
||||
setIsGenerating(false)
|
||||
if (e.name === 'NOT_ENOUGH_FOLLOWERS') {
|
||||
followersDialogControl.open()
|
||||
} else {
|
||||
errorDialogControl.open()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const generate = () => {
|
||||
setIsGenerating(true)
|
||||
generateStarterPack()
|
||||
}
|
||||
|
||||
return (
|
||||
<LinearGradientBackground
|
||||
style={[
|
||||
a.px_lg,
|
||||
a.py_lg,
|
||||
a.justify_between,
|
||||
a.gap_lg,
|
||||
a.shadow_lg,
|
||||
{marginTop: 2},
|
||||
]}>
|
||||
<View style={[a.gap_xs]}>
|
||||
<Text
|
||||
style={[
|
||||
a.font_bold,
|
||||
a.text_lg,
|
||||
t.atoms.text_contrast_medium,
|
||||
{color: 'white'},
|
||||
]}>
|
||||
<Trans>You haven't created a starter pack yet!</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md, {color: 'white'}]}>
|
||||
<Trans>
|
||||
Starter packs let you easily share your favorite feeds and people
|
||||
with your friends.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[a.flex_row, a.gap_md, {marginLeft: 'auto'}]}>
|
||||
<Button
|
||||
label={_(msg`Create a starter pack for me`)}
|
||||
variant="ghost"
|
||||
color="primary"
|
||||
size="small"
|
||||
disabled={isGenerating}
|
||||
onPress={confirmDialogControl.open}
|
||||
style={{backgroundColor: 'transparent'}}>
|
||||
<ButtonText style={{color: 'white'}}>
|
||||
<Trans>Make one for me</Trans>
|
||||
</ButtonText>
|
||||
{isGenerating && <Loader size="md" />}
|
||||
</Button>
|
||||
<Button
|
||||
label={_(msg`Create a starter pack`)}
|
||||
variant="ghost"
|
||||
color="primary"
|
||||
size="small"
|
||||
disabled={isGenerating}
|
||||
onPress={() => navigation.navigate('StarterPackWizard')}
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderColor: 'white',
|
||||
width: 100,
|
||||
}}
|
||||
hoverStyle={[{backgroundColor: '#dfdfdf'}]}>
|
||||
<ButtonText>
|
||||
<Trans>Create</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
<Prompt.Outer control={confirmDialogControl}>
|
||||
<Prompt.TitleText>
|
||||
<Trans>Generate a starter pack</Trans>
|
||||
</Prompt.TitleText>
|
||||
<Prompt.DescriptionText>
|
||||
<Trans>
|
||||
Bluesky will choose a set of recommended accounts from people in
|
||||
your network.
|
||||
</Trans>
|
||||
</Prompt.DescriptionText>
|
||||
<Prompt.Actions>
|
||||
<Prompt.Action
|
||||
color="primary"
|
||||
cta={_(msg`Choose for me`)}
|
||||
onPress={generate}
|
||||
/>
|
||||
<Prompt.Action
|
||||
color="secondary"
|
||||
cta={_(msg`Let me choose`)}
|
||||
onPress={() => {
|
||||
navigation.navigate('StarterPackWizard')
|
||||
}}
|
||||
/>
|
||||
</Prompt.Actions>
|
||||
</Prompt.Outer>
|
||||
<Prompt.Basic
|
||||
control={followersDialogControl}
|
||||
title={_(msg`Oops!`)}
|
||||
description={_(
|
||||
msg`You must be following at least seven other people to generate a starter pack.`,
|
||||
)}
|
||||
onConfirm={() => {}}
|
||||
showCancel={false}
|
||||
/>
|
||||
<Prompt.Basic
|
||||
control={errorDialogControl}
|
||||
title={_(msg`Oops!`)}
|
||||
description={_(
|
||||
msg`An error occurred while generating your starter pack. Want to try again?`,
|
||||
)}
|
||||
onConfirm={generate}
|
||||
confirmButtonCta={_(msg`Retry`)}
|
||||
/>
|
||||
</LinearGradientBackground>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import QRCode from 'react-native-qrcode-styled'
|
||||
import ViewShot from 'react-native-view-shot'
|
||||
import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {Logo} from 'view/icons/Logo'
|
||||
import {Logotype} from 'view/icons/Logotype'
|
||||
import {useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {LinearGradientBackground} from '#/components/LinearGradientBackground'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
interface Props {
|
||||
starterPack: AppBskyGraphDefs.StarterPackView
|
||||
link: string
|
||||
}
|
||||
|
||||
export const QrCode = React.forwardRef<ViewShot, Props>(function QrCode(
|
||||
{starterPack, link},
|
||||
ref,
|
||||
) {
|
||||
const {record} = starterPack
|
||||
|
||||
if (!AppBskyGraphStarterpack.isRecord(record)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ViewShot ref={ref}>
|
||||
<LinearGradientBackground
|
||||
style={[
|
||||
{width: 300, minHeight: 390},
|
||||
a.align_center,
|
||||
a.px_sm,
|
||||
a.py_xl,
|
||||
a.rounded_sm,
|
||||
a.justify_between,
|
||||
a.gap_md,
|
||||
]}>
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text
|
||||
style={[a.font_bold, a.text_3xl, a.text_center, {color: 'white'}]}>
|
||||
{record.name}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[a.gap_xl, a.align_center]}>
|
||||
<Text
|
||||
style={[
|
||||
a.font_bold,
|
||||
a.text_center,
|
||||
{color: 'white', fontSize: 18},
|
||||
]}>
|
||||
<Trans>Join the conversation</Trans>
|
||||
</Text>
|
||||
<View style={[a.rounded_sm, a.overflow_hidden]}>
|
||||
<QrCodeInner link={link} />
|
||||
</View>
|
||||
|
||||
<View style={[a.flex_row, a.align_center, {gap: 5}]}>
|
||||
<Text
|
||||
style={[
|
||||
a.font_bold,
|
||||
a.text_center,
|
||||
{color: 'white', fontSize: 18},
|
||||
]}>
|
||||
<Trans>on</Trans>
|
||||
</Text>
|
||||
<Logo width={26} fill="white" />
|
||||
<View style={[{marginTop: 5, marginLeft: 2.5}]}>
|
||||
<Logotype width={68} fill="white" />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradientBackground>
|
||||
</ViewShot>
|
||||
)
|
||||
})
|
||||
|
||||
export function QrCodeInner({link}: {link: string}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<QRCode
|
||||
data={link}
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
{height: 225, width: 225, backgroundColor: '#f3f3f3'},
|
||||
]}
|
||||
pieceSize={isWeb ? 8 : 6}
|
||||
padding={20}
|
||||
// pieceLiquidRadius={2}
|
||||
pieceBorderRadius={isWeb ? 4.5 : 3.5}
|
||||
outerEyesOptions={{
|
||||
topLeft: {
|
||||
borderRadius: [12, 12, 0, 12],
|
||||
color: t.palette.primary_500,
|
||||
},
|
||||
topRight: {
|
||||
borderRadius: [12, 12, 12, 0],
|
||||
color: t.palette.primary_500,
|
||||
},
|
||||
bottomLeft: {
|
||||
borderRadius: [12, 0, 12, 12],
|
||||
color: t.palette.primary_500,
|
||||
},
|
||||
}}
|
||||
innerEyesOptions={{borderRadius: 3}}
|
||||
logo={{
|
||||
href: require('../../../assets/logo.png'),
|
||||
scale: 0.95,
|
||||
padding: 2,
|
||||
hidePieces: true,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import ViewShot from 'react-native-view-shot'
|
||||
import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker'
|
||||
import {createAssetAsync} from 'expo-media-library'
|
||||
import * as Sharing from 'expo-sharing'
|
||||
import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {logEvent} from 'lib/statsig/statsig'
|
||||
import {isNative, isWeb} from 'platform/detection'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {DialogControlProps} from '#/components/Dialog'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {QrCode} from '#/components/StarterPack/QrCode'
|
||||
|
||||
export function QrCodeDialog({
|
||||
starterPack,
|
||||
link,
|
||||
control,
|
||||
}: {
|
||||
starterPack: AppBskyGraphDefs.StarterPackView
|
||||
link?: string
|
||||
control: DialogControlProps
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const [isProcessing, setIsProcessing] = React.useState(false)
|
||||
|
||||
const ref = React.useRef<ViewShot>(null)
|
||||
|
||||
const getCanvas = (base64: string): Promise<HTMLCanvasElement> => {
|
||||
return new Promise(resolve => {
|
||||
const image = new Image()
|
||||
image.onload = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = image.width
|
||||
canvas.height = image.height
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx?.drawImage(image, 0, 0)
|
||||
resolve(canvas)
|
||||
}
|
||||
image.src = base64
|
||||
})
|
||||
}
|
||||
|
||||
const onSavePress = async () => {
|
||||
ref.current?.capture?.().then(async (uri: string) => {
|
||||
if (isNative) {
|
||||
const res = await requestMediaLibraryPermissionsAsync()
|
||||
|
||||
if (!res) {
|
||||
Toast.show(
|
||||
_(
|
||||
msg`You must grant access to your photo library to save a QR code`,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Incase of a FS failure, don't crash the app
|
||||
try {
|
||||
await createAssetAsync(`file://${uri}`)
|
||||
} catch (e: unknown) {
|
||||
Toast.show(
|
||||
_(msg`An error occurred while saving the QR code!`),
|
||||
'xmark',
|
||||
)
|
||||
logger.error('Failed to save QR code', {error: e})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
setIsProcessing(true)
|
||||
|
||||
if (!AppBskyGraphStarterpack.isRecord(starterPack.record)) {
|
||||
return
|
||||
}
|
||||
|
||||
const canvas = await getCanvas(uri)
|
||||
const imgHref = canvas
|
||||
.toDataURL('image/png')
|
||||
.replace('image/png', 'image/octet-stream')
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.setAttribute(
|
||||
'download',
|
||||
`${starterPack.record.name.replaceAll(' ', '_')}_Share_Card.png`,
|
||||
)
|
||||
link.setAttribute('href', imgHref)
|
||||
link.click()
|
||||
}
|
||||
|
||||
logEvent('starterPack:share', {
|
||||
starterPack: starterPack.uri,
|
||||
shareType: 'qrcode',
|
||||
qrShareType: 'save',
|
||||
})
|
||||
setIsProcessing(false)
|
||||
Toast.show(
|
||||
isWeb
|
||||
? _(msg`QR code has been downloaded!`)
|
||||
: _(msg`QR code saved to your camera roll!`),
|
||||
)
|
||||
control.close()
|
||||
})
|
||||
}
|
||||
|
||||
const onCopyPress = async () => {
|
||||
setIsProcessing(true)
|
||||
ref.current?.capture?.().then(async (uri: string) => {
|
||||
const canvas = await getCanvas(uri)
|
||||
// @ts-expect-error web only
|
||||
canvas.toBlob((blob: Blob) => {
|
||||
const item = new ClipboardItem({'image/png': blob})
|
||||
navigator.clipboard.write([item])
|
||||
})
|
||||
|
||||
logEvent('starterPack:share', {
|
||||
starterPack: starterPack.uri,
|
||||
shareType: 'qrcode',
|
||||
qrShareType: 'copy',
|
||||
})
|
||||
Toast.show(_(msg`QR code copied to your clipboard!`))
|
||||
setIsProcessing(false)
|
||||
control.close()
|
||||
})
|
||||
}
|
||||
|
||||
const onSharePress = async () => {
|
||||
ref.current?.capture?.().then(async (uri: string) => {
|
||||
control.close(() => {
|
||||
Sharing.shareAsync(uri, {mimeType: 'image/png', UTI: 'image/png'}).then(
|
||||
() => {
|
||||
logEvent('starterPack:share', {
|
||||
starterPack: starterPack.uri,
|
||||
shareType: 'qrcode',
|
||||
qrShareType: 'share',
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Create a QR code for a starter pack`)}>
|
||||
<View style={[a.flex_1, a.align_center, a.gap_5xl]}>
|
||||
{!link ? (
|
||||
<View style={[a.align_center, a.p_xl]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<QrCode starterPack={starterPack} link={link} ref={ref} />
|
||||
{isProcessing ? (
|
||||
<View>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={[a.w_full, a.gap_md, isWeb && [a.flex_row_reverse]]}>
|
||||
<Button
|
||||
label={_(msg`Copy QR code`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
onPress={isWeb ? onCopyPress : onSharePress}>
|
||||
<ButtonText>
|
||||
{isWeb ? <Trans>Copy</Trans> : <Trans>Share</Trans>}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
label={_(msg`Save QR code`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
onPress={onSavePress}>
|
||||
<ButtonText>
|
||||
<Trans>Save</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker'
|
||||
import {AppBskyGraphDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {saveImageToMediaLibrary} from 'lib/media/manip'
|
||||
import {shareUrl} from 'lib/sharing'
|
||||
import {logEvent} from 'lib/statsig/statsig'
|
||||
import {getStarterPackOgCard} from 'lib/strings/starter-pack'
|
||||
import {isNative, isWeb} from 'platform/detection'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {DialogControlProps} from '#/components/Dialog'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
interface Props {
|
||||
starterPack: AppBskyGraphDefs.StarterPackView
|
||||
link?: string
|
||||
imageLoaded?: boolean
|
||||
qrDialogControl: DialogControlProps
|
||||
control: DialogControlProps
|
||||
}
|
||||
|
||||
export function ShareDialog(props: Props) {
|
||||
return (
|
||||
<Dialog.Outer control={props.control}>
|
||||
<ShareDialogInner {...props} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function ShareDialogInner({
|
||||
starterPack,
|
||||
link,
|
||||
imageLoaded,
|
||||
qrDialogControl,
|
||||
control,
|
||||
}: Props) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||
|
||||
const imageUrl = getStarterPackOgCard(starterPack)
|
||||
|
||||
const onShareLink = async () => {
|
||||
if (!link) return
|
||||
shareUrl(link)
|
||||
logEvent('starterPack:share', {
|
||||
starterPack: starterPack.uri,
|
||||
shareType: 'link',
|
||||
})
|
||||
control.close()
|
||||
}
|
||||
|
||||
const onSave = async () => {
|
||||
const res = await requestMediaLibraryPermissionsAsync()
|
||||
|
||||
if (!res) {
|
||||
Toast.show(
|
||||
_(msg`You must grant access to your photo library to save the image.`),
|
||||
'xmark',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await saveImageToMediaLibrary({uri: imageUrl})
|
||||
Toast.show(_(msg`Image saved to your camera roll!`))
|
||||
control.close()
|
||||
} catch (e: unknown) {
|
||||
Toast.show(_(msg`An error occurred while saving the QR code!`), 'xmark')
|
||||
logger.error('Failed to save QR code', {error: e})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog.Handle />
|
||||
<Dialog.ScrollableInner label={_(msg`Share link dialog`)}>
|
||||
{!imageLoaded || !link ? (
|
||||
<View style={[a.p_xl, a.align_center]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
) : (
|
||||
<View style={[!isTabletOrDesktop && a.gap_lg]}>
|
||||
<View style={[a.gap_sm, isTabletOrDesktop && a.pb_lg]}>
|
||||
<Text style={[a.font_bold, a.text_2xl]}>
|
||||
<Trans>Invite people to this starter pack!</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
Share this starter pack and help people join your community on
|
||||
Bluesky.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<Image
|
||||
source={{uri: imageUrl}}
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
{
|
||||
aspectRatio: 1200 / 630,
|
||||
transform: [{scale: isTabletOrDesktop ? 0.85 : 1}],
|
||||
marginTop: isTabletOrDesktop ? -20 : 0,
|
||||
},
|
||||
]}
|
||||
accessibilityIgnoresInvertColors={true}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.gap_md,
|
||||
isWeb && [a.gap_sm, a.flex_row_reverse, {marginLeft: 'auto'}],
|
||||
]}>
|
||||
<Button
|
||||
label={isWeb ? _(msg`Copy link`) : _(msg`Share link`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
style={[isWeb && a.self_center]}
|
||||
onPress={onShareLink}>
|
||||
<ButtonText>
|
||||
{isWeb ? <Trans>Copy Link</Trans> : <Trans>Share link</Trans>}
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
label={_(msg`Share QR code`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
style={[isWeb && a.self_center]}
|
||||
onPress={() => {
|
||||
control.close(() => {
|
||||
qrDialogControl.open()
|
||||
})
|
||||
}}>
|
||||
<ButtonText>
|
||||
<Trans>Share QR code</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
{isNative && (
|
||||
<Button
|
||||
label={_(msg`Save image`)}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="small"
|
||||
style={[isWeb && a.self_center]}
|
||||
onPress={onSave}>
|
||||
<ButtonText>
|
||||
<Trans>Save image</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Dialog.ScrollableInner>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {AppBskyGraphDefs, AppBskyGraphStarterpack, AtUri} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {getStarterPackOgCard} from 'lib/strings/starter-pack'
|
||||
import {precacheResolvedUri} from 'state/queries/resolve-uri'
|
||||
import {precacheStarterPack} from 'state/queries/starter-packs'
|
||||
import {useSession} from 'state/session'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {StarterPack} from '#/components/icons/StarterPack'
|
||||
import {BaseLink} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function Default({
|
||||
starterPack,
|
||||
}: {
|
||||
starterPack?: AppBskyGraphDefs.StarterPackViewBasic
|
||||
}) {
|
||||
if (!starterPack) return null
|
||||
return (
|
||||
<Link starterPack={starterPack}>
|
||||
<Card starterPack={starterPack} />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function Notification({
|
||||
starterPack,
|
||||
}: {
|
||||
starterPack?: AppBskyGraphDefs.StarterPackViewBasic
|
||||
}) {
|
||||
if (!starterPack) return null
|
||||
return (
|
||||
<Link starterPack={starterPack}>
|
||||
<Card starterPack={starterPack} noIcon={true} noDescription={true} />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function Card({
|
||||
starterPack,
|
||||
noIcon,
|
||||
noDescription,
|
||||
}: {
|
||||
starterPack: AppBskyGraphDefs.StarterPackViewBasic
|
||||
noIcon?: boolean
|
||||
noDescription?: boolean
|
||||
}) {
|
||||
const {record, creator, joinedAllTimeCount} = starterPack
|
||||
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
if (!AppBskyGraphStarterpack.isRecord(record)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1, a.gap_md]}>
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
{!noIcon ? <StarterPack width={40} gradient="sky" /> : null}
|
||||
<View>
|
||||
<Text style={[a.text_md, a.font_bold, a.leading_snug]}>
|
||||
{record.name}
|
||||
</Text>
|
||||
<Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
Starter pack by{' '}
|
||||
{creator?.did === currentAccount?.did
|
||||
? _(msg`you`)
|
||||
: `@${sanitizeHandle(creator.handle)}`}
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{!noDescription && record.description ? (
|
||||
<Text numberOfLines={3} style={[a.leading_snug]}>
|
||||
{record.description}
|
||||
</Text>
|
||||
) : null}
|
||||
{!!joinedAllTimeCount && joinedAllTimeCount >= 50 && (
|
||||
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
|
||||
{joinedAllTimeCount} users have joined!
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Link({
|
||||
starterPack,
|
||||
children,
|
||||
}: {
|
||||
starterPack: AppBskyGraphDefs.StarterPackViewBasic
|
||||
onPress?: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const queryClient = useQueryClient()
|
||||
const {record} = starterPack
|
||||
const {rkey, handleOrDid} = React.useMemo(() => {
|
||||
const rkey = new AtUri(starterPack.uri).rkey
|
||||
const {creator} = starterPack
|
||||
return {rkey, handleOrDid: creator.handle || creator.did}
|
||||
}, [starterPack])
|
||||
|
||||
if (!AppBskyGraphStarterpack.isRecord(record)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseLink
|
||||
action="push"
|
||||
to={`/starter-pack/${handleOrDid}/${rkey}`}
|
||||
label={_(msg`Navigate to ${record.name}`)}
|
||||
onPress={() => {
|
||||
precacheResolvedUri(
|
||||
queryClient,
|
||||
starterPack.creator.handle,
|
||||
starterPack.creator.did,
|
||||
)
|
||||
precacheStarterPack(queryClient, starterPack)
|
||||
}}>
|
||||
{children}
|
||||
</BaseLink>
|
||||
)
|
||||
}
|
||||
|
||||
export function Embed({
|
||||
starterPack,
|
||||
}: {
|
||||
starterPack: AppBskyGraphDefs.StarterPackViewBasic
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const imageUri = getStarterPackOgCard(starterPack)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.mt_xs,
|
||||
a.border,
|
||||
a.rounded_sm,
|
||||
a.overflow_hidden,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<Link starterPack={starterPack}>
|
||||
<Image
|
||||
source={imageUri}
|
||||
style={[a.w_full, {aspectRatio: 1.91}]}
|
||||
accessibilityIgnoresInvertColors={true}
|
||||
/>
|
||||
<View style={[a.px_sm, a.py_md]}>
|
||||
<Card starterPack={starterPack} />
|
||||
</View>
|
||||
</Link>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, ViewStyle} from 'react-native'
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
SlideInLeft,
|
||||
SlideInRight,
|
||||
} from 'react-native-reanimated'
|
||||
|
||||
import {isWeb} from 'platform/detection'
|
||||
|
||||
export function ScreenTransition({
|
||||
direction,
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
direction: 'Backward' | 'Forward'
|
||||
style?: StyleProp<ViewStyle>
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const entering = direction === 'Forward' ? SlideInRight : SlideInLeft
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={isWeb ? FadeIn.duration(90) : entering}
|
||||
exiting={FadeOut.duration(90)} // Totally vibes based
|
||||
style={style}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import React, {useRef} from 'react'
|
||||
import type {ListRenderItemInfo} from 'react-native'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs, ModerationOpts} from '@atproto/api'
|
||||
import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
|
||||
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {useSession} from 'state/session'
|
||||
import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
|
||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {
|
||||
WizardFeedCard,
|
||||
WizardProfileCard,
|
||||
} from '#/components/StarterPack/Wizard/WizardListCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
function keyExtractor(
|
||||
item: AppBskyActorDefs.ProfileViewBasic | GeneratorView,
|
||||
index: number,
|
||||
) {
|
||||
return `${item.did}-${index}`
|
||||
}
|
||||
|
||||
export function WizardEditListDialog({
|
||||
control,
|
||||
state,
|
||||
dispatch,
|
||||
moderationOpts,
|
||||
profile,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
state: WizardState
|
||||
dispatch: (action: WizardAction) => void
|
||||
moderationOpts: ModerationOpts
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const listRef = useRef<BottomSheetFlatListMethods>(null)
|
||||
|
||||
const getData = () => {
|
||||
if (state.currentStep === 'Feeds') return state.feeds
|
||||
|
||||
return [
|
||||
profile,
|
||||
...state.profiles.filter(p => p.did !== currentAccount?.did),
|
||||
]
|
||||
}
|
||||
|
||||
const renderItem = ({item}: ListRenderItemInfo<any>) =>
|
||||
state.currentStep === 'Profiles' ? (
|
||||
<WizardProfileCard
|
||||
profile={item}
|
||||
btnType="remove"
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
) : (
|
||||
<WizardFeedCard
|
||||
generator={item}
|
||||
btnType="remove"
|
||||
state={state}
|
||||
dispatch={dispatch}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
testID="newChatDialog"
|
||||
nativeOptions={{sheet: {snapPoints: ['95%']}}}>
|
||||
<Dialog.Handle />
|
||||
<Dialog.InnerFlatList
|
||||
ref={listRef}
|
||||
data={getData()}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
ListHeaderComponent={
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_between,
|
||||
a.border_b,
|
||||
a.px_sm,
|
||||
a.mb_sm,
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_medium,
|
||||
isWeb
|
||||
? [
|
||||
a.align_center,
|
||||
{
|
||||
height: 48,
|
||||
},
|
||||
]
|
||||
: [
|
||||
a.pb_sm,
|
||||
a.align_end,
|
||||
{
|
||||
height: 68,
|
||||
},
|
||||
],
|
||||
]}>
|
||||
<View style={{width: 60}} />
|
||||
<Text style={[a.font_bold, a.text_xl]}>
|
||||
{state.currentStep === 'Profiles' ? (
|
||||
<Trans>Edit People</Trans>
|
||||
) : (
|
||||
<Trans>Edit Feeds</Trans>
|
||||
)}
|
||||
</Text>
|
||||
<View style={{width: 60}}>
|
||||
{isWeb && (
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
variant="ghost"
|
||||
color="primary"
|
||||
size="xsmall"
|
||||
onPress={() => control.close()}>
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
}
|
||||
stickyHeaderIndices={[0]}
|
||||
style={[
|
||||
web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
|
||||
native({
|
||||
height: '100%',
|
||||
paddingHorizontal: 0,
|
||||
marginTop: 0,
|
||||
paddingTop: 0,
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
}),
|
||||
]}
|
||||
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
|
||||
keyboardDismissMode="on-drag"
|
||||
removeClippedSubviews={true}
|
||||
/>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import React from 'react'
|
||||
import {Keyboard, View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyFeedDefs,
|
||||
moderateFeedGenerator,
|
||||
moderateProfile,
|
||||
ModerationOpts,
|
||||
ModerationUI,
|
||||
} from '@atproto/api'
|
||||
import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {DISCOVER_FEED_URI} from 'lib/constants'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {useSession} from 'state/session'
|
||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Checkbox} from '#/components/forms/Toggle'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
function WizardListCard({
|
||||
type,
|
||||
btnType,
|
||||
displayName,
|
||||
subtitle,
|
||||
onPress,
|
||||
avatar,
|
||||
included,
|
||||
disabled,
|
||||
moderationUi,
|
||||
}: {
|
||||
type: 'user' | 'algo'
|
||||
btnType: 'checkbox' | 'remove'
|
||||
profile?: AppBskyActorDefs.ProfileViewBasic
|
||||
feed?: AppBskyFeedDefs.GeneratorView
|
||||
displayName: string
|
||||
subtitle: string
|
||||
onPress: () => void
|
||||
avatar?: string
|
||||
included?: boolean
|
||||
disabled?: boolean
|
||||
moderationUi: ModerationUI
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<Toggle.Item
|
||||
name={type === 'user' ? _(msg`Person toggle`) : _(msg`Feed toggle`)}
|
||||
label={
|
||||
included
|
||||
? _(msg`Remove ${displayName} from starter pack`)
|
||||
: _(msg`Add ${displayName} to starter pack`)
|
||||
}
|
||||
value={included}
|
||||
disabled={btnType === 'remove' || disabled}
|
||||
onChange={onPress}
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.px_lg,
|
||||
a.py_md,
|
||||
a.gap_md,
|
||||
a.border_b,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<UserAvatar
|
||||
size={45}
|
||||
avatar={avatar}
|
||||
moderation={moderationUi}
|
||||
type={type}
|
||||
/>
|
||||
<View style={[a.flex_1, a.gap_2xs]}>
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.font_bold,
|
||||
a.text_md,
|
||||
a.leading_tight,
|
||||
a.self_start,
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text
|
||||
style={[a.flex_1, a.leading_tight, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={1}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
{btnType === 'checkbox' ? (
|
||||
<Checkbox />
|
||||
) : !disabled ? (
|
||||
<Button
|
||||
label={_(msg`Remove`)}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="xsmall"
|
||||
style={[a.self_center, {marginLeft: 'auto'}]}
|
||||
onPress={onPress}>
|
||||
<ButtonText>
|
||||
<Trans>Remove</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
) : null}
|
||||
</Toggle.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export function WizardProfileCard({
|
||||
btnType,
|
||||
state,
|
||||
dispatch,
|
||||
profile,
|
||||
moderationOpts,
|
||||
}: {
|
||||
btnType: 'checkbox' | 'remove'
|
||||
state: WizardState
|
||||
dispatch: (action: WizardAction) => void
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const isMe = profile.did === currentAccount?.did
|
||||
const included = isMe || state.profiles.some(p => p.did === profile.did)
|
||||
const disabled = isMe || (!included && state.profiles.length >= 49)
|
||||
const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar')
|
||||
const displayName = profile.displayName
|
||||
? sanitizeDisplayName(profile.displayName)
|
||||
: `@${sanitizeHandle(profile.handle)}`
|
||||
|
||||
const onPress = () => {
|
||||
if (disabled) return
|
||||
|
||||
Keyboard.dismiss()
|
||||
if (profile.did === currentAccount?.did) return
|
||||
|
||||
if (!included) {
|
||||
dispatch({type: 'AddProfile', profile})
|
||||
} else {
|
||||
dispatch({type: 'RemoveProfile', profileDid: profile.did})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<WizardListCard
|
||||
type="user"
|
||||
btnType={btnType}
|
||||
displayName={displayName}
|
||||
subtitle={`@${sanitizeHandle(profile.handle)}`}
|
||||
onPress={onPress}
|
||||
avatar={profile.avatar}
|
||||
included={included}
|
||||
disabled={disabled}
|
||||
moderationUi={moderationUi}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function WizardFeedCard({
|
||||
btnType,
|
||||
generator,
|
||||
state,
|
||||
dispatch,
|
||||
moderationOpts,
|
||||
}: {
|
||||
btnType: 'checkbox' | 'remove'
|
||||
generator: GeneratorView
|
||||
state: WizardState
|
||||
dispatch: (action: WizardAction) => void
|
||||
moderationOpts: ModerationOpts
|
||||
}) {
|
||||
const isDiscover = generator.uri === DISCOVER_FEED_URI
|
||||
const included = isDiscover || state.feeds.some(f => f.uri === generator.uri)
|
||||
const disabled = isDiscover || (!included && state.feeds.length >= 3)
|
||||
const moderationUi = moderateFeedGenerator(generator, moderationOpts).ui(
|
||||
'avatar',
|
||||
)
|
||||
|
||||
const onPress = () => {
|
||||
if (disabled) return
|
||||
|
||||
Keyboard.dismiss()
|
||||
if (included) {
|
||||
dispatch({type: 'RemoveFeed', feedUri: generator.uri})
|
||||
} else {
|
||||
dispatch({type: 'AddFeed', feed: generator})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<WizardListCard
|
||||
type="algo"
|
||||
btnType={btnType}
|
||||
displayName={sanitizeDisplayName(generator.displayName)}
|
||||
subtitle={`Feed by @${sanitizeHandle(generator.creator.handle)}`}
|
||||
onPress={onPress}
|
||||
avatar={generator.avatar}
|
||||
included={included}
|
||||
disabled={disabled}
|
||||
moderationUi={moderationUi}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +1,27 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
|
||||
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {Link} from '#/components/Link'
|
||||
import {makeSearchLink} from '#/lib/routes/links'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {isInvalidHandle} from '#/lib/strings/handles'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
useRemoveMutedWordsMutation,
|
||||
useUpsertMutedWordsMutation,
|
||||
useRemoveMutedWordMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
||||
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
|
||||
import {Link} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {isInvalidHandle} from '#/lib/strings/handles'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function useTagMenuControl() {
|
||||
return Dialog.useDialogControl()
|
||||
@@ -52,10 +52,10 @@ export function TagMenu({
|
||||
reset: resetUpsert,
|
||||
} = useUpsertMutedWordsMutation()
|
||||
const {
|
||||
mutateAsync: removeMutedWord,
|
||||
mutateAsync: removeMutedWords,
|
||||
variables: optimisticRemove,
|
||||
reset: resetRemove,
|
||||
} = useRemoveMutedWordMutation()
|
||||
} = useRemoveMutedWordsMutation()
|
||||
const displayTag = '#' + tag
|
||||
|
||||
const isMuted = Boolean(
|
||||
@@ -65,9 +65,20 @@ export function TagMenu({
|
||||
optimisticUpsert?.find(
|
||||
m => m.value === tag && m.targets.includes('tag'),
|
||||
)) &&
|
||||
!(optimisticRemove?.value === tag),
|
||||
!optimisticRemove?.find(m => m?.value === tag),
|
||||
)
|
||||
|
||||
/*
|
||||
* Mute word records that exactly match the tag in question.
|
||||
*/
|
||||
const removeableMuteWords = React.useMemo(() => {
|
||||
return (
|
||||
preferences?.moderationPrefs.mutedWords?.filter(word => {
|
||||
return word.value === tag
|
||||
}) || []
|
||||
)
|
||||
}, [tag, preferences?.moderationPrefs?.mutedWords])
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
@@ -212,13 +223,16 @@ export function TagMenu({
|
||||
control.close(() => {
|
||||
if (isMuted) {
|
||||
resetUpsert()
|
||||
removeMutedWord({
|
||||
value: tag,
|
||||
targets: ['tag'],
|
||||
})
|
||||
removeMutedWords(removeableMuteWords)
|
||||
} else {
|
||||
resetRemove()
|
||||
upsertMutedWord([{value: tag, targets: ['tag']}])
|
||||
upsertMutedWord([
|
||||
{
|
||||
value: tag,
|
||||
targets: ['tag'],
|
||||
actorTarget: 'all',
|
||||
},
|
||||
])
|
||||
}
|
||||
})
|
||||
}}>
|
||||
|
||||
@@ -3,16 +3,16 @@ import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {isInvalidHandle} from '#/lib/strings/handles'
|
||||
import {EventStopper} from '#/view/com/util/EventStopper'
|
||||
import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {isInvalidHandle} from '#/lib/strings/handles'
|
||||
import {enforceLen} from '#/lib/strings/helpers'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
useRemoveMutedWordsMutation,
|
||||
useUpsertMutedWordsMutation,
|
||||
useRemoveMutedWordMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {enforceLen} from '#/lib/strings/helpers'
|
||||
import {EventStopper} from '#/view/com/util/EventStopper'
|
||||
import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown'
|
||||
import {web} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
|
||||
@@ -47,8 +47,8 @@ export function TagMenu({
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} =
|
||||
useUpsertMutedWordsMutation()
|
||||
const {mutateAsync: removeMutedWord, variables: optimisticRemove} =
|
||||
useRemoveMutedWordMutation()
|
||||
const {mutateAsync: removeMutedWords, variables: optimisticRemove} =
|
||||
useRemoveMutedWordsMutation()
|
||||
const isMuted = Boolean(
|
||||
(preferences?.moderationPrefs.mutedWords?.find(
|
||||
m => m.value === tag && m.targets.includes('tag'),
|
||||
@@ -56,10 +56,21 @@ export function TagMenu({
|
||||
optimisticUpsert?.find(
|
||||
m => m.value === tag && m.targets.includes('tag'),
|
||||
)) &&
|
||||
!(optimisticRemove?.value === tag),
|
||||
!optimisticRemove?.find(m => m?.value === tag),
|
||||
)
|
||||
const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle')
|
||||
|
||||
/*
|
||||
* Mute word records that exactly match the tag in question.
|
||||
*/
|
||||
const removeableMuteWords = React.useMemo(() => {
|
||||
return (
|
||||
preferences?.moderationPrefs.mutedWords?.filter(word => {
|
||||
return word.value === tag
|
||||
}) || []
|
||||
)
|
||||
}, [tag, preferences?.moderationPrefs?.mutedWords])
|
||||
|
||||
const dropdownItems = React.useMemo(() => {
|
||||
return [
|
||||
{
|
||||
@@ -105,9 +116,11 @@ export function TagMenu({
|
||||
: _(msg`Mute ${truncatedTag}`),
|
||||
onPress() {
|
||||
if (isMuted) {
|
||||
removeMutedWord({value: tag, targets: ['tag']})
|
||||
removeMutedWords(removeableMuteWords)
|
||||
} else {
|
||||
upsertMutedWord([{value: tag, targets: ['tag']}])
|
||||
upsertMutedWord([
|
||||
{value: tag, targets: ['tag'], actorTarget: 'all'},
|
||||
])
|
||||
}
|
||||
},
|
||||
testID: 'tagMenuMute',
|
||||
@@ -129,7 +142,8 @@ export function TagMenu({
|
||||
tag,
|
||||
truncatedTag,
|
||||
upsertMutedWord,
|
||||
removeMutedWord,
|
||||
removeMutedWords,
|
||||
removeableMuteWords,
|
||||
])
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export function VideoDownloadScreen() {
|
||||
// @TODO redirect
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import React from 'react'
|
||||
import {parse} from 'hls-parser'
|
||||
import {MasterPlaylist, MediaPlaylist, Variant} from 'hls-parser/types'
|
||||
|
||||
interface PostMessageData {
|
||||
action: 'progress' | 'error'
|
||||
messageStr?: string
|
||||
messageFloat?: number
|
||||
}
|
||||
|
||||
function postMessage(data: PostMessageData) {
|
||||
// @ts-expect-error safari webview only
|
||||
if (window?.webkit) {
|
||||
// @ts-expect-error safari webview only
|
||||
window.webkit.messageHandlers.onMessage.postMessage(JSON.stringify(data))
|
||||
// @ts-expect-error android webview only
|
||||
} else if (AndroidInterface) {
|
||||
// @ts-expect-error android webview only
|
||||
AndroidInterface.onMessage(JSON.stringify(data))
|
||||
}
|
||||
}
|
||||
|
||||
function createSegementUrl(originalUrl: string, newFile: string) {
|
||||
const parts = originalUrl.split('/')
|
||||
parts[parts.length - 1] = newFile
|
||||
return parts.join('/')
|
||||
}
|
||||
|
||||
export function VideoDownloadScreen() {
|
||||
const ffmpegRef = React.useRef<any>(null)
|
||||
const fetchFileRef = React.useRef<any>(null)
|
||||
|
||||
const [dataUrl, setDataUrl] = React.useState<any>(null)
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
const ffmpegLib = await import('@ffmpeg/ffmpeg')
|
||||
const ffmpeg = new ffmpegLib.FFmpeg()
|
||||
ffmpegRef.current = ffmpeg
|
||||
|
||||
const ffmpegUtilLib = await import('@ffmpeg/util')
|
||||
fetchFileRef.current = ffmpegUtilLib.fetchFile
|
||||
|
||||
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm'
|
||||
|
||||
await ffmpeg.load({
|
||||
coreURL: await ffmpegUtilLib.toBlobURL(
|
||||
`${baseURL}/ffmpeg-core.js`,
|
||||
'text/javascript',
|
||||
),
|
||||
wasmURL: await ffmpegUtilLib.toBlobURL(
|
||||
`${baseURL}/ffmpeg-core.wasm`,
|
||||
'application/wasm',
|
||||
),
|
||||
})
|
||||
}, [])
|
||||
|
||||
const createMp4 = React.useCallback(async (videoUrl: string) => {
|
||||
// Get the master playlist and find the best variant
|
||||
const masterPlaylistRes = await fetch(videoUrl)
|
||||
const masterPlaylistText = await masterPlaylistRes.text()
|
||||
const masterPlaylist = parse(masterPlaylistText) as MasterPlaylist
|
||||
|
||||
// If URL given is not a master playlist, we probably cannot handle this.
|
||||
if (!masterPlaylist.isMasterPlaylist) {
|
||||
postMessage({
|
||||
action: 'error',
|
||||
messageStr: 'A master playlist was not found in the provided playlist.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Figure out what the best quality is. These should generally be in order, but we'll check them all just in case
|
||||
let bestVariant: Variant | undefined
|
||||
for (const variant of masterPlaylist.variants) {
|
||||
if (!bestVariant || variant.bandwidth > bestVariant.bandwidth) {
|
||||
bestVariant = variant
|
||||
}
|
||||
}
|
||||
|
||||
// Should only happen if there was no variants at all given to us. Mostly for types.
|
||||
if (!bestVariant) {
|
||||
postMessage({
|
||||
action: 'error',
|
||||
messageStr: 'No variants were found in the provided master playlist.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const urlParts = videoUrl.split('/')
|
||||
urlParts[urlParts.length - 1] = bestVariant?.uri
|
||||
const bestVariantUrl = urlParts.join('/')
|
||||
|
||||
// Download and parse m3u8
|
||||
const hlsFileRes = await fetch(bestVariantUrl)
|
||||
const hlsPlainText = await hlsFileRes.text()
|
||||
const playlist = parse(hlsPlainText) as MediaPlaylist
|
||||
|
||||
// This one shouldn't be a master playlist - again just for types really
|
||||
if (playlist.isMasterPlaylist) {
|
||||
postMessage({
|
||||
action: 'error',
|
||||
messageStr: 'An unknown error has occurred.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const ffmpeg = ffmpegRef.current
|
||||
|
||||
// Get the correctly ordered file names. We need to remove the tracking info from the end of the file name
|
||||
const segments = playlist.segments.map(segment => {
|
||||
return segment.uri.split('?')[0]
|
||||
})
|
||||
|
||||
// Download each segment
|
||||
let error: string | null = null
|
||||
let completed = 0
|
||||
await Promise.all(
|
||||
playlist.segments.map(async segment => {
|
||||
const uri = createSegementUrl(bestVariantUrl, segment.uri)
|
||||
const filename = segment.uri.split('?')[0]
|
||||
|
||||
const res = await fetch(uri)
|
||||
if (!res.ok) {
|
||||
error = 'Failed to download playlist segment.'
|
||||
}
|
||||
|
||||
const blob = await res.blob()
|
||||
try {
|
||||
await ffmpeg.writeFile(filename, await fetchFileRef.current(blob))
|
||||
} catch (e: unknown) {
|
||||
error = 'Failed to write file.'
|
||||
} finally {
|
||||
completed++
|
||||
const progress = completed / playlist.segments.length
|
||||
postMessage({
|
||||
action: 'progress',
|
||||
messageFloat: progress,
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// Do something if there was an error
|
||||
if (error) {
|
||||
postMessage({
|
||||
action: 'error',
|
||||
messageStr: error,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Put the segments together
|
||||
await ffmpeg.exec([
|
||||
'-i',
|
||||
`concat:${segments.join('|')}`,
|
||||
'-c:v',
|
||||
'copy',
|
||||
'output.mp4',
|
||||
])
|
||||
|
||||
const fileData = await ffmpeg.readFile('output.mp4')
|
||||
const blob = new Blob([fileData.buffer], {type: 'video/mp4'})
|
||||
const dataUrl = await new Promise<string | null>(resolve => {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => resolve(reader.result as string)
|
||||
reader.onerror = () => resolve(null)
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
return dataUrl
|
||||
}, [])
|
||||
|
||||
const download = React.useCallback(
|
||||
async (videoUrl: string) => {
|
||||
await load()
|
||||
const mp4Res = await createMp4(videoUrl)
|
||||
|
||||
if (!mp4Res) {
|
||||
postMessage({
|
||||
action: 'error',
|
||||
messageStr: 'An error occurred while creating the MP4.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setDataUrl(mp4Res)
|
||||
},
|
||||
[createMp4, load],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
const url = new URL(window.location.href)
|
||||
const videoUrl = url.searchParams.get('videoUrl')
|
||||
|
||||
if (!videoUrl) {
|
||||
postMessage({action: 'error', messageStr: 'No video URL provided'})
|
||||
} else {
|
||||
setDataUrl(null)
|
||||
download(videoUrl)
|
||||
}
|
||||
}, [download])
|
||||
|
||||
if (!dataUrl) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<a
|
||||
href={dataUrl}
|
||||
ref={el => {
|
||||
el?.click()
|
||||
}}
|
||||
download="video.mp4"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import React from 'react'
|
||||
import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetPostThread,
|
||||
AppBskyGraphDefs,
|
||||
AtUri,
|
||||
BskyAgent,
|
||||
} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {createThreadgate} from '#/lib/api'
|
||||
import {until} from '#/lib/async/until'
|
||||
import {HITSLOP_10} from '#/lib/constants'
|
||||
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
|
||||
import {logger} from '#/logger'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread'
|
||||
import {
|
||||
ThreadgateSetting,
|
||||
threadgateViewToSettings,
|
||||
} from '#/state/queries/threadgate'
|
||||
import {useAgent} from '#/state/session'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
|
||||
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
|
||||
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {TextLink} from '../view/com/util/Link'
|
||||
import {ThreadgateEditorDialog} from './dialogs/ThreadgateEditor'
|
||||
import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil'
|
||||
|
||||
interface WhoCanReplyProps {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
isThreadAuthor: boolean
|
||||
style?: StyleProp<ViewStyle>
|
||||
}
|
||||
|
||||
export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const infoDialogControl = useDialogControl()
|
||||
const editDialogControl = useDialogControl()
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const settings = React.useMemo(
|
||||
() => threadgateViewToSettings(post.threadgate),
|
||||
[post],
|
||||
)
|
||||
const isRootPost = !('reply' in post.record)
|
||||
|
||||
if (!isRootPost) {
|
||||
return null
|
||||
}
|
||||
if (!settings.length && !isThreadAuthor) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isEverybody = settings.length === 0
|
||||
const isNobody = !!settings.find(gate => gate.type === 'nobody')
|
||||
const description = isEverybody
|
||||
? _(msg`Everybody can reply`)
|
||||
: isNobody
|
||||
? _(msg`Replies disabled`)
|
||||
: _(msg`Some people can reply`)
|
||||
|
||||
const onPressEdit = () => {
|
||||
if (isNative && Keyboard.isVisible()) {
|
||||
Keyboard.dismiss()
|
||||
}
|
||||
if (isThreadAuthor) {
|
||||
editDialogControl.open()
|
||||
} else {
|
||||
infoDialogControl.open()
|
||||
}
|
||||
}
|
||||
|
||||
const onEditConfirm = async (newSettings: ThreadgateSetting[]) => {
|
||||
if (JSON.stringify(settings) === JSON.stringify(newSettings)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (newSettings.length) {
|
||||
await createThreadgate(agent, post.uri, newSettings)
|
||||
} else {
|
||||
await agent.api.com.atproto.repo.deleteRecord({
|
||||
repo: agent.session!.did,
|
||||
collection: 'app.bsky.feed.threadgate',
|
||||
rkey: new AtUri(post.uri).rkey,
|
||||
})
|
||||
}
|
||||
await whenAppViewReady(agent, post.uri, res => {
|
||||
const thread = res.data.thread
|
||||
if (AppBskyFeedDefs.isThreadViewPost(thread)) {
|
||||
const fetchedSettings = threadgateViewToSettings(
|
||||
thread.post.threadgate,
|
||||
)
|
||||
return JSON.stringify(fetchedSettings) === JSON.stringify(newSettings)
|
||||
}
|
||||
return false
|
||||
})
|
||||
Toast.show(_(msg`Thread settings updated`))
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [POST_THREAD_RQKEY_ROOT],
|
||||
})
|
||||
} catch (err) {
|
||||
Toast.show(
|
||||
_(
|
||||
msg`There was an issue. Please check your internet connection and try again.`,
|
||||
),
|
||||
'xmark',
|
||||
)
|
||||
logger.error('Failed to edit threadgate', {message: err})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
label={
|
||||
isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`)
|
||||
}
|
||||
onPress={isThreadAuthor ? onPressEdit : infoDialogControl.open}
|
||||
hitSlop={HITSLOP_10}>
|
||||
{({hovered}) => (
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs, style]}>
|
||||
<Icon
|
||||
color={t.palette.contrast_400}
|
||||
width={16}
|
||||
settings={settings}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
a.text_sm,
|
||||
a.leading_tight,
|
||||
t.atoms.text_contrast_medium,
|
||||
hovered && a.underline,
|
||||
]}>
|
||||
{description}
|
||||
</Text>
|
||||
{isThreadAuthor && (
|
||||
<PencilLine width={12} fill={t.palette.primary_500} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
<WhoCanReplyDialog
|
||||
control={infoDialogControl}
|
||||
post={post}
|
||||
settings={settings}
|
||||
/>
|
||||
{isThreadAuthor && (
|
||||
<ThreadgateEditorDialog
|
||||
control={editDialogControl}
|
||||
threadgate={settings}
|
||||
onConfirm={onEditConfirm}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Icon({
|
||||
color,
|
||||
width,
|
||||
settings,
|
||||
}: {
|
||||
color: string
|
||||
width?: number
|
||||
settings: ThreadgateSetting[]
|
||||
}) {
|
||||
const isEverybody = settings.length === 0
|
||||
const isNobody = !!settings.find(gate => gate.type === 'nobody')
|
||||
const IconComponent = isEverybody ? Earth : isNobody ? CircleBanSign : Group
|
||||
return <IconComponent fill={color} width={width} />
|
||||
}
|
||||
|
||||
function WhoCanReplyDialog({
|
||||
control,
|
||||
post,
|
||||
settings,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
post: AppBskyFeedDefs.PostView
|
||||
settings: ThreadgateSetting[]
|
||||
}) {
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<WhoCanReplyDialogInner post={post} settings={settings} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function WhoCanReplyDialogInner({
|
||||
post,
|
||||
settings,
|
||||
}: {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
settings: ThreadgateSetting[]
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Who can reply dialog`)}
|
||||
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
|
||||
<View style={[a.gap_sm]}>
|
||||
<Text style={[a.font_bold, a.text_xl]}>
|
||||
<Trans>Who can reply?</Trans>
|
||||
</Text>
|
||||
<Rules post={post} settings={settings} />
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
|
||||
function Rules({
|
||||
post,
|
||||
settings,
|
||||
}: {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
settings: ThreadgateSetting[]
|
||||
}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_tight,
|
||||
a.flex_wrap,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{!settings.length ? (
|
||||
<Trans>Everybody can reply</Trans>
|
||||
) : settings[0].type === 'nobody' ? (
|
||||
<Trans>Replies to this thread are disabled</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Only{' '}
|
||||
{settings.map((rule, i) => (
|
||||
<>
|
||||
<Rule
|
||||
key={`rule-${i}`}
|
||||
rule={rule}
|
||||
post={post}
|
||||
lists={post.threadgate!.lists}
|
||||
/>
|
||||
<Separator key={`sep-${i}`} i={i} length={settings.length} />
|
||||
</>
|
||||
))}{' '}
|
||||
can reply
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
function Rule({
|
||||
rule,
|
||||
post,
|
||||
lists,
|
||||
}: {
|
||||
rule: ThreadgateSetting
|
||||
post: AppBskyFeedDefs.PostView
|
||||
lists: AppBskyGraphDefs.ListViewBasic[] | undefined
|
||||
}) {
|
||||
const t = useTheme()
|
||||
if (rule.type === 'mention') {
|
||||
return <Trans>mentioned users</Trans>
|
||||
}
|
||||
if (rule.type === 'following') {
|
||||
return (
|
||||
<Trans>
|
||||
users followed by{' '}
|
||||
<TextLink
|
||||
type="sm"
|
||||
href={makeProfileLink(post.author)}
|
||||
text={`@${post.author.handle}`}
|
||||
style={{color: t.palette.primary_500}}
|
||||
/>
|
||||
</Trans>
|
||||
)
|
||||
}
|
||||
if (rule.type === 'list') {
|
||||
const list = lists?.find(l => l.uri === rule.list)
|
||||
if (list) {
|
||||
const listUrip = new AtUri(list.uri)
|
||||
return (
|
||||
<Trans>
|
||||
<TextLink
|
||||
type="sm"
|
||||
href={makeListLink(listUrip.hostname, listUrip.rkey)}
|
||||
text={list.name}
|
||||
style={{color: t.palette.primary_500}}
|
||||
/>{' '}
|
||||
members
|
||||
</Trans>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Separator({i, length}: {i: number; length: number}) {
|
||||
if (length < 2 || i === length - 1) {
|
||||
return null
|
||||
}
|
||||
if (i === length - 2) {
|
||||
return (
|
||||
<>
|
||||
{length > 2 ? ',' : ''} <Trans>and</Trans>{' '}
|
||||
</>
|
||||
)
|
||||
}
|
||||
return <>, </>
|
||||
}
|
||||
|
||||
async function whenAppViewReady(
|
||||
agent: BskyAgent,
|
||||
uri: string,
|
||||
fn: (res: AppBskyFeedGetPostThread.Response) => boolean,
|
||||
) {
|
||||
await until(
|
||||
5, // 5 tries
|
||||
1e3, // 1s delay between tries
|
||||
fn,
|
||||
() =>
|
||||
agent.app.bsky.feed.getPostThread({
|
||||
uri,
|
||||
depth: 0,
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from 'react'
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedProps,
|
||||
useSharedValue,
|
||||
withDelay,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import Svg, {Circle, Path} from 'react-native-svg'
|
||||
|
||||
import {Props, useCommonSVGProps} from '#/components/icons/common'
|
||||
|
||||
const AnimatedPath = Animated.createAnimatedComponent(Path)
|
||||
const AnimatedCircle = Animated.createAnimatedComponent(Circle)
|
||||
|
||||
const PATH = 'M14.1 27.2l7.1 7.2 16.7-16.8'
|
||||
|
||||
export interface AnimatedCheckRef {
|
||||
play(cb?: () => void): void
|
||||
}
|
||||
|
||||
export interface AnimatedCheckProps extends Props {
|
||||
playOnMount?: boolean
|
||||
}
|
||||
|
||||
export const AnimatedCheck = React.forwardRef<
|
||||
AnimatedCheckRef,
|
||||
AnimatedCheckProps
|
||||
>(function AnimatedCheck({playOnMount, ...props}, ref) {
|
||||
const {fill, size, style, ...rest} = useCommonSVGProps(props)
|
||||
const circleAnim = useSharedValue(0)
|
||||
const checkAnim = useSharedValue(0)
|
||||
|
||||
const circleAnimatedProps = useAnimatedProps(() => ({
|
||||
strokeDashoffset: 166 - circleAnim.value * 166,
|
||||
}))
|
||||
const checkAnimatedProps = useAnimatedProps(() => ({
|
||||
strokeDashoffset: 48 - 48 * checkAnim.value,
|
||||
}))
|
||||
|
||||
const play = React.useCallback(
|
||||
(cb?: () => void) => {
|
||||
circleAnim.value = 0
|
||||
checkAnim.value = 0
|
||||
|
||||
circleAnim.value = withTiming(1, {duration: 500, easing: Easing.linear})
|
||||
checkAnim.value = withDelay(
|
||||
500,
|
||||
withTiming(1, {duration: 300, easing: Easing.linear}, cb),
|
||||
)
|
||||
},
|
||||
[circleAnim, checkAnim],
|
||||
)
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
play,
|
||||
}))
|
||||
|
||||
React.useEffect(() => {
|
||||
if (playOnMount) {
|
||||
play()
|
||||
}
|
||||
}, [play, playOnMount])
|
||||
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
{...rest}
|
||||
viewBox="0 0 52 52"
|
||||
width={size}
|
||||
height={size}
|
||||
style={style}>
|
||||
<AnimatedCircle
|
||||
animatedProps={circleAnimatedProps}
|
||||
cx="26"
|
||||
cy="26"
|
||||
r="24"
|
||||
fill="none"
|
||||
stroke={fill}
|
||||
strokeWidth={4}
|
||||
strokeDasharray={166}
|
||||
/>
|
||||
<AnimatedPath
|
||||
animatedProps={checkAnimatedProps}
|
||||
stroke={fill}
|
||||
d={PATH}
|
||||
strokeWidth={4}
|
||||
strokeDasharray={48}
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
})
|
||||
@@ -113,6 +113,7 @@ export function EmbedConsentDialog({
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {Modal, ScrollView, TextInput, View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {
|
||||
Gif,
|
||||
useFeaturedGifsQuery,
|
||||
useGifSearchQuery,
|
||||
} from '#/state/queries/tenor'
|
||||
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
|
||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||
import {FlatList_INTERNAL} from '#/view/com/util/Views'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {Button, ButtonText} from '../Button'
|
||||
import {Handle} from '../Dialog'
|
||||
import {useThrottledValue} from '../hooks/useThrottledValue'
|
||||
import {ListFooter, ListMaybePlaceholder} from '../Lists'
|
||||
import {GifPreview} from './GifSelect.shared'
|
||||
|
||||
export function GifSelectDialog({
|
||||
controlRef,
|
||||
onClose,
|
||||
onSelectGif: onSelectGifProp,
|
||||
}: {
|
||||
controlRef: React.RefObject<{open: () => void}>
|
||||
onClose: () => void
|
||||
onSelectGif: (gif: Gif) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useImperativeHandle(controlRef, () => ({
|
||||
open: () => setOpen(true),
|
||||
}))
|
||||
|
||||
const close = useCallback(() => {
|
||||
setOpen(false)
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
const onSelectGif = useCallback(
|
||||
(gif: Gif) => {
|
||||
onSelectGifProp(gif)
|
||||
close()
|
||||
},
|
||||
[onSelectGifProp, close],
|
||||
)
|
||||
|
||||
const renderErrorBoundary = useCallback(
|
||||
(error: any) => <ModalError details={String(error)} close={close} />,
|
||||
[close],
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={open}
|
||||
animationType="slide"
|
||||
presentationStyle="formSheet"
|
||||
onRequestClose={close}
|
||||
aria-modal
|
||||
accessibilityViewIsModal>
|
||||
<View style={[a.flex_1, t.atoms.bg]}>
|
||||
<Handle />
|
||||
<ErrorBoundary renderError={renderErrorBoundary}>
|
||||
<GifList onSelectGif={onSelectGif} close={close} />
|
||||
</ErrorBoundary>
|
||||
</View>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function GifList({
|
||||
onSelectGif,
|
||||
}: {
|
||||
close: () => void
|
||||
onSelectGif: (gif: Gif) => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const textInputRef = useRef<TextInput>(null)
|
||||
const listRef = useRef<FlatList_INTERNAL>(null)
|
||||
const [undeferredSearch, setSearch] = useState('')
|
||||
const search = useThrottledValue(undeferredSearch, 500)
|
||||
|
||||
const isSearching = search.length > 0
|
||||
|
||||
const trendingQuery = useFeaturedGifsQuery()
|
||||
const searchQuery = useGifSearchQuery(search)
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
error,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = isSearching ? searchQuery : trendingQuery
|
||||
|
||||
const flattenedData = useMemo(() => {
|
||||
return data?.pages.flatMap(page => page.results) || []
|
||||
}, [data])
|
||||
|
||||
const renderItem = useCallback(
|
||||
({item}: {item: Gif}) => {
|
||||
return <GifPreview gif={item} onSelectGif={onSelectGif} />
|
||||
},
|
||||
[onSelectGif],
|
||||
)
|
||||
|
||||
const onEndReached = React.useCallback(() => {
|
||||
if (isFetchingNextPage || !hasNextPage || error) return
|
||||
fetchNextPage()
|
||||
}, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
|
||||
|
||||
const hasData = flattenedData.length > 0
|
||||
|
||||
const onGoBack = useCallback(() => {
|
||||
if (isSearching) {
|
||||
// clear the input and reset the state
|
||||
textInputRef.current?.clear()
|
||||
setSearch('')
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
}, [isSearching])
|
||||
|
||||
const listHeader = useMemo(() => {
|
||||
return (
|
||||
<View style={[a.relative, a.mb_lg, a.pt_4xl, a.flex_row, a.align_center]}>
|
||||
{/* cover top corners */}
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
{
|
||||
borderBottomLeftRadius: 8,
|
||||
borderBottomRightRadius: 8,
|
||||
},
|
||||
t.atoms.bg,
|
||||
]}
|
||||
/>
|
||||
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={Search} />
|
||||
<TextField.Input
|
||||
label={_(msg`Search GIFs`)}
|
||||
placeholder={_(msg`Search Tenor`)}
|
||||
onChangeText={text => {
|
||||
setSearch(text)
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||
}}
|
||||
returnKeyType="search"
|
||||
clearButtonMode="while-editing"
|
||||
inputRef={textInputRef}
|
||||
maxLength={50}
|
||||
/>
|
||||
</TextField.Root>
|
||||
</View>
|
||||
)
|
||||
}, [t.atoms.bg, _])
|
||||
|
||||
return (
|
||||
<FlatList_INTERNAL
|
||||
ref={listRef}
|
||||
key={gtMobile ? '3 cols' : '2 cols'}
|
||||
data={flattenedData}
|
||||
renderItem={renderItem}
|
||||
numColumns={gtMobile ? 3 : 2}
|
||||
columnWrapperStyle={a.gap_sm}
|
||||
contentContainerStyle={a.px_lg}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
{listHeader}
|
||||
{!hasData && (
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
onRetry={refetch}
|
||||
onGoBack={onGoBack}
|
||||
emptyType="results"
|
||||
sideBorders={false}
|
||||
topBorder={false}
|
||||
errorTitle={_(msg`Failed to load GIFs`)}
|
||||
errorMessage={_(msg`There was an issue connecting to Tenor.`)}
|
||||
emptyMessage={
|
||||
isSearching
|
||||
? _(msg`No search results found for "${search}".`)
|
||||
: _(
|
||||
msg`No featured GIFs found. There may be an issue with Tenor.`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
stickyHeaderIndices={[0]}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={4}
|
||||
keyExtractor={(item: Gif) => item.id}
|
||||
keyboardDismissMode="on-drag"
|
||||
ListFooterComponent={
|
||||
hasData ? (
|
||||
<ListFooter
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
error={cleanError(error)}
|
||||
onRetry={fetchNextPage}
|
||||
style={{borderTopWidth: 0}}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ModalError({details, close}: {details?: string; close: () => void}) {
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={[a.flex_1, a.gap_md]}
|
||||
centerContent
|
||||
contentContainerStyle={a.px_lg}>
|
||||
<ErrorScreen
|
||||
title={_(msg`Oh no!`)}
|
||||
message={_(
|
||||
msg`There was an unexpected issue in the application. Please let us know if this happened to you!`,
|
||||
)}
|
||||
details={details}
|
||||
/>
|
||||
<Button
|
||||
label={_(msg`Close dialog`)}
|
||||
onPress={close}
|
||||
color="primary"
|
||||
size="medium"
|
||||
variant="solid">
|
||||
<ButtonText>
|
||||
<Trans>Close</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {Image} from 'expo-image'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {Gif} from '#/state/queries/tenor'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button} from '../Button'
|
||||
|
||||
export function GifPreview({
|
||||
gif,
|
||||
onSelectGif,
|
||||
}: {
|
||||
gif: Gif
|
||||
onSelectGif: (gif: Gif) => void
|
||||
}) {
|
||||
const {gtTablet} = useBreakpoints()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
logEvent('composer:gif:select', {})
|
||||
onSelectGif(gif)
|
||||
}, [onSelectGif, gif])
|
||||
|
||||
return (
|
||||
<Button
|
||||
label={_(msg`Select GIF "${gif.title}"`)}
|
||||
style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
|
||||
onPress={onPress}>
|
||||
{({pressed}) => (
|
||||
<Image
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.mb_sm,
|
||||
a.rounded_sm,
|
||||
{aspectRatio: 1, opacity: pressed ? 0.8 : 1},
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
source={{
|
||||
uri: gif.media_formats.tinygif.url,
|
||||
}}
|
||||
contentFit="cover"
|
||||
accessibilityLabel={gif.title}
|
||||
accessibilityHint=""
|
||||
cachePolicy="none"
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import React, {useCallback, useMemo, useRef, useState} from 'react'
|
||||
import React, {
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {TextInput, View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {
|
||||
@@ -23,16 +27,23 @@ import {ArrowLeft_Stroke2_Corner0_Rounded as Arrow} from '#/components/icons/Arr
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {Button, ButtonIcon, ButtonText} from '../Button'
|
||||
import {ListFooter, ListMaybePlaceholder} from '../Lists'
|
||||
import {GifPreview} from './GifSelect.shared'
|
||||
|
||||
export function GifSelectDialog({
|
||||
control,
|
||||
controlRef,
|
||||
onClose,
|
||||
onSelectGif: onSelectGifProp,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
controlRef: React.RefObject<{open: () => void}>
|
||||
onClose: () => void
|
||||
onSelectGif: (gif: Gif) => void
|
||||
}) {
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
useImperativeHandle(controlRef, () => ({
|
||||
open: () => control.open(),
|
||||
}))
|
||||
|
||||
const onSelectGif = useCallback(
|
||||
(gif: Gif) => {
|
||||
control.close(() => onSelectGifProp(gif))
|
||||
@@ -233,56 +244,14 @@ function GifList({
|
||||
)
|
||||
}
|
||||
|
||||
function GifPreview({
|
||||
gif,
|
||||
onSelectGif,
|
||||
}: {
|
||||
gif: Gif
|
||||
onSelectGif: (gif: Gif) => void
|
||||
}) {
|
||||
const {gtTablet} = useBreakpoints()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
logEvent('composer:gif:select', {})
|
||||
onSelectGif(gif)
|
||||
}, [onSelectGif, gif])
|
||||
|
||||
return (
|
||||
<Button
|
||||
label={_(msg`Select GIF "${gif.title}"`)}
|
||||
style={[a.flex_1, gtTablet ? {maxWidth: '33%'} : {maxWidth: '50%'}]}
|
||||
onPress={onPress}>
|
||||
{({pressed}) => (
|
||||
<Image
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.mb_sm,
|
||||
a.rounded_sm,
|
||||
{aspectRatio: 1, opacity: pressed ? 0.8 : 1},
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
source={{
|
||||
uri: gif.media_formats.tinygif.url,
|
||||
}}
|
||||
contentFit="cover"
|
||||
accessibilityLabel={gif.title}
|
||||
accessibilityHint=""
|
||||
cachePolicy="none"
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogError({details}: {details?: string}) {
|
||||
const {_} = useLingui()
|
||||
const control = Dialog.useDialogContext()
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner style={a.gap_md} label={_(msg`An error occured`)}>
|
||||
<Dialog.ScrollableInner
|
||||
style={a.gap_md}
|
||||
label={_(msg`An error has occurred`)}>
|
||||
<Dialog.Close />
|
||||
<ErrorScreen
|
||||
title={_(msg`Oh no!`)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import {Keyboard, View} from 'react-native'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -24,6 +24,7 @@ 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 {useFormatDistance} from '#/components/hooks/dates'
|
||||
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
||||
import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
@@ -32,6 +33,8 @@ import {Loader} from '#/components/Loader'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const ONE_DAY = 24 * 60 * 60 * 1000
|
||||
|
||||
export function MutedWordsDialog() {
|
||||
const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
|
||||
return (
|
||||
@@ -53,16 +56,32 @@ function MutedWordsInner() {
|
||||
} = usePreferencesQuery()
|
||||
const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
|
||||
const [field, setField] = React.useState('')
|
||||
const [options, setOptions] = React.useState(['content'])
|
||||
const [targets, setTargets] = React.useState(['content'])
|
||||
const [error, setError] = React.useState('')
|
||||
const [durations, setDurations] = React.useState(['forever'])
|
||||
const [excludeFollowing, setExcludeFollowing] = React.useState(false)
|
||||
|
||||
const submit = React.useCallback(async () => {
|
||||
const sanitizedValue = sanitizeMutedWordValue(field)
|
||||
const targets = ['tag', options.includes('content') && 'content'].filter(
|
||||
const surfaces = ['tag', targets.includes('content') && 'content'].filter(
|
||||
Boolean,
|
||||
) as AppBskyActorDefs.MutedWord['targets']
|
||||
const actorTarget = excludeFollowing ? 'exclude-following' : 'all'
|
||||
|
||||
if (!sanitizedValue || !targets.length) {
|
||||
const now = Date.now()
|
||||
const rawDuration = durations.at(0)
|
||||
// undefined evaluates to 'forever'
|
||||
let duration: string | undefined
|
||||
|
||||
if (rawDuration === '24_hours') {
|
||||
duration = new Date(now + ONE_DAY).toISOString()
|
||||
} else if (rawDuration === '7_days') {
|
||||
duration = new Date(now + 7 * ONE_DAY).toISOString()
|
||||
} else if (rawDuration === '30_days') {
|
||||
duration = new Date(now + 30 * ONE_DAY).toISOString()
|
||||
}
|
||||
|
||||
if (!sanitizedValue || !surfaces.length) {
|
||||
setField('')
|
||||
setError(_(msg`Please enter a valid word, tag, or phrase to mute`))
|
||||
return
|
||||
@@ -70,28 +89,37 @@ function MutedWordsInner() {
|
||||
|
||||
try {
|
||||
// send raw value and rely on SDK as sanitization source of truth
|
||||
await addMutedWord([{value: field, targets}])
|
||||
await addMutedWord([
|
||||
{
|
||||
value: field,
|
||||
targets: surfaces,
|
||||
actorTarget,
|
||||
expiresAt: duration,
|
||||
},
|
||||
])
|
||||
setField('')
|
||||
} catch (e: any) {
|
||||
logger.error(`Failed to save muted word`, {message: e.message})
|
||||
setError(e.message)
|
||||
}
|
||||
}, [_, field, options, addMutedWord, setField])
|
||||
}, [_, field, targets, addMutedWord, setField, durations, excludeFollowing])
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
|
||||
<View onTouchStart={Keyboard.dismiss}>
|
||||
<View>
|
||||
<Text
|
||||
style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
|
||||
<Trans>Add muted words and tags</Trans>
|
||||
</Text>
|
||||
<Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
Posts can be muted based on their text, their tags, or both.
|
||||
Posts can be muted based on their text, their tags, or both. We
|
||||
recommend avoiding common words that appear in many posts, since it
|
||||
can result in no posts being shown.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.pb_lg]}>
|
||||
<View style={[a.pb_sm]}>
|
||||
<Dialog.Input
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
@@ -107,30 +135,135 @@ function MutedWordsInner() {
|
||||
}}
|
||||
onSubmitEditing={submit}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[a.pb_xl, a.gap_sm]}>
|
||||
<Toggle.Group
|
||||
label={_(msg`Toggle between muted word options.`)}
|
||||
label={_(msg`Select how long to mute this word for.`)}
|
||||
type="radio"
|
||||
values={options}
|
||||
onChange={setOptions}>
|
||||
values={durations}
|
||||
onChange={setDurations}>
|
||||
<Text
|
||||
style={[
|
||||
a.pb_xs,
|
||||
a.text_sm,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>Duration:</Trans>
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.pt_sm,
|
||||
a.py_sm,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
gtMobile && [a.flex_row, a.align_center, a.justify_start],
|
||||
a.gap_sm,
|
||||
a.flex_wrap,
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.justify_start,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
]}>
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word until you unmute it`)}
|
||||
name="forever"
|
||||
style={[a.flex_1]}>
|
||||
<TargetToggle>
|
||||
<View
|
||||
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>Forever</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word for 24 hours`)}
|
||||
name="24_hours"
|
||||
style={[a.flex_1]}>
|
||||
<TargetToggle>
|
||||
<View
|
||||
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>24 hours</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.justify_start,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
]}>
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word for 7 days`)}
|
||||
name="7_days"
|
||||
style={[a.flex_1]}>
|
||||
<TargetToggle>
|
||||
<View
|
||||
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>7 days</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word for 30 days`)}
|
||||
name="30_days"
|
||||
style={[a.flex_1]}>
|
||||
<TargetToggle>
|
||||
<View
|
||||
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>30 days</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
</View>
|
||||
</Toggle.Group>
|
||||
|
||||
<Toggle.Group
|
||||
label={_(msg`Select what content this mute word should apply to.`)}
|
||||
type="radio"
|
||||
values={targets}
|
||||
onChange={setTargets}>
|
||||
<Text
|
||||
style={[
|
||||
a.pb_xs,
|
||||
a.text_sm,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>Mute in:</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm, a.flex_wrap]}>
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word in post text and tags`)}
|
||||
name="content"
|
||||
style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
|
||||
style={[a.flex_1]}>
|
||||
<TargetToggle>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<View
|
||||
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText>
|
||||
<Trans>Mute in text & tags</Trans>
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>Text & tags</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
<PageText size="sm" />
|
||||
@@ -140,34 +273,64 @@ function MutedWordsInner() {
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word in tags only`)}
|
||||
name="tag"
|
||||
style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
|
||||
style={[a.flex_1]}>
|
||||
<TargetToggle>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<View
|
||||
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText>
|
||||
<Trans>Mute in tags only</Trans>
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>Tags only</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
<Hashtag size="sm" />
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
|
||||
<Button
|
||||
disabled={isPending || !field}
|
||||
label={_(msg`Add mute word for configured settings`)}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="solid"
|
||||
style={[!gtMobile && [a.w_full, a.flex_0]]}
|
||||
onPress={submit}>
|
||||
<ButtonText>
|
||||
<Trans>Add</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={isPending ? Loader : Plus} />
|
||||
</Button>
|
||||
</View>
|
||||
</Toggle.Group>
|
||||
|
||||
<View>
|
||||
<Text
|
||||
style={[
|
||||
a.pb_xs,
|
||||
a.text_sm,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>Options:</Trans>
|
||||
</Text>
|
||||
<Toggle.Item
|
||||
label={_(msg`Do not apply this mute word to users you follow`)}
|
||||
name="exclude_following"
|
||||
style={[a.flex_row, a.justify_between]}
|
||||
value={excludeFollowing}
|
||||
onChange={setExcludeFollowing}>
|
||||
<TargetToggle>
|
||||
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Checkbox />
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>Exclude users you follow</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
|
||||
<View style={[a.pt_xs]}>
|
||||
<Button
|
||||
disabled={isPending || !field}
|
||||
label={_(msg`Add mute word for configured settings`)}
|
||||
size="medium"
|
||||
color="primary"
|
||||
variant="solid"
|
||||
style={[]}
|
||||
onPress={submit}>
|
||||
<ButtonText>
|
||||
<Trans>Add</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={isPending ? Loader : Plus} position="right" />
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View
|
||||
style={[
|
||||
@@ -191,20 +354,6 @@ function MutedWordsInner() {
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text
|
||||
style={[
|
||||
a.pt_xs,
|
||||
a.text_sm,
|
||||
a.italic,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>
|
||||
We recommend avoiding common words that appear in many posts,
|
||||
since it can result in no posts being shown.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Divider />
|
||||
@@ -268,6 +417,9 @@ function MutedWordRow({
|
||||
const {_} = useLingui()
|
||||
const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
|
||||
const control = Prompt.usePromptControl()
|
||||
const expiryDate = word.expiresAt ? new Date(word.expiresAt) : undefined
|
||||
const isExpired = expiryDate && expiryDate < new Date()
|
||||
const formatDistance = useFormatDistance()
|
||||
|
||||
const remove = React.useCallback(async () => {
|
||||
control.close()
|
||||
@@ -280,7 +432,7 @@ function MutedWordRow({
|
||||
control={control}
|
||||
title={_(msg`Are you sure?`)}
|
||||
description={_(
|
||||
msg`This will delete ${word.value} from your muted words. You can always add it back later.`,
|
||||
msg`This will delete "${word.value}" from your muted words. You can always add it back later.`,
|
||||
)}
|
||||
onConfirm={remove}
|
||||
confirmButtonCta={_(msg`Remove`)}
|
||||
@@ -289,53 +441,94 @@ function MutedWordRow({
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_between,
|
||||
a.py_md,
|
||||
a.px_lg,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.rounded_md,
|
||||
a.gap_md,
|
||||
style,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.leading_snug,
|
||||
a.w_full,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_high,
|
||||
web({
|
||||
overflowWrap: 'break-word',
|
||||
wordBreak: 'break-word',
|
||||
}),
|
||||
]}>
|
||||
{word.value}
|
||||
</Text>
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.leading_snug,
|
||||
a.font_bold,
|
||||
web({
|
||||
overflowWrap: 'break-word',
|
||||
wordBreak: 'break-word',
|
||||
}),
|
||||
]}>
|
||||
{word.targets.find(t => t === 'content') ? (
|
||||
<Trans comment="Pattern: {wordValue} in text, tags">
|
||||
{word.value}{' '}
|
||||
<Text style={[a.font_normal, t.atoms.text_contrast_medium]}>
|
||||
in{' '}
|
||||
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
|
||||
text & tags
|
||||
</Text>
|
||||
</Text>
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans comment="Pattern: {wordValue} in tags">
|
||||
{word.value}{' '}
|
||||
<Text style={[a.font_normal, t.atoms.text_contrast_medium]}>
|
||||
in{' '}
|
||||
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
|
||||
tags
|
||||
</Text>
|
||||
</Text>
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[a.flex_row, a.align_center, a.justify_end, a.gap_sm]}>
|
||||
{word.targets.map(target => (
|
||||
<View
|
||||
key={target}
|
||||
style={[a.py_xs, a.px_sm, a.rounded_sm, t.atoms.bg_contrast_100]}>
|
||||
{(expiryDate || word.actorTarget === 'exclude-following') && (
|
||||
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Text
|
||||
style={[a.text_xs, a.font_bold, t.atoms.text_contrast_medium]}>
|
||||
{target === 'content' ? _(msg`text`) : _(msg`tag`)}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_xs,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{expiryDate && (
|
||||
<>
|
||||
{isExpired ? (
|
||||
<Trans>Expired</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Expires{' '}
|
||||
{formatDistance(expiryDate, new Date(), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</Trans>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{word.actorTarget === 'exclude-following' && (
|
||||
<>
|
||||
{' • '}
|
||||
<Trans>Excludes users you follow</Trans>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<Button
|
||||
label={_(msg`Remove mute word from your list`)}
|
||||
size="tiny"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onPress={() => control.open()}
|
||||
style={[a.ml_sm]}>
|
||||
<ButtonIcon icon={isPending ? Loader : X} />
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Button
|
||||
label={_(msg`Remove mute word from your list`)}
|
||||
size="tiny"
|
||||
shape="round"
|
||||
variant="outline"
|
||||
color="secondary"
|
||||
onPress={() => control.open()}
|
||||
style={[a.ml_sm]}>
|
||||
<ButtonIcon icon={isPending ? Loader : X} />
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
@@ -357,12 +550,11 @@ function TargetToggle({children}: React.PropsWithChildren<{}>) {
|
||||
a.px_sm,
|
||||
gtMobile && a.px_md,
|
||||
a.rounded_sm,
|
||||
t.atoms.bg_contrast_50,
|
||||
(ctx.hovered || ctx.focused) && t.atoms.bg_contrast_100,
|
||||
t.atoms.bg_contrast_25,
|
||||
(ctx.hovered || ctx.focused) && t.atoms.bg_contrast_50,
|
||||
ctx.selected && [
|
||||
{
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.primary_50 : t.palette.primary_975,
|
||||
backgroundColor: t.palette.primary_50,
|
||||
},
|
||||
],
|
||||
ctx.disabled && {
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import isEqual from 'lodash.isequal'
|
||||
|
||||
import {useMyListsQuery} from '#/state/queries/my-lists'
|
||||
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
interface ThreadgateEditorDialogProps {
|
||||
control: Dialog.DialogControlProps
|
||||
threadgate: ThreadgateSetting[]
|
||||
onChange?: (v: ThreadgateSetting[]) => void
|
||||
onConfirm?: (v: ThreadgateSetting[]) => void
|
||||
}
|
||||
|
||||
export function ThreadgateEditorDialog({
|
||||
control,
|
||||
threadgate,
|
||||
onChange,
|
||||
onConfirm,
|
||||
}: ThreadgateEditorDialogProps) {
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<DialogContent
|
||||
seedThreadgate={threadgate}
|
||||
onChange={onChange}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
seedThreadgate,
|
||||
onChange,
|
||||
onConfirm,
|
||||
}: {
|
||||
seedThreadgate: ThreadgateSetting[]
|
||||
onChange?: (v: ThreadgateSetting[]) => void
|
||||
onConfirm?: (v: ThreadgateSetting[]) => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const control = Dialog.useDialogContext()
|
||||
const {data: lists} = useMyListsQuery('curate')
|
||||
const [draft, setDraft] = React.useState(seedThreadgate)
|
||||
|
||||
const [prevSeedThreadgate, setPrevSeedThreadgate] =
|
||||
React.useState(seedThreadgate)
|
||||
if (seedThreadgate !== prevSeedThreadgate) {
|
||||
// New data flowed from above (e.g. due to update coming through).
|
||||
setPrevSeedThreadgate(seedThreadgate)
|
||||
setDraft(seedThreadgate) // Reset draft.
|
||||
}
|
||||
|
||||
function updateThreadgate(nextThreadgate: ThreadgateSetting[]) {
|
||||
setDraft(nextThreadgate)
|
||||
onChange?.(nextThreadgate)
|
||||
}
|
||||
|
||||
const onPressEverybody = () => {
|
||||
updateThreadgate([])
|
||||
}
|
||||
|
||||
const onPressNobody = () => {
|
||||
updateThreadgate([{type: 'nobody'}])
|
||||
}
|
||||
|
||||
const onPressAudience = (setting: ThreadgateSetting) => {
|
||||
// remove nobody
|
||||
let newSelected: ThreadgateSetting[] = draft.filter(
|
||||
v => v.type !== 'nobody',
|
||||
)
|
||||
// toggle
|
||||
const i = newSelected.findIndex(v => isEqual(v, setting))
|
||||
if (i === -1) {
|
||||
newSelected.push(setting)
|
||||
} else {
|
||||
newSelected.splice(i, 1)
|
||||
}
|
||||
updateThreadgate(newSelected)
|
||||
}
|
||||
|
||||
const doneLabel = onConfirm ? _(msg`Save`) : _(msg`Done`)
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Choose who can reply`)}
|
||||
style={[{maxWidth: 500}, a.w_full]}>
|
||||
<View style={[a.flex_1, a.gap_md]}>
|
||||
<Text style={[a.text_2xl, a.font_bold]}>
|
||||
<Trans>Choose who can reply</Trans>
|
||||
</Text>
|
||||
<Text style={a.mt_xs}>
|
||||
<Trans>Either choose "Everybody" or "Nobody"</Trans>
|
||||
</Text>
|
||||
<View style={[a.flex_row, a.gap_sm]}>
|
||||
<Selectable
|
||||
label={_(msg`Everybody`)}
|
||||
isSelected={draft.length === 0}
|
||||
onPress={onPressEverybody}
|
||||
style={{flex: 1}}
|
||||
/>
|
||||
<Selectable
|
||||
label={_(msg`Nobody`)}
|
||||
isSelected={!!draft.find(v => v.type === 'nobody')}
|
||||
onPress={onPressNobody}
|
||||
style={{flex: 1}}
|
||||
/>
|
||||
</View>
|
||||
<Text style={a.mt_md}>
|
||||
<Trans>Or combine these options:</Trans>
|
||||
</Text>
|
||||
<View style={[a.gap_sm]}>
|
||||
<Selectable
|
||||
label={_(msg`Mentioned users`)}
|
||||
isSelected={!!draft.find(v => v.type === 'mention')}
|
||||
onPress={() => onPressAudience({type: 'mention'})}
|
||||
/>
|
||||
<Selectable
|
||||
label={_(msg`Followed users`)}
|
||||
isSelected={!!draft.find(v => v.type === 'following')}
|
||||
onPress={() => onPressAudience({type: 'following'})}
|
||||
/>
|
||||
{lists && lists.length > 0
|
||||
? lists.map(list => (
|
||||
<Selectable
|
||||
key={list.uri}
|
||||
label={_(msg`Users in "${list.name}"`)}
|
||||
isSelected={
|
||||
!!draft.find(v => v.type === 'list' && v.list === list.uri)
|
||||
}
|
||||
onPress={() =>
|
||||
onPressAudience({type: 'list', list: list.uri})
|
||||
}
|
||||
/>
|
||||
))
|
||||
: // No loading states to avoid jumps for the common case (no lists)
|
||||
null}
|
||||
</View>
|
||||
</View>
|
||||
<Button
|
||||
label={doneLabel}
|
||||
onPress={() => {
|
||||
control.close()
|
||||
onConfirm?.(draft)
|
||||
}}
|
||||
onAccessibilityEscape={control.close}
|
||||
color="primary"
|
||||
size="medium"
|
||||
variant="solid"
|
||||
style={a.mt_xl}>
|
||||
<ButtonText>{doneLabel}</ButtonText>
|
||||
</Button>
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
|
||||
function Selectable({
|
||||
label,
|
||||
isSelected,
|
||||
onPress,
|
||||
style,
|
||||
}: {
|
||||
label: string
|
||||
isSelected: boolean
|
||||
onPress: () => void
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<Button
|
||||
onPress={onPress}
|
||||
label={label}
|
||||
accessibilityHint="Select this option"
|
||||
accessibilityRole="checkbox"
|
||||
aria-checked={isSelected}
|
||||
accessibilityState={{
|
||||
checked: isSelected,
|
||||
}}
|
||||
style={a.flex_1}>
|
||||
{({hovered, focused}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.rounded_sm,
|
||||
a.p_md,
|
||||
{height: 40}, // for consistency with checkmark icon visible or not
|
||||
t.atoms.bg_contrast_50,
|
||||
(hovered || focused) && t.atoms.bg_contrast_100,
|
||||
isSelected && {
|
||||
backgroundColor: t.palette.primary_100,
|
||||
},
|
||||
style,
|
||||
]}>
|
||||
<Text style={[a.text_sm, isSelected && a.font_semibold]}>
|
||||
{label}
|
||||
</Text>
|
||||
{isSelected ? (
|
||||
<Check size="sm" fill={t.palette.primary_500} />
|
||||
) : (
|
||||
<View />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import {Keyboard, Pressable, View} from 'react-native'
|
||||
import {Keyboard} from 'react-native'
|
||||
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
runOnJS,
|
||||
@@ -8,6 +9,8 @@ import Animated, {
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {HITSLOP_10} from 'lib/constants'
|
||||
import {useHaptics} from 'lib/haptics'
|
||||
@@ -15,8 +18,6 @@ import {atoms as a} from '#/alf'
|
||||
import {MessageMenu} from '#/components/dms/MessageMenu'
|
||||
import {useMenuControl} from '#/components/Menu'
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
|
||||
|
||||
export function ActionsWrapper({
|
||||
message,
|
||||
isFromSelf,
|
||||
@@ -26,60 +27,69 @@ export function ActionsWrapper({
|
||||
isFromSelf: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const playHaptic = useHaptics()
|
||||
const menuControl = useMenuControl()
|
||||
|
||||
const scale = useSharedValue(1)
|
||||
const animationDidComplete = useSharedValue(false)
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{scale: scale.value}],
|
||||
}))
|
||||
|
||||
// Reanimated's `runOnJS` doesn't like refs, so we can't use `runOnJS(menuControl.open)()`. Instead, we'll use this
|
||||
// function
|
||||
const open = React.useCallback(() => {
|
||||
playHaptic()
|
||||
Keyboard.dismiss()
|
||||
menuControl.open()
|
||||
}, [menuControl])
|
||||
}, [menuControl, playHaptic])
|
||||
|
||||
const shrink = React.useCallback(() => {
|
||||
'worklet'
|
||||
cancelAnimation(scale)
|
||||
scale.value = withTiming(1, {duration: 200}, () => {
|
||||
animationDidComplete.value = false
|
||||
})
|
||||
}, [animationDidComplete, scale])
|
||||
scale.value = withTiming(1, {duration: 200})
|
||||
}, [scale])
|
||||
|
||||
const grow = React.useCallback(() => {
|
||||
'worklet'
|
||||
scale.value = withTiming(1.05, {duration: 450}, finished => {
|
||||
if (!finished) return
|
||||
animationDidComplete.value = true
|
||||
runOnJS(playHaptic)()
|
||||
runOnJS(open)()
|
||||
const doubleTapGesture = Gesture.Tap()
|
||||
.numberOfTaps(2)
|
||||
.hitSlop(HITSLOP_10)
|
||||
.onEnd(open)
|
||||
|
||||
shrink()
|
||||
const pressAndHoldGesture = Gesture.LongPress()
|
||||
.onStart(() => {
|
||||
scale.value = withTiming(1.05, {duration: 200}, finished => {
|
||||
if (!finished) return
|
||||
runOnJS(open)()
|
||||
shrink()
|
||||
})
|
||||
})
|
||||
}, [scale, animationDidComplete, playHaptic, shrink, open])
|
||||
.onTouchesUp(shrink)
|
||||
.onTouchesMove(shrink)
|
||||
.cancelsTouchesInView(false)
|
||||
.runOnJS(true)
|
||||
|
||||
const composedGestures = Gesture.Exclusive(
|
||||
doubleTapGesture,
|
||||
pressAndHoldGesture,
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
maxWidth: '80%',
|
||||
},
|
||||
isFromSelf ? a.self_end : a.self_start,
|
||||
]}>
|
||||
<AnimatedPressable
|
||||
style={animatedStyle}
|
||||
unstable_pressDelay={200}
|
||||
onPressIn={grow}
|
||||
onTouchEnd={shrink}
|
||||
hitSlop={HITSLOP_10}>
|
||||
<GestureDetector gesture={composedGestures}>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
maxWidth: '80%',
|
||||
},
|
||||
isFromSelf ? a.self_end : a.self_start,
|
||||
animatedStyle,
|
||||
]}
|
||||
accessible={true}
|
||||
accessibilityActions={[
|
||||
{name: 'activate', label: _(msg`Open message options`)},
|
||||
]}
|
||||
onAccessibilityAction={open}>
|
||||
{children}
|
||||
</AnimatedPressable>
|
||||
<MessageMenu message={message} control={menuControl} />
|
||||
</View>
|
||||
<MessageMenu message={message} control={menuControl} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {ModerationCause} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {listUriToHref} from 'lib/strings/url-helpers'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {DialogControlProps} from '#/components/Dialog'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function BlockedByListDialog({
|
||||
control,
|
||||
listBlocks,
|
||||
}: {
|
||||
control: DialogControlProps
|
||||
listBlocks: ModerationCause[]
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<Prompt.Outer control={control} testID="blockedByListDialog">
|
||||
<Prompt.TitleText>{_(msg`User blocked by list`)}</Prompt.TitleText>
|
||||
|
||||
<View style={[a.gap_sm, a.pb_lg]}>
|
||||
<Text
|
||||
selectable
|
||||
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
|
||||
{_(
|
||||
msg`This account is blocked by one or more of your moderation lists. To unblock, please visit the lists directly and remove this user.`,
|
||||
)}{' '}
|
||||
</Text>
|
||||
|
||||
<Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
|
||||
{_(msg`Lists blocking this user:`)}{' '}
|
||||
{listBlocks.map((block, i) =>
|
||||
block.source.type === 'list' ? (
|
||||
<React.Fragment key={block.source.list.uri}>
|
||||
{i === 0 ? null : ', '}
|
||||
<InlineLinkText
|
||||
label={block.source.list.name}
|
||||
to={listUriToHref(block.source.list.uri)}
|
||||
style={[a.text_md, a.leading_snug]}>
|
||||
{block.source.list.name}
|
||||
</InlineLinkText>
|
||||
</React.Fragment>
|
||||
) : null,
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Prompt.Actions>
|
||||
<Prompt.Action cta={_(msg`I understand`)} onPress={() => {}} />
|
||||
</Prompt.Actions>
|
||||
|
||||
<Dialog.Close />
|
||||
</Prompt.Outer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import React from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {ScaleAndFadeIn} from 'lib/custom-animations/ScaleAndFade'
|
||||
import {ShrinkAndPop} from 'lib/custom-animations/ShrinkAndPop'
|
||||
import {useHaptics} from 'lib/haptics'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
|
||||
|
||||
let lastIndex = 0
|
||||
|
||||
export function ChatEmptyPill() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const playHaptic = useHaptics()
|
||||
const [promptIndex, setPromptIndex] = React.useState(lastIndex)
|
||||
|
||||
const scale = useSharedValue(1)
|
||||
|
||||
const prompts = React.useMemo(() => {
|
||||
return [
|
||||
_(msg`Say hello!`),
|
||||
_(msg`Share your favorite feed!`),
|
||||
_(msg`Tell a joke!`),
|
||||
_(msg`Share a fun fact!`),
|
||||
_(msg`Share a cool story!`),
|
||||
_(msg`Send a neat website!`),
|
||||
_(msg`Clip 🐴 clop 🐴`),
|
||||
]
|
||||
}, [_])
|
||||
|
||||
const onPressIn = React.useCallback(() => {
|
||||
if (isWeb) return
|
||||
scale.value = withTiming(1.075, {duration: 100})
|
||||
}, [scale])
|
||||
|
||||
const onPressOut = React.useCallback(() => {
|
||||
if (isWeb) return
|
||||
scale.value = withTiming(1, {duration: 100})
|
||||
}, [scale])
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
runOnJS(playHaptic)()
|
||||
let randomPromptIndex = Math.floor(Math.random() * prompts.length)
|
||||
while (randomPromptIndex === lastIndex) {
|
||||
randomPromptIndex = Math.floor(Math.random() * prompts.length)
|
||||
}
|
||||
setPromptIndex(randomPromptIndex)
|
||||
lastIndex = randomPromptIndex
|
||||
}, [playHaptic, prompts.length])
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{scale: scale.value}],
|
||||
}))
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.w_full,
|
||||
a.z_10,
|
||||
a.align_center,
|
||||
{
|
||||
top: -50,
|
||||
},
|
||||
]}>
|
||||
<AnimatedPressable
|
||||
style={[
|
||||
a.px_xl,
|
||||
a.py_md,
|
||||
a.rounded_full,
|
||||
t.atoms.bg_contrast_25,
|
||||
a.align_center,
|
||||
animatedStyle,
|
||||
]}
|
||||
entering={ScaleAndFadeIn}
|
||||
exiting={ShrinkAndPop}
|
||||
onPress={onPress}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}>
|
||||
<Text style={[a.font_bold, a.pointer_events_none]} selectable={false}>
|
||||
{prompts[promptIndex]}
|
||||
</Text>
|
||||
</AnimatedPressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
+106
-155
@@ -3,37 +3,37 @@ import {Keyboard, Pressable, View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
ChatBskyConvoDefs,
|
||||
ModerationDecision,
|
||||
ModerationCause,
|
||||
} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {listUriToHref} from '#/lib/strings/url-helpers'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {
|
||||
useConvoQuery,
|
||||
useMarkAsReadMutation,
|
||||
} from '#/state/queries/messages/conversation'
|
||||
import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
|
||||
import {useMuteConvo} from '#/state/queries/messages/mute-conversation'
|
||||
import {useProfileBlockMutationQueue} from '#/state/queries/profile'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
|
||||
import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog'
|
||||
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
|
||||
import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt'
|
||||
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft'
|
||||
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
|
||||
import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
|
||||
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
||||
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
|
||||
import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck'
|
||||
import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX'
|
||||
import {
|
||||
Person_Stroke2_Corner0_Rounded as Person,
|
||||
PersonCheck_Stroke2_Corner0_Rounded as PersonCheck,
|
||||
PersonX_Stroke2_Corner0_Rounded as PersonX,
|
||||
} from '#/components/icons/Person'
|
||||
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '../icons/Bubble'
|
||||
|
||||
let ConvoMenu = ({
|
||||
@@ -43,8 +43,8 @@ let ConvoMenu = ({
|
||||
currentScreen,
|
||||
showMarkAsRead,
|
||||
hideTrigger,
|
||||
triggerOpacity,
|
||||
moderation,
|
||||
blockInfo,
|
||||
style,
|
||||
}: {
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
|
||||
@@ -52,8 +52,11 @@ let ConvoMenu = ({
|
||||
currentScreen: 'list' | 'conversation'
|
||||
showMarkAsRead?: boolean
|
||||
hideTrigger?: boolean
|
||||
triggerOpacity?: number
|
||||
moderation: ModerationDecision
|
||||
blockInfo: {
|
||||
listBlocks: ModerationCause[]
|
||||
userBlock?: ModerationCause
|
||||
}
|
||||
style?: ViewStyleProp['style']
|
||||
}): React.ReactNode => {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {_} = useLingui()
|
||||
@@ -62,17 +65,10 @@ let ConvoMenu = ({
|
||||
const reportControl = Prompt.usePromptControl()
|
||||
const blockedByListControl = Prompt.usePromptControl()
|
||||
const {mutate: markAsRead} = useMarkAsReadMutation()
|
||||
const modui = moderation.ui('profileView')
|
||||
const {listBlocks, userBlock} = React.useMemo(() => {
|
||||
const blocks = modui.alerts.filter(alert => alert.type === 'blocking')
|
||||
const listBlocks = blocks.filter(alert => alert.source.type === 'list')
|
||||
const userBlock = blocks.find(alert => alert.source.type === 'user')
|
||||
return {
|
||||
listBlocks,
|
||||
userBlock,
|
||||
}
|
||||
}, [modui])
|
||||
const isBlocking = !!userBlock || !!listBlocks.length
|
||||
|
||||
const {listBlocks, userBlock} = blockInfo
|
||||
const isBlocking = userBlock || !!listBlocks.length
|
||||
const isDeletedAccount = profile.handle === 'missing.invalid'
|
||||
|
||||
const {data: convo} = useConvoQuery(initialConvo)
|
||||
|
||||
@@ -89,7 +85,7 @@ let ConvoMenu = ({
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
Toast.show(_(msg`Could not mute chat`))
|
||||
Toast.show(_(msg`Could not mute chat`), 'xmark')
|
||||
},
|
||||
})
|
||||
|
||||
@@ -108,22 +104,11 @@ let ConvoMenu = ({
|
||||
}
|
||||
}, [userBlock, listBlocks, blockedByListControl, queueBlock, queueUnblock])
|
||||
|
||||
const {mutate: leaveConvo} = useLeaveConvo(convo?.id, {
|
||||
onSuccess: () => {
|
||||
if (currentScreen === 'conversation') {
|
||||
navigation.replace('Messages')
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
Toast.show(_(msg`Could not leave chat`))
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu.Root control={control}>
|
||||
{!hideTrigger && (
|
||||
<View style={{opacity: triggerOpacity}}>
|
||||
<View style={[style]}>
|
||||
<Menu.Trigger label={_(msg`Chat settings`)}>
|
||||
{({props, state}) => (
|
||||
<Pressable
|
||||
@@ -146,66 +131,9 @@ let ConvoMenu = ({
|
||||
</Menu.Trigger>
|
||||
</View>
|
||||
)}
|
||||
<Menu.Outer>
|
||||
<Menu.Group>
|
||||
{showMarkAsRead && (
|
||||
<Menu.Item
|
||||
label={_(msg`Mark as read`)}
|
||||
onPress={() =>
|
||||
markAsRead({
|
||||
convoId: convo?.id,
|
||||
})
|
||||
}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Mark as read</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Bubble} />
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
label={_(msg`Go to user's profile`)}
|
||||
onPress={onNavigateToProfile}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Go to profile</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Person} />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
label={_(msg`Mute notifications`)}
|
||||
onPress={() => muteConvo({mute: !convo?.muted})}>
|
||||
<Menu.ItemText>
|
||||
{convo?.muted ? (
|
||||
<Trans>Unmute notifications</Trans>
|
||||
) : (
|
||||
<Trans>Mute notifications</Trans>
|
||||
)}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={convo?.muted ? Unmute : Mute} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<Menu.Divider />
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
label={
|
||||
isBlocking ? _(msg`Unblock account`) : _(msg`Block account`)
|
||||
}
|
||||
onPress={toggleBlock}>
|
||||
<Menu.ItemText>
|
||||
{isBlocking ? _(msg`Unblock account`) : _(msg`Block account`)}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={isBlocking ? PersonX : PersonCheck} />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
label={_(msg`Report conversation`)}
|
||||
onPress={reportControl.open}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Report conversation</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Flag} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<Menu.Divider />
|
||||
<Menu.Group>
|
||||
|
||||
{isDeletedAccount ? (
|
||||
<Menu.Outer>
|
||||
<Menu.Item
|
||||
label={_(msg`Leave conversation`)}
|
||||
onPress={leaveConvoControl.open}>
|
||||
@@ -214,71 +142,94 @@ let ConvoMenu = ({
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={ArrowBoxLeft} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
</Menu.Outer>
|
||||
</Menu.Outer>
|
||||
) : (
|
||||
<Menu.Outer>
|
||||
<Menu.Group>
|
||||
{showMarkAsRead && (
|
||||
<Menu.Item
|
||||
label={_(msg`Mark as read`)}
|
||||
onPress={() =>
|
||||
markAsRead({
|
||||
convoId: convo?.id,
|
||||
})
|
||||
}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Mark as read</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Bubble} />
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
label={_(msg`Go to user's profile`)}
|
||||
onPress={onNavigateToProfile}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Go to profile</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Person} />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
label={_(msg`Mute conversation`)}
|
||||
onPress={() => muteConvo({mute: !convo?.muted})}>
|
||||
<Menu.ItemText>
|
||||
{convo?.muted ? (
|
||||
<Trans>Unmute conversation</Trans>
|
||||
) : (
|
||||
<Trans>Mute conversation</Trans>
|
||||
)}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={convo?.muted ? Unmute : Mute} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<Menu.Divider />
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
label={
|
||||
isBlocking ? _(msg`Unblock account`) : _(msg`Block account`)
|
||||
}
|
||||
onPress={toggleBlock}>
|
||||
<Menu.ItemText>
|
||||
{isBlocking ? _(msg`Unblock account`) : _(msg`Block account`)}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={isBlocking ? PersonCheck : PersonX} />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
label={_(msg`Report conversation`)}
|
||||
onPress={reportControl.open}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Report conversation</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Flag} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<Menu.Divider />
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
label={_(msg`Leave conversation`)}
|
||||
onPress={leaveConvoControl.open}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Leave conversation</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={ArrowBoxLeft} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
</Menu.Outer>
|
||||
)}
|
||||
</Menu.Root>
|
||||
|
||||
<Prompt.Basic
|
||||
<LeaveConvoPrompt
|
||||
control={leaveConvoControl}
|
||||
title={_(msg`Leave conversation`)}
|
||||
description={_(
|
||||
msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for other participants.`,
|
||||
)}
|
||||
confirmButtonCta={_(msg`Leave`)}
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={() => leaveConvo()}
|
||||
convoId={convo.id}
|
||||
currentScreen={currentScreen}
|
||||
/>
|
||||
|
||||
<Prompt.Basic
|
||||
control={reportControl}
|
||||
title={_(msg`Report conversation`)}
|
||||
description={_(
|
||||
msg`To report a conversation, please report one of its messages via the conversation screen. This lets our moderators understand the context of your issue.`,
|
||||
)}
|
||||
confirmButtonCta={_(msg`I understand`)}
|
||||
onConfirm={noop}
|
||||
<ReportConversationPrompt control={reportControl} />
|
||||
<BlockedByListDialog
|
||||
control={blockedByListControl}
|
||||
listBlocks={listBlocks}
|
||||
/>
|
||||
|
||||
<Prompt.Outer control={blockedByListControl} testID="blockedByListDialog">
|
||||
<Prompt.TitleText>{_(msg`User blocked by list`)}</Prompt.TitleText>
|
||||
|
||||
<View style={[a.gap_sm, a.pb_lg]}>
|
||||
<Text
|
||||
selectable
|
||||
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
|
||||
{_(
|
||||
msg`This account is blocked by one or more of your moderation lists. To unblock, please visit the lists directly and remove this user.`,
|
||||
)}{' '}
|
||||
</Text>
|
||||
|
||||
<Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
|
||||
{_(msg`Lists blocking this user:`)}{' '}
|
||||
{listBlocks.map((block, i) =>
|
||||
block.source.type === 'list' ? (
|
||||
<React.Fragment key={block.source.list.uri}>
|
||||
{i === 0 ? null : ', '}
|
||||
<InlineLinkText
|
||||
to={listUriToHref(block.source.list.uri)}
|
||||
style={[a.text_md, a.leading_snug]}>
|
||||
{block.source.list.name}
|
||||
</InlineLinkText>
|
||||
</React.Fragment>
|
||||
) : null,
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Prompt.Actions>
|
||||
<Prompt.Cancel cta={_(msg`I understand`)} />
|
||||
</Prompt.Actions>
|
||||
|
||||
<Dialog.Close />
|
||||
</Prompt.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
ConvoMenu = React.memo(ConvoMenu)
|
||||
|
||||
export {ConvoMenu}
|
||||
|
||||
function noop() {}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {useLeaveConvo} from 'state/queries/messages/leave-conversation'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {DialogOuterProps} from '#/components/Dialog'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
|
||||
export function LeaveConvoPrompt({
|
||||
control,
|
||||
convoId,
|
||||
currentScreen,
|
||||
}: {
|
||||
control: DialogOuterProps['control']
|
||||
convoId: string
|
||||
currentScreen: 'list' | 'conversation'
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
const {mutate: leaveConvo} = useLeaveConvo(convoId, {
|
||||
onSuccess: () => {
|
||||
if (currentScreen === 'conversation') {
|
||||
navigation.replace(
|
||||
'Messages',
|
||||
isNative
|
||||
? {
|
||||
animation: 'pop',
|
||||
}
|
||||
: {},
|
||||
)
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
Toast.show(_(msg`Could not leave chat`), 'xmark')
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Prompt.Basic
|
||||
control={control}
|
||||
title={_(msg`Leave conversation`)}
|
||||
description={_(
|
||||
msg`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`,
|
||||
)}
|
||||
confirmButtonCta={_(msg`Leave`)}
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={() => leaveConvo()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
TextStyle,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {ChatBskyConvoDefs, RichText as RichTextAPI} from '@atproto/api'
|
||||
import {
|
||||
AppBskyEmbedRecord,
|
||||
ChatBskyConvoDefs,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -17,7 +21,8 @@ import {atoms as a, useTheme} from '#/alf'
|
||||
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {RichText} from '../RichText'
|
||||
import {isOnlyEmoji, RichText} from '../RichText'
|
||||
import {MessageItemEmbed} from './MessageItemEmbed'
|
||||
|
||||
let MessageItem = ({
|
||||
item,
|
||||
@@ -67,47 +72,50 @@ let MessageItem = ({
|
||||
lastInGroupRef.current = isLastInGroup
|
||||
}
|
||||
|
||||
const pendingColor =
|
||||
t.name === 'light' ? t.palette.primary_200 : t.palette.primary_800
|
||||
const pendingColor = t.palette.primary_200
|
||||
|
||||
const rt = useMemo(() => {
|
||||
return new RichTextAPI({text: message.text, facets: message.facets})
|
||||
}, [message.text, message.facets])
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={[isFromSelf ? a.mr_md : a.ml_md]}>
|
||||
<ActionsWrapper isFromSelf={isFromSelf} message={message}>
|
||||
<View
|
||||
style={[
|
||||
a.py_sm,
|
||||
a.my_2xs,
|
||||
a.rounded_md,
|
||||
{
|
||||
paddingLeft: 14,
|
||||
paddingRight: 14,
|
||||
backgroundColor: isFromSelf
|
||||
? isPending
|
||||
? pendingColor
|
||||
: t.palette.primary_500
|
||||
: t.palette.contrast_50,
|
||||
borderRadius: 17,
|
||||
},
|
||||
isFromSelf
|
||||
? {borderBottomRightRadius: isLastInGroup ? 2 : 17}
|
||||
: {borderBottomLeftRadius: isLastInGroup ? 2 : 17},
|
||||
]}>
|
||||
<RichText
|
||||
value={rt}
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
isFromSelf && {color: t.palette.white},
|
||||
isPending && t.name !== 'light' && {color: t.palette.primary_300},
|
||||
]}
|
||||
interactiveStyle={a.underline}
|
||||
enableTags
|
||||
/>
|
||||
</View>
|
||||
{AppBskyEmbedRecord.isView(message.embed) && (
|
||||
<MessageItemEmbed embed={message.embed} />
|
||||
)}
|
||||
{rt.text.length > 0 && (
|
||||
<View
|
||||
style={
|
||||
!isOnlyEmoji(message.text) && [
|
||||
a.py_sm,
|
||||
a.my_2xs,
|
||||
a.rounded_md,
|
||||
{
|
||||
paddingLeft: 14,
|
||||
paddingRight: 14,
|
||||
backgroundColor: isFromSelf
|
||||
? isPending
|
||||
? pendingColor
|
||||
: t.palette.primary_500
|
||||
: t.palette.contrast_50,
|
||||
borderRadius: 17,
|
||||
},
|
||||
isFromSelf ? a.self_end : a.self_start,
|
||||
isFromSelf
|
||||
? {borderBottomRightRadius: isLastInGroup ? 2 : 17}
|
||||
: {borderBottomLeftRadius: isLastInGroup ? 2 : 17},
|
||||
]
|
||||
}>
|
||||
<RichText
|
||||
value={rt}
|
||||
style={[a.text_md, isFromSelf && {color: t.palette.white}]}
|
||||
interactiveStyle={a.underline}
|
||||
enableTags
|
||||
emojiMultiplier={3}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</ActionsWrapper>
|
||||
|
||||
{isLastInGroup && (
|
||||
@@ -202,7 +210,7 @@ let MessageItemMetadata = ({
|
||||
)}
|
||||
</TimeElapsed>
|
||||
|
||||
{item.type === 'pending-message' && item.retry && (
|
||||
{item.type === 'pending-message' && item.failed && (
|
||||
<>
|
||||
{' '}
|
||||
·{' '}
|
||||
@@ -214,15 +222,20 @@ let MessageItemMetadata = ({
|
||||
},
|
||||
]}>
|
||||
{_(msg`Failed to send`)}
|
||||
</Text>{' '}
|
||||
·{' '}
|
||||
<InlineLinkText
|
||||
label={_(msg`Click to retry failed message`)}
|
||||
to="#"
|
||||
onPress={handleRetry}
|
||||
style={[a.text_xs]}>
|
||||
{_(msg`Retry`)}
|
||||
</InlineLinkText>
|
||||
</Text>
|
||||
{item.retry && (
|
||||
<>
|
||||
{' '}
|
||||
·{' '}
|
||||
<InlineLinkText
|
||||
label={_(msg`Click to retry failed message`)}
|
||||
to="#"
|
||||
onPress={handleRetry}
|
||||
style={[a.text_xs]}>
|
||||
{_(msg`Retry`)}
|
||||
</InlineLinkText>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyEmbedRecord} from '@atproto/api'
|
||||
|
||||
import {PostEmbeds} from '#/view/com/util/post-embeds'
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
|
||||
let MessageItemEmbed = ({
|
||||
embed,
|
||||
}: {
|
||||
embed: AppBskyEmbedRecord.View
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
|
||||
<PostEmbeds embed={embed} allowNestedQuotes />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
MessageItemEmbed = React.memo(MessageItemEmbed)
|
||||
export {MessageItemEmbed}
|
||||
@@ -6,11 +6,16 @@ import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||
import {getTranslatorLink} from '#/locale/helpers'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
import {useOpenLink} from '#/state/preferences/in-app-browser'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {useConvoActive} from 'state/messages/convo'
|
||||
import {useSession} from 'state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ReportDialog} from '#/components/dms/ReportDialog'
|
||||
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
|
||||
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
|
||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
|
||||
@@ -18,7 +23,6 @@ import * as Menu from '#/components/Menu'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {usePromptControl} from '#/components/Prompt'
|
||||
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '../icons/Clipboard'
|
||||
import {MessageReportDialog} from './MessageReportDialog'
|
||||
|
||||
export let MessageMenu = ({
|
||||
message,
|
||||
@@ -34,12 +38,13 @@ export let MessageMenu = ({
|
||||
const {currentAccount} = useSession()
|
||||
const convo = useConvoActive()
|
||||
const deleteControl = usePromptControl()
|
||||
const retryDeleteControl = usePromptControl()
|
||||
const reportControl = usePromptControl()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const openLink = useOpenLink()
|
||||
|
||||
const isFromSelf = message.sender?.did === currentAccount?.did
|
||||
|
||||
const onCopyPostText = React.useCallback(() => {
|
||||
const onCopyMessage = React.useCallback(() => {
|
||||
const str = richTextToString(
|
||||
new RichText({
|
||||
text: message.text,
|
||||
@@ -49,16 +54,24 @@ export let MessageMenu = ({
|
||||
)
|
||||
|
||||
Clipboard.setStringAsync(str)
|
||||
Toast.show(_(msg`Copied to clipboard`))
|
||||
Toast.show(_(msg`Copied to clipboard`), 'clipboard-check')
|
||||
}, [_, message.text, message.facets])
|
||||
|
||||
const onPressTranslateMessage = React.useCallback(() => {
|
||||
const translatorUrl = getTranslatorLink(
|
||||
message.text,
|
||||
langPrefs.primaryLanguage,
|
||||
)
|
||||
openLink(translatorUrl)
|
||||
}, [langPrefs.primaryLanguage, message.text, openLink])
|
||||
|
||||
const onDelete = React.useCallback(() => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
convo
|
||||
.deleteMessage(message.id)
|
||||
.then(() => Toast.show(_(msg`Message deleted`)))
|
||||
.catch(() => retryDeleteControl.open())
|
||||
}, [_, convo, message.id, retryDeleteControl])
|
||||
.catch(() => Toast.show(_(msg`Failed to delete message`)))
|
||||
}, [_, convo, message.id])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -82,16 +95,27 @@ export let MessageMenu = ({
|
||||
)}
|
||||
|
||||
<Menu.Outer>
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="messageDropdownCopyBtn"
|
||||
label={_(msg`Copy message text`)}
|
||||
onPress={onCopyPostText}>
|
||||
<Menu.ItemText>{_(msg`Copy message text`)}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={ClipboardIcon} position="right" />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<Menu.Divider />
|
||||
{message.text.length > 0 && (
|
||||
<>
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="messageDropdownTranslateBtn"
|
||||
label={_(msg`Translate`)}
|
||||
onPress={onPressTranslateMessage}>
|
||||
<Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Translate} position="right" />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
testID="messageDropdownCopyBtn"
|
||||
label={_(msg`Copy message text`)}
|
||||
onPress={onCopyMessage}>
|
||||
<Menu.ItemText>{_(msg`Copy message text`)}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={ClipboardIcon} position="right" />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="messageDropdownDeleteBtn"
|
||||
@@ -113,29 +137,21 @@ export let MessageMenu = ({
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
|
||||
<MessageReportDialog message={message} control={reportControl} />
|
||||
<ReportDialog
|
||||
params={{type: 'convoMessage', convoId: convo.convo.id, message}}
|
||||
control={reportControl}
|
||||
/>
|
||||
|
||||
<Prompt.Basic
|
||||
control={deleteControl}
|
||||
title={_(msg`Delete message`)}
|
||||
description={_(
|
||||
msg`Are you sure you want to delete this message? The message will be deleted for you, but not for other participants.`,
|
||||
msg`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participant.`,
|
||||
)}
|
||||
confirmButtonCta={_(msg`Delete`)}
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
|
||||
<Prompt.Basic
|
||||
control={retryDeleteControl}
|
||||
title={_(msg`Failed to delete message`)}
|
||||
description={_(
|
||||
msg`An error occurred while trying to delete the message. Please try again.`,
|
||||
)}
|
||||
confirmButtonCta={_(msg`Retry`)}
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useMaybeConvoForUser} from '#/state/queries/messages/get-convo-for-members'
|
||||
import {logEvent} from 'lib/statsig/statsig'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Message_Stroke2_Corner0_Rounded as Message} from '../icons/Message'
|
||||
import {Link} from '../Link'
|
||||
import {canBeMessaged} from './util'
|
||||
|
||||
export function MessageProfileButton({
|
||||
profile,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileView
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const {data: convo, isPending} = useMaybeConvoForUser(profile.did)
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
if (convo && !convo.lastMessage) {
|
||||
logEvent('chat:create', {logContext: 'ProfileHeader'})
|
||||
}
|
||||
logEvent('chat:open', {logContext: 'ProfileHeader'})
|
||||
}, [convo])
|
||||
|
||||
if (isPending) {
|
||||
// show pending state based on declaration
|
||||
if (canBeMessaged(profile)) {
|
||||
return (
|
||||
<View
|
||||
testID="dmBtnLoading"
|
||||
aria-hidden={true}
|
||||
style={[
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
t.atoms.bg_contrast_25,
|
||||
a.rounded_full,
|
||||
{width: 36, height: 36},
|
||||
]}>
|
||||
<Message
|
||||
style={[
|
||||
t.atoms.text,
|
||||
{marginLeft: 1, marginBottom: 1, opacity: 0.3},
|
||||
]}
|
||||
size="md"
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (convo) {
|
||||
return (
|
||||
<Link
|
||||
testID="dmBtn"
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
shape="round"
|
||||
label={_(msg`Message ${profile.handle}`)}
|
||||
to={`/messages/${convo.id}`}
|
||||
style={[a.justify_center, {width: 36, height: 36}]}
|
||||
onPress={onPress}>
|
||||
<Message
|
||||
style={[t.atoms.text, {marginLeft: 1, marginBottom: 1}]}
|
||||
size="md"
|
||||
/>
|
||||
</Link>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs, ModerationCause} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||
import {useProfileBlockMutationQueue} from 'state/queries/profile'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog'
|
||||
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
|
||||
import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function MessagesListBlockedFooter({
|
||||
recipient: initialRecipient,
|
||||
convoId,
|
||||
hasMessages,
|
||||
blockInfo,
|
||||
}: {
|
||||
recipient: AppBskyActorDefs.ProfileViewBasic
|
||||
convoId: string
|
||||
hasMessages: boolean
|
||||
blockInfo: {
|
||||
listBlocks: ModerationCause[]
|
||||
userBlock: ModerationCause | undefined
|
||||
}
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {_} = useLingui()
|
||||
const recipient = useProfileShadow(initialRecipient)
|
||||
const [__, queueUnblock] = useProfileBlockMutationQueue(recipient)
|
||||
|
||||
const leaveConvoControl = useDialogControl()
|
||||
const reportControl = useDialogControl()
|
||||
const blockedByListControl = useDialogControl()
|
||||
|
||||
const {listBlocks, userBlock} = blockInfo
|
||||
const isBlocking = !!userBlock || !!listBlocks.length
|
||||
|
||||
const onUnblockPress = React.useCallback(() => {
|
||||
if (listBlocks.length) {
|
||||
blockedByListControl.open()
|
||||
} else {
|
||||
queueUnblock()
|
||||
}
|
||||
}, [blockedByListControl, listBlocks, queueUnblock])
|
||||
|
||||
return (
|
||||
<View style={[hasMessages && a.pt_md, a.pb_xl, a.gap_lg]}>
|
||||
<Divider />
|
||||
<Text style={[a.text_md, a.font_bold, a.text_center]}>
|
||||
{isBlocking ? (
|
||||
<Trans>You have blocked this user</Trans>
|
||||
) : (
|
||||
<Trans>This user has blocked you</Trans>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<View style={[a.flex_row, a.justify_between, a.gap_lg, a.px_md]}>
|
||||
<Button
|
||||
label={_(msg`Leave chat`)}
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
size="small"
|
||||
style={[a.flex_1]}
|
||||
onPress={leaveConvoControl.open}>
|
||||
<ButtonText style={{color: t.palette.negative_500}}>
|
||||
<Trans>Leave chat</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
<Button
|
||||
label={_(msg`Report`)}
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
size="small"
|
||||
style={[a.flex_1]}
|
||||
onPress={reportControl.open}>
|
||||
<ButtonText style={{color: t.palette.negative_500}}>
|
||||
<Trans>Report</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
{isBlocking && gtMobile && (
|
||||
<Button
|
||||
label={_(msg`Unblock`)}
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
size="small"
|
||||
style={[a.flex_1]}
|
||||
onPress={onUnblockPress}>
|
||||
<ButtonText style={{color: t.palette.primary_500}}>
|
||||
<Trans>Unblock</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
{isBlocking && !gtMobile && (
|
||||
<View style={[a.flex_row, a.justify_center, a.px_md]}>
|
||||
<Button
|
||||
label={_(msg`Unblock`)}
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
size="small"
|
||||
style={[a.flex_1]}
|
||||
onPress={onUnblockPress}>
|
||||
<ButtonText style={{color: t.palette.primary_500}}>
|
||||
<Trans>Unblock</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<LeaveConvoPrompt
|
||||
control={leaveConvoControl}
|
||||
currentScreen="conversation"
|
||||
convoId={convoId}
|
||||
/>
|
||||
|
||||
<ReportConversationPrompt control={reportControl} />
|
||||
|
||||
<BlockedByListDialog
|
||||
control={blockedByListControl}
|
||||
listBlocks={listBlocks}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {TouchableOpacity, View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
ModerationCause,
|
||||
ModerationDecision,
|
||||
} from '@atproto/api'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {BACK_HITSLOP} from 'lib/constants'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||
import {isConvoActive, useConvo} from 'state/messages/convo'
|
||||
import {PreviewableUserAvatar} from 'view/com/util/UserAvatar'
|
||||
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
|
||||
import {ConvoMenu} from '#/components/dms/ConvoMenu'
|
||||
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
|
||||
import {Link} from '#/components/Link'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const PFP_SIZE = isWeb ? 40 : 34
|
||||
|
||||
export let MessagesListHeader = ({
|
||||
profile,
|
||||
moderation,
|
||||
blockInfo,
|
||||
}: {
|
||||
profile?: AppBskyActorDefs.ProfileViewBasic
|
||||
moderation?: ModerationDecision
|
||||
blockInfo?: {
|
||||
listBlocks: ModerationCause[]
|
||||
userBlock?: ModerationCause
|
||||
}
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtTablet} = useBreakpoints()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
const onPressBack = useCallback(() => {
|
||||
if (isWeb) {
|
||||
navigation.replace('Messages', {})
|
||||
} else {
|
||||
navigation.goBack()
|
||||
}
|
||||
}, [navigation])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_low,
|
||||
a.border_b,
|
||||
a.flex_row,
|
||||
a.align_start,
|
||||
a.gap_sm,
|
||||
gtTablet ? a.pl_lg : a.pl_xl,
|
||||
a.pr_lg,
|
||||
a.py_sm,
|
||||
]}>
|
||||
{!gtTablet && (
|
||||
<TouchableOpacity
|
||||
testID="conversationHeaderBackBtn"
|
||||
onPress={onPressBack}
|
||||
hitSlop={BACK_HITSLOP}
|
||||
style={{width: 30, height: 30, marginTop: isWeb ? 6 : 4}}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Back`)}
|
||||
accessibilityHint="">
|
||||
<FontAwesomeIcon
|
||||
size={18}
|
||||
icon="angle-left"
|
||||
style={{
|
||||
marginTop: 6,
|
||||
}}
|
||||
color={t.atoms.text.color}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{profile && moderation && blockInfo ? (
|
||||
<HeaderReady
|
||||
profile={profile}
|
||||
moderation={moderation}
|
||||
blockInfo={blockInfo}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_md, a.flex_1]}>
|
||||
<View
|
||||
style={[
|
||||
{width: PFP_SIZE, height: PFP_SIZE},
|
||||
a.rounded_full,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
/>
|
||||
<View style={a.gap_xs}>
|
||||
<View
|
||||
style={[
|
||||
{width: 120, height: 16},
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
a.mt_xs,
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
{width: 175, height: 12},
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{width: 30}} />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
MessagesListHeader = React.memo(MessagesListHeader)
|
||||
|
||||
function HeaderReady({
|
||||
profile: profileUnshadowed,
|
||||
moderation,
|
||||
blockInfo,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewBasic
|
||||
moderation: ModerationDecision
|
||||
blockInfo: {
|
||||
listBlocks: ModerationCause[]
|
||||
userBlock?: ModerationCause
|
||||
}
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const convoState = useConvo()
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
|
||||
const isDeletedAccount = profile?.handle === 'missing.invalid'
|
||||
const displayName = isDeletedAccount
|
||||
? 'Deleted Account'
|
||||
: sanitizeDisplayName(
|
||||
profile.displayName || profile.handle,
|
||||
moderation.ui('displayName'),
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1]}>
|
||||
<View style={[a.w_full, a.flex_row, a.align_center, a.justify_between]}>
|
||||
<Link
|
||||
label={_(msg`View ${displayName}'s profile`)}
|
||||
style={[a.flex_row, a.align_start, a.gap_md, a.flex_1, a.pr_md]}
|
||||
to={makeProfileLink(profile)}>
|
||||
<View style={[a.pt_2xs]}>
|
||||
<PreviewableUserAvatar
|
||||
size={PFP_SIZE}
|
||||
profile={profile}
|
||||
moderation={moderation.ui('avatar')}
|
||||
disableHoverCard={moderation.blocked}
|
||||
/>
|
||||
</View>
|
||||
<View style={a.flex_1}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
a.self_start,
|
||||
web(a.leading_normal),
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
{!isDeletedAccount && (
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
a.text_sm,
|
||||
web([a.leading_normal, {marginTop: -2}]),
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
@{profile.handle}
|
||||
{convoState.convo?.muted && (
|
||||
<>
|
||||
{' '}
|
||||
·{' '}
|
||||
<BellStroke
|
||||
size="xs"
|
||||
style={t.atoms.text_contrast_medium}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</Link>
|
||||
|
||||
{isConvoActive(convoState) && (
|
||||
<ConvoMenu
|
||||
convo={convoState.convo}
|
||||
profile={profile}
|
||||
currentScreen="conversation"
|
||||
blockInfo={blockInfo}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
paddingLeft: PFP_SIZE + a.gap_md.gap,
|
||||
},
|
||||
]}>
|
||||
<PostAlerts
|
||||
modui={moderation.ui('contentList')}
|
||||
size="lg"
|
||||
style={[a.pt_xs]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import React, {useCallback, useEffect} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {ChatBskyActorDeclaration} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useUpdateActorDeclaration} from '#/state/queries/messages/actor-declaration'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Message_Stroke2_Corner0_Rounded} from '#/components/icons/Message'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function MessagesNUX() {
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
const {currentAccount} = useSession()
|
||||
const {data: profile} = useProfileQuery({
|
||||
did: currentAccount!.did,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (profile && typeof profile.associated?.chat === 'undefined') {
|
||||
const timeout = setTimeout(() => {
|
||||
control.open()
|
||||
}, 1000)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [profile, control])
|
||||
|
||||
if (!profile) return null
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<DialogInner chatDeclation={profile.associated?.chat} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogInner({
|
||||
chatDeclation,
|
||||
}: {
|
||||
chatDeclation?: ChatBskyActorDeclaration.Record
|
||||
}) {
|
||||
const control = Dialog.useDialogContext()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const [initialized, setInitialzed] = React.useState(false)
|
||||
const {mutate: updateDeclaration} = useUpdateActorDeclaration({
|
||||
onError: () => {
|
||||
Toast.show(_(msg`Failed to update settings`), 'xmark')
|
||||
},
|
||||
})
|
||||
|
||||
const onSelectItem = useCallback(
|
||||
(keys: string[]) => {
|
||||
const key = keys[0]
|
||||
if (!key) return
|
||||
updateDeclaration(key as 'all' | 'none' | 'following')
|
||||
},
|
||||
[updateDeclaration],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatDeclation && !initialized) {
|
||||
updateDeclaration('following')
|
||||
setInitialzed(true)
|
||||
}
|
||||
}, [chatDeclation, updateDeclaration, initialized])
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Introducing Direct Messages`)}
|
||||
style={web({maxWidth: 440})}>
|
||||
<View style={a.gap_xl}>
|
||||
<View style={[a.align_center, a.pt_sm, a.pb_xs]}>
|
||||
<Message_Stroke2_Corner0_Rounded width={64} />
|
||||
<Text style={[a.text_2xl, a.font_bold, a.text_center, a.mt_md]}>
|
||||
<Trans>Direct messages are here!</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_md, a.text_center, a.mt_sm]}>
|
||||
<Trans>Privately chat with other users.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
a.gap_xs,
|
||||
a.border,
|
||||
a.overflow_hidden,
|
||||
a.rounded_sm,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.p_md,
|
||||
a.border_b,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<Text style={[a.text_sm, a.font_bold]}>
|
||||
<Trans>Who can message you?</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.mt_xs,
|
||||
a.text_sm,
|
||||
a.italic,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>You can change this at any time.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[a.px_md, a.py_xs]}>
|
||||
<Toggle.Group
|
||||
label={_(msg`Who can message you?`)}
|
||||
type="radio"
|
||||
values={[chatDeclation?.allowIncoming ?? 'following']}
|
||||
onChange={onSelectItem}>
|
||||
<View>
|
||||
<Toggle.Item
|
||||
name="all"
|
||||
label={_(msg`Everyone`)}
|
||||
style={[a.justify_between, a.py_sm, a.rounded_2xs]}>
|
||||
<Toggle.LabelText>
|
||||
<Trans>Everyone</Trans>
|
||||
</Toggle.LabelText>
|
||||
<Toggle.Radio />
|
||||
</Toggle.Item>
|
||||
<Toggle.Item
|
||||
name="following"
|
||||
label={_(msg`Users I follow`)}
|
||||
style={[a.justify_between, a.py_sm, a.rounded_2xs]}>
|
||||
<Toggle.LabelText>
|
||||
<Trans>Users I follow</Trans>
|
||||
</Toggle.LabelText>
|
||||
<Toggle.Radio />
|
||||
</Toggle.Item>
|
||||
<Toggle.Item
|
||||
name="none"
|
||||
label={_(msg`No one`)}
|
||||
style={[a.justify_between, a.py_sm, a.rounded_2xs]}>
|
||||
<Toggle.LabelText>
|
||||
<Trans>No one</Trans>
|
||||
</Toggle.LabelText>
|
||||
<Toggle.Radio />
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
</Toggle.Group>
|
||||
</View>
|
||||
</View>
|
||||
<Button
|
||||
label={_(msg`Start chatting`)}
|
||||
accessibilityHint={_(msg`Close modal`)}
|
||||
size="medium"
|
||||
color="primary"
|
||||
variant="solid"
|
||||
onPress={() => control.close()}>
|
||||
<ButtonText>
|
||||
<Trans>Get started</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
import React, {useCallback, useMemo, useRef, useState} from 'react'
|
||||
import {Keyboard, View} from 'react-native'
|
||||
import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
|
||||
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
|
||||
import {FAB} from '#/view/com/util/fab/FAB'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {Button} from '../Button'
|
||||
import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '../icons/Envelope'
|
||||
import {ListMaybePlaceholder} from '../Lists'
|
||||
import {Text} from '../Typography'
|
||||
import {canBeMessaged} from './util'
|
||||
|
||||
export function NewChat({
|
||||
control,
|
||||
onNewChat,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
onNewChat: (chatId: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const {mutate: createChat} = useGetConvoForMembers({
|
||||
onSuccess: data => {
|
||||
onNewChat(data.convo.id)
|
||||
},
|
||||
onError: error => {
|
||||
Toast.show(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const onCreateChat = useCallback(
|
||||
(did: string) => {
|
||||
control.close(() => createChat([did]))
|
||||
},
|
||||
[control, createChat],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<FAB
|
||||
testID="newChatFAB"
|
||||
onPress={control.open}
|
||||
icon={<Plus size="lg" fill={t.palette.white} />}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`New chat`)}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
testID="newChatDialog"
|
||||
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
|
||||
<Dialog.Handle />
|
||||
<SearchablePeopleList onCreateChat={onCreateChat} />
|
||||
</Dialog.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchablePeopleList({
|
||||
onCreateChat,
|
||||
}: {
|
||||
onCreateChat: (did: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const control = Dialog.useDialogContext()
|
||||
const listRef = useRef<BottomSheetFlatListMethods>(null)
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
const {
|
||||
data: actorAutocompleteData,
|
||||
isFetching,
|
||||
isError,
|
||||
refetch,
|
||||
} = useActorAutocompleteQuery(searchText, true)
|
||||
|
||||
const renderItem = useCallback(
|
||||
({item: profile}: {item: AppBskyActorDefs.ProfileView}) => {
|
||||
if (!moderationOpts) return null
|
||||
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
|
||||
const disabled = !canBeMessaged(profile)
|
||||
const handle = sanitizeHandle(profile.handle, '@')
|
||||
|
||||
return (
|
||||
<Button
|
||||
label={profile.displayName || sanitizeHandle(profile.handle)}
|
||||
onPress={() => !disabled && onCreateChat(profile.did)}>
|
||||
{({hovered, pressed, focused}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.px_md,
|
||||
a.py_sm,
|
||||
a.gap_md,
|
||||
a.align_center,
|
||||
a.flex_row,
|
||||
a.rounded_sm,
|
||||
disabled
|
||||
? {opacity: 0.5}
|
||||
: pressed || focused
|
||||
? t.atoms.bg_contrast_25
|
||||
: hovered
|
||||
? t.atoms.bg_contrast_50
|
||||
: t.atoms.bg,
|
||||
]}>
|
||||
<UserAvatar
|
||||
size={40}
|
||||
avatar={profile.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
<View style={{flex: 1}}>
|
||||
<Text
|
||||
style={[t.atoms.text, a.font_bold, a.leading_snug]}
|
||||
numberOfLines={1}>
|
||||
{sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
)}
|
||||
</Text>
|
||||
<Text style={t.atoms.text_contrast_high} numberOfLines={2}>
|
||||
{disabled ? (
|
||||
<Trans>{handle} can't be messaged</Trans>
|
||||
) : (
|
||||
handle
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
[
|
||||
moderationOpts,
|
||||
onCreateChat,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.bg_contrast_50,
|
||||
t.atoms.bg,
|
||||
t.atoms.text,
|
||||
t.atoms.text_contrast_high,
|
||||
],
|
||||
)
|
||||
|
||||
const listHeader = useMemo(() => {
|
||||
return (
|
||||
<View style={[a.relative, a.mb_lg]}>
|
||||
{/* cover top corners */}
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
{
|
||||
borderBottomLeftRadius: 8,
|
||||
borderBottomRightRadius: 8,
|
||||
},
|
||||
t.atoms.bg,
|
||||
]}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
a.text_2xl,
|
||||
a.font_bold,
|
||||
a.leading_tight,
|
||||
a.pb_lg,
|
||||
web(a.pt_lg),
|
||||
]}>
|
||||
<Trans>Start a new chat</Trans>
|
||||
</Text>
|
||||
<TextField.Root>
|
||||
<TextField.Icon icon={Search} />
|
||||
<Dialog.Input
|
||||
label={_(msg`Search profiles`)}
|
||||
placeholder={_(msg`Search`)}
|
||||
value={searchText}
|
||||
onChangeText={text => {
|
||||
setSearchText(text)
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||
}}
|
||||
returnKeyType="search"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={50}
|
||||
onKeyPress={({nativeEvent}) => {
|
||||
if (nativeEvent.key === 'Escape') {
|
||||
control.close()
|
||||
}
|
||||
}}
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
autoFocus
|
||||
/>
|
||||
</TextField.Root>
|
||||
<Dialog.Close />
|
||||
</View>
|
||||
)
|
||||
}, [t.atoms.bg, _, control, searchText])
|
||||
|
||||
const dataWithoutSelf = useMemo(() => {
|
||||
return (
|
||||
actorAutocompleteData?.filter(
|
||||
profile => profile.did !== currentAccount?.did,
|
||||
) ?? []
|
||||
)
|
||||
}, [actorAutocompleteData, currentAccount?.did])
|
||||
|
||||
return (
|
||||
<Dialog.InnerFlatList
|
||||
ref={listRef}
|
||||
data={dataWithoutSelf}
|
||||
renderItem={renderItem}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
{listHeader}
|
||||
{searchText.length === 0 ? (
|
||||
<View style={[a.pt_4xl, a.align_center, a.px_lg]}>
|
||||
<Envelope width={64} fill={t.palette.contrast_200} />
|
||||
<Text
|
||||
style={[
|
||||
a.text_lg,
|
||||
a.text_center,
|
||||
a.mt_md,
|
||||
t.atoms.text_contrast_low,
|
||||
]}>
|
||||
<Trans>Search for someone to start a conversation with.</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
!actorAutocompleteData?.length && (
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isFetching}
|
||||
isError={isError}
|
||||
onRetry={refetch}
|
||||
hideBackButton={true}
|
||||
emptyType="results"
|
||||
sideBorders={false}
|
||||
topBorder={false}
|
||||
emptyMessage={
|
||||
isError
|
||||
? _(msg`No search results found for "${searchText}".`)
|
||||
: _(msg`Could not load profiles. Please try again later.`)
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
}
|
||||
stickyHeaderIndices={[0]}
|
||||
keyExtractor={(item: AppBskyActorDefs.ProfileView) => item.did}
|
||||
// @ts-expect-error web only
|
||||
style={isWeb && {minHeight: '100vh'}}
|
||||
onScrollBeginDrag={() => Keyboard.dismiss()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import Animated, {
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {
|
||||
ScaleAndFadeIn,
|
||||
ScaleAndFadeOut,
|
||||
} from 'lib/custom-animations/ScaleAndFade'
|
||||
import {useHaptics} from 'lib/haptics'
|
||||
import {isAndroid, isIOS, isWeb} from 'platform/detection'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
|
||||
|
||||
export function NewMessagesPill({
|
||||
onPress: onPressInner,
|
||||
}: {
|
||||
onPress: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const playHaptic = useHaptics()
|
||||
const {bottom: bottomInset} = useSafeAreaInsets()
|
||||
const bottomBarHeight = isIOS ? 42 : isAndroid ? 60 : 0
|
||||
const bottomOffset = isWeb ? 0 : bottomInset + bottomBarHeight
|
||||
|
||||
const scale = useSharedValue(1)
|
||||
|
||||
const onPressIn = React.useCallback(() => {
|
||||
if (isWeb) return
|
||||
scale.value = withTiming(1.075, {duration: 100})
|
||||
}, [scale])
|
||||
|
||||
const onPressOut = React.useCallback(() => {
|
||||
if (isWeb) return
|
||||
scale.value = withTiming(1, {duration: 100})
|
||||
}, [scale])
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
runOnJS(playHaptic)()
|
||||
onPressInner?.()
|
||||
}, [onPressInner, playHaptic])
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{scale: scale.value}],
|
||||
}))
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.w_full,
|
||||
a.z_10,
|
||||
a.align_center,
|
||||
{
|
||||
bottom: bottomOffset + 70,
|
||||
// Don't prevent scrolling in this area _except_ for in the pill itself
|
||||
pointerEvents: 'box-none',
|
||||
},
|
||||
]}>
|
||||
<AnimatedPressable
|
||||
style={[
|
||||
a.py_sm,
|
||||
a.rounded_full,
|
||||
a.shadow_sm,
|
||||
a.border,
|
||||
t.atoms.bg_contrast_50,
|
||||
t.atoms.border_contrast_medium,
|
||||
{
|
||||
width: 160,
|
||||
alignItems: 'center',
|
||||
shadowOpacity: 0.125,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: {width: 0, height: 5},
|
||||
pointerEvents: 'box-only',
|
||||
},
|
||||
animatedStyle,
|
||||
]}
|
||||
entering={ScaleAndFadeIn}
|
||||
exiting={ScaleAndFadeOut}
|
||||
onPress={onPress}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}>
|
||||
<Text style={[a.font_bold]}>
|
||||
<Trans>New messages</Trans>
|
||||
</Text>
|
||||
</AnimatedPressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {DialogControlProps} from '#/components/Dialog'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
|
||||
export function ReportConversationPrompt({
|
||||
control,
|
||||
}: {
|
||||
control: DialogControlProps
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<Prompt.Basic
|
||||
control={control}
|
||||
title={_(msg`Report conversation`)}
|
||||
description={_(
|
||||
msg`To report a conversation, please report one of its messages via the conversation screen. This lets our moderators understand the context of your issue.`,
|
||||
)}
|
||||
confirmButtonCta={_(msg`I understand`)}
|
||||
onConfirm={() => {}}
|
||||
showCancel={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {KeyboardControllerPadding} from '#/components/KeyboardControllerPadding'
|
||||
import {Button, ButtonIcon, ButtonText} from '../Button'
|
||||
import {Divider} from '../Divider'
|
||||
import {ChevronLeft_Stroke2_Corner0_Rounded as Chevron} from '../icons/Chevron'
|
||||
@@ -25,12 +26,18 @@ import {RichText} from '../RichText'
|
||||
import {Text} from '../Typography'
|
||||
import {MessageItemMetadata} from './MessageItem'
|
||||
|
||||
let MessageReportDialog = ({
|
||||
type ReportDialogParams = {
|
||||
type: 'convoMessage'
|
||||
convoId: string
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
}
|
||||
|
||||
let ReportDialog = ({
|
||||
control,
|
||||
message,
|
||||
params,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
params: ReportDialogParams
|
||||
}): React.ReactNode => {
|
||||
const {_} = useLingui()
|
||||
return (
|
||||
@@ -39,26 +46,27 @@ let MessageReportDialog = ({
|
||||
nativeOptions={isAndroid ? {sheet: {snapPoints: ['100%']}} : {}}>
|
||||
<Dialog.Handle />
|
||||
<Dialog.ScrollableInner label={_(msg`Report this message`)}>
|
||||
<DialogInner message={message} />
|
||||
<DialogInner params={params} />
|
||||
<Dialog.Close />
|
||||
<KeyboardControllerPadding />
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
MessageReportDialog = memo(MessageReportDialog)
|
||||
export {MessageReportDialog}
|
||||
ReportDialog = memo(ReportDialog)
|
||||
export {ReportDialog}
|
||||
|
||||
function DialogInner({message}: {message: ChatBskyConvoDefs.MessageView}) {
|
||||
function DialogInner({params}: {params: ReportDialogParams}) {
|
||||
const [reportOption, setReportOption] = useState<ReportOption | null>(null)
|
||||
|
||||
return reportOption ? (
|
||||
<SubmitStep
|
||||
message={message}
|
||||
params={params}
|
||||
reportOption={reportOption}
|
||||
goBack={() => setReportOption(null)}
|
||||
/>
|
||||
) : (
|
||||
<ReasonStep setReportOption={setReportOption} />
|
||||
<ReasonStep params={params} setReportOption={setReportOption} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -66,6 +74,7 @@ function ReasonStep({
|
||||
setReportOption,
|
||||
}: {
|
||||
setReportOption: (reportOption: ReportOption) => void
|
||||
params: ReportDialogParams
|
||||
}) {
|
||||
const control = Dialog.useDialogContext()
|
||||
|
||||
@@ -73,18 +82,20 @@ function ReasonStep({
|
||||
<SelectReportOptionView
|
||||
labelers={[]}
|
||||
goBack={control.close}
|
||||
params={{type: 'message'}}
|
||||
params={{
|
||||
type: 'convoMessage',
|
||||
}}
|
||||
onSelectReportOption={setReportOption}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SubmitStep({
|
||||
message,
|
||||
params,
|
||||
reportOption,
|
||||
goBack,
|
||||
}: {
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
params: ReportDialogParams
|
||||
reportOption: ReportOption
|
||||
goBack: () => void
|
||||
}) {
|
||||
@@ -93,7 +104,7 @@ function SubmitStep({
|
||||
const t = useTheme()
|
||||
const [details, setDetails] = useState('')
|
||||
const control = Dialog.useDialogContext()
|
||||
const {getAgent} = useAgent()
|
||||
const agent = useAgent()
|
||||
|
||||
const {
|
||||
mutate: submit,
|
||||
@@ -101,17 +112,22 @@ function SubmitStep({
|
||||
isPending: submitting,
|
||||
} = useMutation({
|
||||
mutationFn: async () => {
|
||||
const report = {
|
||||
reasonType: reportOption.reason,
|
||||
subject: {
|
||||
$type: 'chat.bsky.convo.defs#messageRef',
|
||||
messageId: message.id,
|
||||
did: message.sender!.did,
|
||||
} satisfies ChatBskyConvoDefs.MessageRef,
|
||||
reason: details,
|
||||
} satisfies ComAtprotoModerationCreateReport.InputSchema
|
||||
if (params.type === 'convoMessage') {
|
||||
const {convoId, message} = params
|
||||
|
||||
await getAgent().createModerationReport(report)
|
||||
const report = {
|
||||
reasonType: reportOption.reason,
|
||||
subject: {
|
||||
$type: 'chat.bsky.convo.defs#messageRef',
|
||||
messageId: message.id,
|
||||
convoId,
|
||||
did: message.sender.did,
|
||||
} satisfies ChatBskyConvoDefs.MessageRef,
|
||||
reason: details,
|
||||
} satisfies ComAtprotoModerationCreateReport.InputSchema
|
||||
|
||||
await agent.createModerationReport(report)
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
control.close(() => {
|
||||
@@ -120,6 +136,14 @@ function SubmitStep({
|
||||
},
|
||||
})
|
||||
|
||||
const copy = useMemo(() => {
|
||||
return {
|
||||
convoMessage: {
|
||||
title: _(msg`Report this message`),
|
||||
},
|
||||
}[params.type]
|
||||
}, [_, params])
|
||||
|
||||
return (
|
||||
<View style={a.gap_lg}>
|
||||
<Button
|
||||
@@ -133,9 +157,7 @@ function SubmitStep({
|
||||
</Button>
|
||||
|
||||
<View style={[a.justify_center, gtMobile ? a.gap_sm : a.gap_xs]}>
|
||||
<Text style={[a.text_2xl, a.font_bold]}>
|
||||
<Trans>Report this message</Trans>
|
||||
</Text>
|
||||
<Text style={[a.text_2xl, a.font_bold]}>{copy.title}</Text>
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
Your report will be sent to the Bluesky Moderation Service
|
||||
@@ -143,10 +165,15 @@ function SubmitStep({
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<PreviewMessage message={message} />
|
||||
{params.type === 'convoMessage' && (
|
||||
<PreviewMessage message={params.message} />
|
||||
)}
|
||||
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Reason: {reportOption.title}</Trans>
|
||||
<Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Reason:</Trans>
|
||||
</Text>{' '}
|
||||
<Text style={[a.font_bold, a.text_md]}>{reportOption.title}</Text>
|
||||
</Text>
|
||||
|
||||
<Divider />
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
|
||||
import {logEvent} from 'lib/statsig/statsig'
|
||||
import {FAB} from '#/view/com/util/fab/FAB'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {SearchablePeopleList} from './SearchablePeopleList'
|
||||
|
||||
export function NewChat({
|
||||
control,
|
||||
onNewChat,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
onNewChat: (chatId: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const {mutate: createChat} = useGetConvoForMembers({
|
||||
onSuccess: data => {
|
||||
onNewChat(data.convo.id)
|
||||
|
||||
if (!data.convo.lastMessage) {
|
||||
logEvent('chat:create', {logContext: 'NewChatDialog'})
|
||||
}
|
||||
logEvent('chat:open', {logContext: 'NewChatDialog'})
|
||||
},
|
||||
onError: error => {
|
||||
logger.error('Failed to create chat', {safeMessage: error})
|
||||
Toast.show(_(msg`An issue occurred starting the chat`), 'xmark')
|
||||
},
|
||||
})
|
||||
|
||||
const onCreateChat = useCallback(
|
||||
(did: string) => {
|
||||
control.close(() => createChat([did]))
|
||||
},
|
||||
[control, createChat],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<FAB
|
||||
testID="newChatFAB"
|
||||
onPress={control.open}
|
||||
icon={<Plus size="lg" fill={t.palette.white} />}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`New chat`)}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
testID="newChatDialog"
|
||||
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
|
||||
<SearchablePeopleList
|
||||
title={_(msg`Start a new chat`)}
|
||||
onSelectChat={onCreateChat}
|
||||
/>
|
||||
</Dialog.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type {TextInput as TextInputType} from 'react-native'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
|
||||
import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useListConvosQuery} from '#/state/queries/messages/list-converations'
|
||||
import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {TextInput} from '#/components/dms/dialogs/TextInput'
|
||||
import {canBeMessaged} from '#/components/dms/util'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
type Item =
|
||||
| {
|
||||
type: 'profile'
|
||||
key: string
|
||||
enabled: boolean
|
||||
profile: AppBskyActorDefs.ProfileView
|
||||
}
|
||||
| {
|
||||
type: 'empty'
|
||||
key: string
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
type: 'placeholder'
|
||||
key: string
|
||||
}
|
||||
| {
|
||||
type: 'error'
|
||||
key: string
|
||||
}
|
||||
|
||||
export function SearchablePeopleList({
|
||||
title,
|
||||
onSelectChat,
|
||||
showRecentConvos,
|
||||
}: {
|
||||
title: string
|
||||
onSelectChat: (did: string) => void
|
||||
showRecentConvos?: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const control = Dialog.useDialogContext()
|
||||
const listRef = useRef<BottomSheetFlatListMethods>(null)
|
||||
const {currentAccount} = useSession()
|
||||
const inputRef = useRef<TextInputType>(null)
|
||||
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
const {
|
||||
data: results,
|
||||
isError,
|
||||
isFetching,
|
||||
} = useActorAutocompleteQuery(searchText, true, 12)
|
||||
const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
|
||||
const {data: convos} = useListConvosQuery({enabled: showRecentConvos})
|
||||
|
||||
const items = useMemo(() => {
|
||||
let _items: Item[] = []
|
||||
|
||||
if (isError) {
|
||||
_items.push({
|
||||
type: 'empty',
|
||||
key: 'empty',
|
||||
message: _(msg`We're having network issues, try again`),
|
||||
})
|
||||
} else if (searchText.length) {
|
||||
if (results?.length) {
|
||||
for (const profile of results) {
|
||||
if (profile.did === currentAccount?.did) continue
|
||||
_items.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
enabled: canBeMessaged(profile),
|
||||
profile,
|
||||
})
|
||||
}
|
||||
|
||||
_items = _items.sort(a => {
|
||||
// @ts-ignore
|
||||
return a.enabled ? -1 : 1
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const placeholders: Item[] = Array(10)
|
||||
.fill(0)
|
||||
.map((_, i) => ({
|
||||
type: 'placeholder',
|
||||
key: i + '',
|
||||
}))
|
||||
|
||||
if (showRecentConvos) {
|
||||
if (convos && follows) {
|
||||
const usedDids = new Set()
|
||||
|
||||
for (const page of convos.pages) {
|
||||
for (const convo of page.convos) {
|
||||
const profiles = convo.members.filter(
|
||||
m => m.did !== currentAccount?.did,
|
||||
)
|
||||
|
||||
for (const profile of profiles) {
|
||||
if (usedDids.has(profile.did)) continue
|
||||
|
||||
usedDids.add(profile.did)
|
||||
|
||||
_items.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
enabled: true,
|
||||
profile,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let followsItems: typeof _items = []
|
||||
|
||||
for (const page of follows.pages) {
|
||||
for (const profile of page.follows) {
|
||||
if (usedDids.has(profile.did)) continue
|
||||
|
||||
followsItems.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
enabled: canBeMessaged(profile),
|
||||
profile,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// only sort follows
|
||||
followsItems = followsItems.sort(a => {
|
||||
// @ts-ignore
|
||||
return a.enabled ? -1 : 1
|
||||
})
|
||||
|
||||
// then append
|
||||
_items.push(...followsItems)
|
||||
} else {
|
||||
_items.push(...placeholders)
|
||||
}
|
||||
} else if (follows) {
|
||||
for (const page of follows.pages) {
|
||||
for (const profile of page.follows) {
|
||||
_items.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
enabled: canBeMessaged(profile),
|
||||
profile,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_items = _items.sort(a => {
|
||||
// @ts-ignore
|
||||
return a.enabled ? -1 : 1
|
||||
})
|
||||
} else {
|
||||
_items.push(...placeholders)
|
||||
}
|
||||
}
|
||||
|
||||
return _items
|
||||
}, [
|
||||
_,
|
||||
searchText,
|
||||
results,
|
||||
isError,
|
||||
currentAccount?.did,
|
||||
follows,
|
||||
convos,
|
||||
showRecentConvos,
|
||||
])
|
||||
|
||||
if (searchText && !isFetching && !items.length && !isError) {
|
||||
items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
|
||||
}
|
||||
|
||||
const renderItems = useCallback(
|
||||
({item}: {item: Item}) => {
|
||||
switch (item.type) {
|
||||
case 'profile': {
|
||||
return (
|
||||
<ProfileCard
|
||||
key={item.key}
|
||||
enabled={item.enabled}
|
||||
profile={item.profile}
|
||||
moderationOpts={moderationOpts!}
|
||||
onPress={onSelectChat}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'placeholder': {
|
||||
return <ProfileCardSkeleton key={item.key} />
|
||||
}
|
||||
case 'empty': {
|
||||
return <Empty key={item.key} message={item.message} />
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
},
|
||||
[moderationOpts, onSelectChat],
|
||||
)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isWeb) {
|
||||
setImmediate(() => {
|
||||
inputRef?.current?.focus()
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const listHeader = useMemo(() => {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.pt_md,
|
||||
a.pb_xs,
|
||||
a.px_lg,
|
||||
a.border_b,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg,
|
||||
native([a.pt_lg]),
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
native(a.align_center),
|
||||
a.justify_center,
|
||||
{height: 32},
|
||||
]}>
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
size="small"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
style={[
|
||||
a.absolute,
|
||||
a.z_20,
|
||||
native({
|
||||
left: -7,
|
||||
}),
|
||||
web({
|
||||
right: -4,
|
||||
}),
|
||||
]}
|
||||
onPress={() => control.close()}>
|
||||
{isWeb ? (
|
||||
<X size="md" fill={t.palette.contrast_500} />
|
||||
) : (
|
||||
<ChevronLeft size="md" fill={t.palette.contrast_500} />
|
||||
)}
|
||||
</Button>
|
||||
<Text
|
||||
style={[
|
||||
a.z_10,
|
||||
a.text_lg,
|
||||
a.font_bold,
|
||||
a.leading_tight,
|
||||
t.atoms.text_contrast_high,
|
||||
]}>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[native([a.pt_sm]), web([a.pt_xs])]}>
|
||||
<SearchInput
|
||||
inputRef={inputRef}
|
||||
value={searchText}
|
||||
onChangeText={text => {
|
||||
setSearchText(text)
|
||||
listRef.current?.scrollToOffset({offset: 0, animated: false})
|
||||
}}
|
||||
onEscape={control.close}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}, [
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg,
|
||||
t.atoms.text_contrast_high,
|
||||
t.palette.contrast_500,
|
||||
_,
|
||||
title,
|
||||
searchText,
|
||||
control,
|
||||
])
|
||||
|
||||
return (
|
||||
<Dialog.InnerFlatList
|
||||
ref={listRef}
|
||||
data={items}
|
||||
renderItem={renderItems}
|
||||
ListHeaderComponent={listHeader}
|
||||
stickyHeaderIndices={[0]}
|
||||
keyExtractor={(item: Item) => item.key}
|
||||
style={[
|
||||
web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
|
||||
native({
|
||||
height: '100%',
|
||||
paddingHorizontal: 0,
|
||||
marginTop: 0,
|
||||
paddingTop: 0,
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
}),
|
||||
]}
|
||||
webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
|
||||
keyboardDismissMode="on-drag"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileCard({
|
||||
enabled,
|
||||
profile,
|
||||
moderationOpts,
|
||||
onPress,
|
||||
}: {
|
||||
enabled: boolean
|
||||
profile: AppBskyActorDefs.ProfileView
|
||||
moderationOpts: ModerationOpts
|
||||
onPress: (did: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
const handle = sanitizeHandle(profile.handle, '@')
|
||||
const displayName = sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
)
|
||||
|
||||
const handleOnPress = useCallback(() => {
|
||||
onPress(profile.did)
|
||||
}, [onPress, profile.did])
|
||||
|
||||
return (
|
||||
<Button
|
||||
disabled={!enabled}
|
||||
label={_(msg`Start chat with ${displayName}`)}
|
||||
onPress={handleOnPress}>
|
||||
{({hovered, pressed, focused}) => (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.py_md,
|
||||
a.px_lg,
|
||||
a.gap_md,
|
||||
a.align_center,
|
||||
a.flex_row,
|
||||
!enabled
|
||||
? {opacity: 0.5}
|
||||
: pressed || focused
|
||||
? t.atoms.bg_contrast_25
|
||||
: hovered
|
||||
? t.atoms.bg_contrast_50
|
||||
: t.atoms.bg,
|
||||
]}>
|
||||
<UserAvatar
|
||||
size={42}
|
||||
avatar={profile.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
<View style={[a.flex_1, a.gap_2xs]}>
|
||||
<Text
|
||||
style={[t.atoms.text, a.font_bold, a.leading_tight, a.self_start]}
|
||||
numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Text
|
||||
style={[a.leading_tight, t.atoms.text_contrast_high]}
|
||||
numberOfLines={2}>
|
||||
{!enabled ? <Trans>{handle} can't be messaged</Trans> : handle}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileCardSkeleton() {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.py_md,
|
||||
a.px_lg,
|
||||
a.gap_md,
|
||||
a.align_center,
|
||||
a.flex_row,
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
{width: 42, height: 42},
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
/>
|
||||
|
||||
<View style={[a.flex_1, a.gap_sm]}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
{width: 80, height: 14},
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
{width: 120, height: 10},
|
||||
t.atoms.bg_contrast_25,
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function Empty({message}: {message: string}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View style={[a.p_lg, a.py_xl, a.align_center, a.gap_md]}>
|
||||
<Text style={[a.text_sm, a.italic, t.atoms.text_contrast_high]}>
|
||||
{message}
|
||||
</Text>
|
||||
|
||||
<Text style={[a.text_xs, t.atoms.text_contrast_low]}>(╯°□°)╯︵ ┻━┻</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchInput({
|
||||
value,
|
||||
onChangeText,
|
||||
onEscape,
|
||||
inputRef,
|
||||
}: {
|
||||
value: string
|
||||
onChangeText: (text: string) => void
|
||||
onEscape: () => void
|
||||
inputRef: React.RefObject<TextInputType>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onMouseEnter,
|
||||
onOut: onMouseLeave,
|
||||
} = useInteractionState()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const interacted = hovered || focused
|
||||
|
||||
return (
|
||||
<View
|
||||
{...web({
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
})}
|
||||
style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Search
|
||||
size="md"
|
||||
fill={interacted ? t.palette.primary_500 : t.palette.contrast_300}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
// @ts-ignore bottom sheet input types issue — esb
|
||||
ref={inputRef}
|
||||
placeholder={_(msg`Search`)}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]}
|
||||
placeholderTextColor={t.palette.contrast_500}
|
||||
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
|
||||
returnKeyType="search"
|
||||
clearButtonMode="while-editing"
|
||||
maxLength={50}
|
||||
onKeyPress={({nativeEvent}) => {
|
||||
if (nativeEvent.key === 'Escape') {
|
||||
onEscape()
|
||||
}
|
||||
}}
|
||||
autoCorrect={false}
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
autoFocus
|
||||
accessibilityLabel={_(msg`Search profiles`)}
|
||||
accessibilityHint={_(msg`Search profiles`)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
|
||||
import {logEvent} from 'lib/statsig/statsig'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {SearchablePeopleList} from './SearchablePeopleList'
|
||||
|
||||
export function SendViaChatDialog({
|
||||
control,
|
||||
onSelectChat,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
onSelectChat: (chatId: string) => void
|
||||
}) {
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
testID="sendViaChatChatDialog"
|
||||
nativeOptions={{sheet: {snapPoints: ['100%']}}}>
|
||||
<SendViaChatDialogInner control={control} onSelectChat={onSelectChat} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function SendViaChatDialogInner({
|
||||
control,
|
||||
onSelectChat,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
onSelectChat: (chatId: string) => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {mutate: createChat} = useGetConvoForMembers({
|
||||
onSuccess: data => {
|
||||
onSelectChat(data.convo.id)
|
||||
|
||||
if (!data.convo.lastMessage) {
|
||||
logEvent('chat:create', {logContext: 'SendViaChatDialog'})
|
||||
}
|
||||
logEvent('chat:open', {logContext: 'SendViaChatDialog'})
|
||||
},
|
||||
onError: error => {
|
||||
logger.error('Failed to share post to chat', {message: error})
|
||||
Toast.show(
|
||||
_(msg`An issue occurred while trying to open the chat`),
|
||||
'xmark',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const onCreateChat = useCallback(
|
||||
(did: string) => {
|
||||
control.close(() => createChat([did]))
|
||||
},
|
||||
[control, createChat],
|
||||
)
|
||||
|
||||
return (
|
||||
<SearchablePeopleList
|
||||
title={_(msg`Send post to...`)}
|
||||
onSelectChat={onCreateChat}
|
||||
showRecentConvos
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {BottomSheetTextInput as TextInput} from '@discord/bottom-sheet/src'
|
||||
@@ -0,0 +1 @@
|
||||
export {TextInput} from 'react-native'
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Keyboard, View} from 'react-native'
|
||||
import DatePicker from 'react-native-date-picker'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -49,7 +49,10 @@ export function DateField({
|
||||
<DateFieldButton
|
||||
label={label}
|
||||
value={value}
|
||||
onPress={control.open}
|
||||
onPress={() => {
|
||||
Keyboard.dismiss()
|
||||
control.open()
|
||||
}}
|
||||
isInvalid={isInvalid}
|
||||
accessibilityHint={accessibilityHint}
|
||||
/>
|
||||
|
||||
@@ -101,16 +101,13 @@ export function useSharedInputStyles() {
|
||||
]
|
||||
const error: ViewStyle[] = [
|
||||
{
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
|
||||
borderColor:
|
||||
t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
|
||||
backgroundColor: t.palette.negative_25,
|
||||
borderColor: t.palette.negative_300,
|
||||
},
|
||||
]
|
||||
const errorHover: ViewStyle[] = [
|
||||
{
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
|
||||
backgroundColor: t.palette.negative_25,
|
||||
borderColor: t.palette.negative_500,
|
||||
},
|
||||
]
|
||||
@@ -140,6 +137,7 @@ export function createInput(Component: typeof TextInput) {
|
||||
onChangeText,
|
||||
isInvalid,
|
||||
inputRef,
|
||||
style,
|
||||
...rest
|
||||
}: InputProps) {
|
||||
const t = useTheme()
|
||||
@@ -195,10 +193,19 @@ export function createInput(Component: typeof TextInput) {
|
||||
lineHeight: a.text_md.fontSize * 1.1875,
|
||||
textAlignVertical: rest.multiline ? 'top' : undefined,
|
||||
minHeight: rest.multiline ? 80 : undefined,
|
||||
minWidth: 0,
|
||||
},
|
||||
// fix for autofill styles covering border
|
||||
web({
|
||||
paddingTop: 12,
|
||||
paddingBottom: 12,
|
||||
marginTop: 2,
|
||||
marginBottom: 2,
|
||||
}),
|
||||
android({
|
||||
paddingBottom: 16,
|
||||
}),
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
import {Pressable, View, ViewStyle} from 'react-native'
|
||||
import Animated, {LinearTransition} from 'react-native-reanimated'
|
||||
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {HITSLOP_10} from 'lib/constants'
|
||||
import {
|
||||
atoms as a,
|
||||
@@ -244,6 +246,7 @@ export function LabelText({
|
||||
<Text
|
||||
style={[
|
||||
a.font_bold,
|
||||
a.leading_tight,
|
||||
{
|
||||
userSelect: 'none',
|
||||
color: disabled
|
||||
@@ -251,7 +254,7 @@ export function LabelText({
|
||||
: t.atoms.text_contrast_high.color,
|
||||
},
|
||||
native({
|
||||
paddingTop: 3,
|
||||
paddingTop: 2,
|
||||
}),
|
||||
flatten(style),
|
||||
]}>
|
||||
@@ -281,24 +284,20 @@ export function createSharedToggleStyles({
|
||||
|
||||
if (selected) {
|
||||
base.push({
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.primary_25 : t.palette.primary_900,
|
||||
backgroundColor: t.palette.primary_25,
|
||||
borderColor: t.palette.primary_500,
|
||||
})
|
||||
|
||||
if (hovered) {
|
||||
baseHover.push({
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.primary_100 : t.palette.primary_800,
|
||||
borderColor:
|
||||
t.name === 'light' ? t.palette.primary_600 : t.palette.primary_400,
|
||||
backgroundColor: t.palette.primary_100,
|
||||
borderColor: t.palette.primary_600,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if (hovered) {
|
||||
baseHover.push({
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.contrast_50 : t.palette.contrast_100,
|
||||
backgroundColor: t.palette.contrast_50,
|
||||
borderColor: t.palette.contrast_500,
|
||||
})
|
||||
}
|
||||
@@ -306,16 +305,13 @@ export function createSharedToggleStyles({
|
||||
|
||||
if (isInvalid) {
|
||||
base.push({
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_975,
|
||||
borderColor:
|
||||
t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
|
||||
backgroundColor: t.palette.negative_25,
|
||||
borderColor: t.palette.negative_300,
|
||||
})
|
||||
|
||||
if (hovered) {
|
||||
baseHover.push({
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
|
||||
backgroundColor: t.palette.negative_25,
|
||||
borderColor: t.palette.negative_600,
|
||||
})
|
||||
}
|
||||
@@ -351,10 +347,10 @@ export function Checkbox() {
|
||||
style={[
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
a.border,
|
||||
a.rounded_xs,
|
||||
t.atoms.border_contrast_high,
|
||||
{
|
||||
borderWidth: 1,
|
||||
height: 20,
|
||||
width: 20,
|
||||
},
|
||||
@@ -382,34 +378,35 @@ export function Switch() {
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.border,
|
||||
a.rounded_full,
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_high,
|
||||
{
|
||||
borderWidth: 1,
|
||||
height: 20,
|
||||
width: 30,
|
||||
width: 32,
|
||||
padding: 2,
|
||||
},
|
||||
baseStyles,
|
||||
hovered ? baseHoverStyles : {},
|
||||
]}>
|
||||
<View
|
||||
<Animated.View
|
||||
layout={LinearTransition.duration(100)}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.rounded_full,
|
||||
{
|
||||
height: 12,
|
||||
width: 12,
|
||||
top: 3,
|
||||
left: 3,
|
||||
backgroundColor: t.palette.contrast_400,
|
||||
height: 14,
|
||||
width: 14,
|
||||
},
|
||||
selected
|
||||
? {
|
||||
backgroundColor: t.palette.primary_500,
|
||||
left: 13,
|
||||
alignSelf: 'flex-end',
|
||||
}
|
||||
: {},
|
||||
: {
|
||||
backgroundColor: t.palette.contrast_400,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
indicatorStyles,
|
||||
]}
|
||||
/>
|
||||
@@ -435,10 +432,10 @@ export function Radio() {
|
||||
style={[
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
a.border,
|
||||
a.rounded_full,
|
||||
t.atoms.border_contrast_high,
|
||||
{
|
||||
borderWidth: 1,
|
||||
height: 20,
|
||||
width: 20,
|
||||
},
|
||||
@@ -463,3 +460,5 @@ export function Radio() {
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export const Platform = isNative ? Switch : Checkbox
|
||||
|
||||
@@ -23,10 +23,10 @@ export function Group({children, multiple, ...props}: GroupProps) {
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.border,
|
||||
a.rounded_sm,
|
||||
a.overflow_hidden,
|
||||
t.atoms.border_contrast_low,
|
||||
{borderWidth: 1},
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Hooks for date-fns localized formatters.
|
||||
*
|
||||
* Our app supports some languages that are not included in date-fns by
|
||||
* default, in which case it will fall back to English.
|
||||
*
|
||||
* {@link https://github.com/date-fns/date-fns/blob/main/docs/i18n.md}
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import {formatDistance, Locale} from 'date-fns'
|
||||
import {
|
||||
ca,
|
||||
de,
|
||||
es,
|
||||
fi,
|
||||
fr,
|
||||
hi,
|
||||
id,
|
||||
it,
|
||||
ja,
|
||||
ko,
|
||||
ptBR,
|
||||
tr,
|
||||
uk,
|
||||
zhCN,
|
||||
zhTW,
|
||||
} from 'date-fns/locale'
|
||||
|
||||
import {AppLanguage} from '#/locale/languages'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
|
||||
/**
|
||||
* {@link AppLanguage}
|
||||
*/
|
||||
const locales: Record<AppLanguage, Locale | undefined> = {
|
||||
en: undefined,
|
||||
ca,
|
||||
de,
|
||||
es,
|
||||
fi,
|
||||
fr,
|
||||
ga: undefined,
|
||||
hi,
|
||||
id,
|
||||
it,
|
||||
ja,
|
||||
ko,
|
||||
['pt-BR']: ptBR,
|
||||
tr,
|
||||
uk,
|
||||
['zh-CN']: zhCN,
|
||||
['zh-TW']: zhTW,
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a localized `formatDistance` function.
|
||||
* {@link formatDistance}
|
||||
*/
|
||||
export function useFormatDistance() {
|
||||
const {appLanguage} = useLanguagePrefs()
|
||||
return React.useCallback<typeof formatDistance>(
|
||||
(date, baseDate, options) => {
|
||||
const locale = locales[appLanguage as AppLanguage]
|
||||
return formatDistance(date, baseDate, {...options, locale: locale})
|
||||
},
|
||||
[appLanguage],
|
||||
)
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export function useFollowMethods({
|
||||
} 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.`))
|
||||
Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -47,7 +47,7 @@ export function useFollowMethods({
|
||||
message: String(e),
|
||||
})
|
||||
if (e?.name !== 'AbortError') {
|
||||
Toast.show(_(msg`An issue occurred, please try again.`))
|
||||
Toast.show(_(msg`An issue occurred, please try again.`), 'xmark')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ export function useRichText(text: string): [RichTextAPI, boolean] {
|
||||
const [prevText, setPrevText] = React.useState(text)
|
||||
const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text}))
|
||||
const [resolvedRT, setResolvedRT] = React.useState<RichTextAPI | null>(null)
|
||||
const {getAgent} = useAgent()
|
||||
const agent = useAgent()
|
||||
if (text !== prevText) {
|
||||
setPrevText(text)
|
||||
setRawRT(new RichTextAPI({text}))
|
||||
@@ -19,7 +19,7 @@ export function useRichText(text: string): [RichTextAPI, boolean] {
|
||||
async function resolveRTFacets() {
|
||||
// new each time
|
||||
const resolvedRT = new RichTextAPI({text})
|
||||
await resolvedRT.detectFacets(getAgent())
|
||||
await resolvedRT.detectFacets(agent)
|
||||
if (!ignore) {
|
||||
setResolvedRT(resolvedRT)
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export function useRichText(text: string): [RichTextAPI, boolean] {
|
||||
return () => {
|
||||
ignore = true
|
||||
}
|
||||
}, [text, getAgent])
|
||||
}, [text, agent])
|
||||
const isResolving = resolvedRT === null
|
||||
return [resolvedRT ?? rawRT, isResolving]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react'
|
||||
|
||||
import {
|
||||
createStarterPackLinkFromAndroidReferrer,
|
||||
httpStarterPackUriToAtUri,
|
||||
} from 'lib/strings/starter-pack'
|
||||
import {isAndroid} from 'platform/detection'
|
||||
import {useHasCheckedForStarterPack} from 'state/preferences/used-starter-packs'
|
||||
import {useSetActiveStarterPack} from 'state/shell/starter-pack'
|
||||
import {Referrer, SharedPrefs} from '../../../modules/expo-bluesky-swiss-army'
|
||||
|
||||
export function useStarterPackEntry() {
|
||||
const [ready, setReady] = React.useState(false)
|
||||
const setActiveStarterPack = useSetActiveStarterPack()
|
||||
const hasCheckedForStarterPack = useHasCheckedForStarterPack()
|
||||
|
||||
React.useEffect(() => {
|
||||
if (ready) return
|
||||
|
||||
// On Android, we cannot clear the referral link. It gets stored for 90 days and all we can do is query for it. So,
|
||||
// let's just ensure we never check again after the first time.
|
||||
if (hasCheckedForStarterPack) {
|
||||
setReady(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Safety for Android. Very unlike this could happen, but just in case. The response should be nearly immediate
|
||||
const timeout = setTimeout(() => {
|
||||
setReady(true)
|
||||
}, 500)
|
||||
|
||||
;(async () => {
|
||||
let uri: string | null | undefined
|
||||
|
||||
if (isAndroid) {
|
||||
const res = await Referrer.getGooglePlayReferrerInfoAsync()
|
||||
|
||||
if (res && res.installReferrer) {
|
||||
uri = createStarterPackLinkFromAndroidReferrer(res.installReferrer)
|
||||
}
|
||||
} else {
|
||||
const starterPackUri = SharedPrefs.getString('starterPackUri')
|
||||
if (starterPackUri) {
|
||||
uri = httpStarterPackUriToAtUri(starterPackUri)
|
||||
SharedPrefs.setValue('starterPackUri', null)
|
||||
}
|
||||
}
|
||||
|
||||
if (uri) {
|
||||
setActiveStarterPack({
|
||||
uri,
|
||||
})
|
||||
}
|
||||
|
||||
setReady(true)
|
||||
})()
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [ready, setActiveStarterPack, hasCheckedForStarterPack])
|
||||
|
||||
return ready
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react'
|
||||
|
||||
import {httpStarterPackUriToAtUri} from 'lib/strings/starter-pack'
|
||||
import {useSetActiveStarterPack} from 'state/shell/starter-pack'
|
||||
|
||||
export function useStarterPackEntry() {
|
||||
const [ready, setReady] = React.useState(false)
|
||||
|
||||
const setActiveStarterPack = useSetActiveStarterPack()
|
||||
|
||||
React.useEffect(() => {
|
||||
const href = window.location.href
|
||||
const atUri = httpStarterPackUriToAtUri(href)
|
||||
|
||||
if (atUri) {
|
||||
const url = new URL(href)
|
||||
// Determines if an App Clip is loading this landing page
|
||||
const isClip = url.searchParams.get('clip') === 'true'
|
||||
setActiveStarterPack({
|
||||
uri: atUri,
|
||||
isClip,
|
||||
})
|
||||
}
|
||||
|
||||
setReady(true)
|
||||
}, [setActiveStarterPack])
|
||||
|
||||
return ready
|
||||
}
|
||||
@@ -7,3 +7,11 @@ export const ArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
export const ArrowLeft_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3 12a1 1 0 0 1 .293-.707l6-6a1 1 0 0 1 1.414 1.414L6.414 11H20a1 1 0 1 1 0 2H6.414l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6A1 1 0 0 1 3 12Z',
|
||||
})
|
||||
|
||||
export const ArrowRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M21 12a1 1 0 0 1-.293.707l-6 6a1 1 0 0 1-1.414-1.414L17.586 13H4a1 1 0 1 1 0-2h13.586l-4.293-4.293a1 1 0 0 1 1.414-1.414l6 6A1 1 0 0 1 21 12Z',
|
||||
})
|
||||
|
||||
export const ArrowBottom_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 21a1 1 0 0 1-.707-.293l-6-6a1 1 0 1 1 1.414-1.414L11 17.586V4a1 1 0 1 1 2 0v13.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-6 6A1 1 0 0 1 12 21Z',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const ArrowsDiagonalOut_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M14 5a1 1 0 1 1 0-2h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14ZM4 13a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H4a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
|
||||
export const ArrowsDiagonalIn_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-6a1 1 0 0 1-1-1V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z',
|
||||
})
|
||||
|
||||
export const ArrowsDiagonalOut_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M13 4a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0V6.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L17.586 5H14a1 1 0 0 1-1-1Zm-9 9a1 1 0 0 1 1 1v3.586l4.293-4.293a1 1 0 0 1 1.414 1.414L6.414 19H10a1 1 0 1 1 0 2H5a2 2 0 0 1-2-2v-5a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
|
||||
export const ArrowsDiagonalIn_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: 'M20.957 3.043a1 1 0 0 1 0 1.414L16.414 9H20a1 1 0 1 1 0 2h-5a2 2 0 0 1-2-2V4a1 1 0 1 1 2 0v3.586l4.543-4.543a1 1 0 0 1 1.414 0ZM3 14a1 1 0 0 1 1-1h5a2 2 0 0 1 2 2v5a1 1 0 1 1-2 0v-3.586l-4.543 4.543a1 1 0 0 1-1.414-1.414L7.586 15H4a1 1 0 0 1-1-1Z',
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const CC_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v14h14V5H5Zm10.957 6.293a1 1 0 1 0 0 1.414 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414Zm-6.331-.22a1 1 0 1 0 .331 1.634 1 1 0 0 1 1.414 1.414 3 3 0 1 1 0-4.242 1 1 0 0 1-1.414 1.414.994.994 0 0 0-.331-.22Z',
|
||||
})
|
||||
|
||||
export const CC_Filled_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm11.543 7.293a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.242 1 1 0 0 0-1.414-1.414 1 1 0 0 1-1.414-1.414Zm-6 0a1 1 0 0 1 1.414 0 1 1 0 0 0 1.414-1.414 3 3 0 1 0 0 4.243 1 1 0 0 0-1.414-1.415 1 1 0 0 1-1.414-1.414Z',
|
||||
})
|
||||
@@ -1,5 +0,0 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const SettingsGear2_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M11.1 2a1 1 0 0 0-.832.445L8.851 4.57 6.6 4.05a1 1 0 0 0-.932.268l-1.35 1.35a1 1 0 0 0-.267.932l.52 2.251-2.126 1.417A1 1 0 0 0 2 11.1v1.8a1 1 0 0 0 .445.832l2.125 1.417-.52 2.251a1 1 0 0 0 .268.932l1.35 1.35a1 1 0 0 0 .932.267l2.251-.52 1.417 2.126A1 1 0 0 0 11.1 22h1.8a1 1 0 0 0 .832-.445l1.417-2.125 2.251.52a1 1 0 0 0 .932-.268l1.35-1.35a1 1 0 0 0 .267-.932l-.52-2.251 2.126-1.417A1 1 0 0 0 22 12.9v-1.8a1 1 0 0 0-.445-.832L19.43 8.851l.52-2.251a1 1 0 0 0-.268-.932l-1.35-1.35a1 1 0 0 0-.932-.267l-2.251.52-1.417-2.126A1 1 0 0 0 12.9 2h-1.8Zm-.968 4.255L11.635 4h.73l1.503 2.255a1 1 0 0 0 1.057.42l2.385-.551.566.566-.55 2.385a1 1 0 0 0 .42 1.057L20 11.635v.73l-2.255 1.503a1 1 0 0 0-.42 1.057l.551 2.385-.566.566-2.385-.55a1 1 0 0 0-1.057.42L12.365 20h-.73l-1.503-2.255a1 1 0 0 0-1.057-.42l-2.385.551-.566-.566.55-2.385a1 1 0 0 0-.42-1.057L4 12.365v-.73l2.255-1.503a1 1 0 0 0 .42-1.057L6.123 6.69l.566-.566 2.385.55a1 1 0 0 0 1.057-.42ZM8 12a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
|
||||
})
|
||||
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||
export const Globe_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M4.062 11h2.961c.103-2.204.545-4.218 1.235-5.77.06-.136.123-.269.188-.399A8.007 8.007 0 0 0 4.062 11ZM12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm0 2c-.227 0-.518.1-.868.432-.354.337-.719.872-1.047 1.61-.561 1.263-.958 2.991-1.06 4.958h5.95c-.102-1.967-.499-3.695-1.06-4.958-.328-.738-.693-1.273-1.047-1.61C12.518 4.099 12.227 4 12 4Zm4.977 7c-.103-2.204-.545-4.218-1.235-5.77a9.78 9.78 0 0 0-.188-.399A8.006 8.006 0 0 1 19.938 11h-2.961Zm-2.003 2H9.026c.101 1.966.498 3.695 1.06 4.958.327.738.692 1.273 1.046 1.61.35.333.641.432.868.432.227 0 .518-.1.868-.432.354-.337.719-.872 1.047-1.61.561-1.263.958-2.991 1.06-4.958Zm.58 6.169c.065-.13.128-.263.188-.399.69-1.552 1.132-3.566 1.235-5.77h2.961a8.006 8.006 0 0 1-4.384 6.169Zm-7.108 0a9.877 9.877 0 0 1-.188-.399c-.69-1.552-1.132-3.566-1.235-5.77H4.062a8.006 8.006 0 0 0 4.384 6.169Z',
|
||||
})
|
||||
|
||||
export const Earth_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M4.4 9.493C4.14 10.28 4 11.124 4 12a8 8 0 1 0 10.899-7.459l-.953 3.81a1 1 0 0 1-.726.727l-3.444.866-.772 1.533a1 1 0 0 1-1.493.35L4.4 9.493Zm.883-1.84L7.756 9.51l.44-.874a1 1 0 0 1 .649-.52l3.306-.832.807-3.227a7.993 7.993 0 0 0-7.676 3.597ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm8.43.162a1 1 0 0 1 .77-.29l1.89.121a1 1 0 0 1 .494.168l2.869 1.928a1 1 0 0 1 .336 1.277l-.973 1.946a1 1 0 0 1-.894.553h-2.92a1 1 0 0 1-.831-.445L9.225 14.5a1 1 0 0 1 .126-1.262l1.08-1.076Zm.915 1.913.177-.177 1.171.074 1.914 1.286-.303.607h-1.766l-1.194-1.79Z',
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Image_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm16 0H5v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5Zm0 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z',
|
||||
path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v7.213l1.246-.932.044-.03a3 3 0 0 1 3.863.454c1.468 1.58 2.941 2.749 4.847 2.749 1.703 0 2.855-.555 4-1.618V5H5Zm14 10.357c-1.112.697-2.386 1.097-4 1.097-2.81 0-4.796-1.755-6.313-3.388a1 1 0 0 0-1.269-.164L5 14.712V19h14v-3.643ZM15 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-3 1a3 3 0 1 1 6 0 3 3 0 0 1-6 0Z',
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user