Merge branch 'main' into hailey/add-license-info

This commit is contained in:
Hailey
2024-08-15 13:51:46 -07:00
817 changed files with 93886 additions and 48560 deletions
+108 -74
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1 -78
View File
@@ -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
View File
@@ -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
}
+4
View File
@@ -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)
+14
View File
@@ -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}`)
}
}
+18 -15
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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) {
+54 -42
View File
@@ -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"
+17 -9
View File
@@ -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)]}
+328
View File
@@ -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}`
}
+478
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
export {FullWindowOverlay} from 'react-native-screens'
+1
View File
@@ -0,0 +1 @@
export {Fragment as FullWindowOverlay} from 'react'
+5 -6
View File
@@ -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
}
+243
View File
@@ -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
View File
@@ -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>
)
}
+129
View File
@@ -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}`
}
+23 -5
View File
@@ -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}
+164
View File
@@ -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>
)
}
+169
View File
@@ -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>
)
}
+392
View File
@@ -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>
)
}
+92 -30
View File
@@ -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>
+1
View File
@@ -4,4 +4,5 @@ export type ProfileHoverCardProps = {
children: React.ReactElement
did: string
inline?: boolean
disable?: boolean
}
+61
View File
@@ -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
}
+50
View File
@@ -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>
)
}
+169
View File
@@ -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>
)
)
})
+13 -8
View File
@@ -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>
)
-76
View File
@@ -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?`)
}
+12 -8
View File
@@ -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 (
+2 -1
View File
@@ -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
View File
@@ -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>
)
}
+119
View File
@@ -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,
}}
/>
)
}
+198
View File
@@ -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>
)
}
+168
View File
@@ -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}
/>
)
}
+35 -21
View File
@@ -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',
},
])
}
})
}}>
+25 -11
View File
@@ -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
}
+215
View File
@@ -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>
)
}
+339
View File
@@ -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,
}),
)
}
+92
View File
@@ -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>
)
})
+1
View File
@@ -113,6 +113,7 @@ export function EmbedConsentDialog({
</ButtonText>
</Button>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
+255
View File
@@ -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>
)
}
+19 -50
View File
@@ -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!`)}
+286 -94
View File
@@ -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 && {
+217
View File
@@ -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>
)
}
+46 -36
View File
@@ -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>
)
}
+98
View File
@@ -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
View File
@@ -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() {}
+55
View File
@@ -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()}
/>
)
}
+59 -46
View File
@@ -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 && (
<>
{' '}
&middot;{' '}
@@ -214,15 +222,20 @@ let MessageItemMetadata = ({
},
]}>
{_(msg`Failed to send`)}
</Text>{' '}
&middot;{' '}
<InlineLinkText
label={_(msg`Click to retry failed message`)}
to="#"
onPress={handleRetry}
style={[a.text_xs]}>
{_(msg`Retry`)}
</InlineLinkText>
</Text>
{item.retry && (
<>
{' '}
&middot;{' '}
<InlineLinkText
label={_(msg`Click to retry failed message`)}
to="#"
onPress={handleRetry}
style={[a.text_xs]}>
{_(msg`Retry`)}
</InlineLinkText>
</>
)}
</>
)}
</Text>
+22
View File
@@ -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}
+45 -29
View File
@@ -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>
)
}
+230
View File
@@ -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 && (
<>
{' '}
&middot;{' '}
<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>
)
}
+175
View File
@@ -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>
)
}
-278
View File
@@ -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()}
/>
)
}
+97
View File
@@ -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
/>
)
}
+1
View File
@@ -0,0 +1 @@
export {BottomSheetTextInput as TextInput} from '@discord/bottom-sheet/src'
@@ -0,0 +1 @@
export {TextInput} from 'react-native'
+5 -2
View File
@@ -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}
/>
+13 -6
View File
@@ -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,
]}
/>
+27 -28
View File
@@ -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
+1 -1
View File
@@ -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>
+69
View File
@@ -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],
)
}
+2 -2
View File
@@ -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')
}
}
})
+3 -3
View File
@@ -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
}
+8
View File
@@ -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',
})
+17
View File
@@ -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',
})
+9
View File
@@ -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',
})
-5
View File
@@ -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',
})
+4
View File
@@ -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 -1
View File
@@ -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