diff options
Diffstat (limited to 'src')
372 files changed, 32184 insertions, 18948 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx index f5d35cf74..64c7e718f 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -5,16 +5,17 @@ import React, {useState, useEffect} from 'react' import {RootSiblingParent} from 'react-native-root-siblings' import * as SplashScreen from 'expo-splash-screen' import {GestureHandlerRootView} from 'react-native-gesture-handler' -import {observer} from 'mobx-react-lite' import {QueryClientProvider} from '@tanstack/react-query' +import {enableFreeze} from 'react-native-screens' import 'view/icons' import {init as initPersistedState} from '#/state/persisted' +import {init as initReminders} from '#/state/shell/reminders' +import {listenSessionDropped} from './state/events' import {useColorMode} from 'state/shell' import {ThemeProvider} from 'lib/ThemeContext' import {s} from 'lib/styles' -import {RootStoreModel, setupState, RootStoreProvider} from './state' import {Shell} from 'view/shell' import * as notifications from 'lib/notifications/notifications' import * as analytics from 'lib/analytics/analytics' @@ -22,48 +23,74 @@ import * as Toast from 'view/com/util/Toast' import {queryClient} from 'lib/react-query' import {TestCtrls} from 'view/com/testing/TestCtrls' import {Provider as ShellStateProvider} from 'state/shell' +import {Provider as ModalStateProvider} from 'state/modals' +import {Provider as LightboxStateProvider} from 'state/lightbox' +import {Provider as MutedThreadsProvider} from 'state/muted-threads' +import {Provider as InvitesStateProvider} from 'state/invites' +import {Provider as PrefsStateProvider} from 'state/preferences' +import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out' +import I18nProvider from './locale/i18nProvider' +import { + Provider as SessionProvider, + useSession, + useSessionApi, +} from 'state/session' +import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread' +import * as persisted from '#/state/persisted' +enableFreeze(true) SplashScreen.preventAutoHideAsync() -const InnerApp = observer(function AppImpl() { +function InnerApp() { const colorMode = useColorMode() - const [rootStore, setRootStore] = useState<RootStoreModel | undefined>( - undefined, - ) + const {isInitialLoad, currentAccount} = useSession() + const {resumeSession} = useSessionApi() // init useEffect(() => { - setupState().then(store => { - setRootStore(store) - analytics.init(store) - notifications.init(store) - store.onSessionDropped(() => { - Toast.show('Sorry! Your session expired. Please log in again.') - }) + initReminders() + analytics.init() + notifications.init(queryClient) + listenSessionDropped(() => { + Toast.show('Sorry! Your session expired. Please log in again.') }) - }, []) + + const account = persisted.get('session').currentAccount + resumeSession(account) + }, [resumeSession]) // show nothing prior to init - if (!rootStore) { + if (isInitialLoad) { + // TODO add a loading state return null } + + /* + * Session and initial state should be loaded prior to rendering below. + */ + return ( - <QueryClientProvider client={queryClient}> - <ThemeProvider theme={colorMode}> - <RootSiblingParent> - <analytics.Provider> - <RootStoreProvider value={rootStore}> - <GestureHandlerRootView style={s.h100pct}> - <TestCtrls /> - <Shell /> - </GestureHandlerRootView> - </RootStoreProvider> - </analytics.Provider> - </RootSiblingParent> - </ThemeProvider> - </QueryClientProvider> + <React.Fragment + // Resets the entire tree below when it changes: + key={currentAccount?.did}> + <LoggedOutViewProvider> + <UnreadNotifsProvider> + <ThemeProvider theme={colorMode}> + <analytics.Provider> + {/* All components should be within this provider */} + <RootSiblingParent> + <GestureHandlerRootView style={s.h100pct}> + <TestCtrls /> + <Shell /> + </GestureHandlerRootView> + </RootSiblingParent> + </analytics.Provider> + </ThemeProvider> + </UnreadNotifsProvider> + </LoggedOutViewProvider> + </React.Fragment> ) -}) +} function App() { const [isReady, setReady] = useState(false) @@ -76,10 +103,30 @@ function App() { return null } + /* + * NOTE: only nothing here can depend on other data or session state, since + * that is set up in the InnerApp component above. + */ return ( - <ShellStateProvider> - <InnerApp /> - </ShellStateProvider> + <QueryClientProvider client={queryClient}> + <SessionProvider> + <ShellStateProvider> + <PrefsStateProvider> + <MutedThreadsProvider> + <InvitesStateProvider> + <ModalStateProvider> + <LightboxStateProvider> + <I18nProvider> + <InnerApp /> + </I18nProvider> + </LightboxStateProvider> + </ModalStateProvider> + </InvitesStateProvider> + </MutedThreadsProvider> + </PrefsStateProvider> + </ShellStateProvider> + </SessionProvider> + </QueryClientProvider> ) } diff --git a/src/App.web.tsx b/src/App.web.tsx index adad9ddb6..e939dda6d 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -1,59 +1,84 @@ import 'lib/sentry' // must be near top import React, {useState, useEffect} from 'react' -import {observer} from 'mobx-react-lite' import {QueryClientProvider} from '@tanstack/react-query' import {SafeAreaProvider} from 'react-native-safe-area-context' import {RootSiblingParent} from 'react-native-root-siblings' +import {enableFreeze} from 'react-native-screens' import 'view/icons' import {init as initPersistedState} from '#/state/persisted' +import {init as initReminders} from '#/state/shell/reminders' import {useColorMode} from 'state/shell' import * as analytics from 'lib/analytics/analytics' -import {RootStoreModel, setupState, RootStoreProvider} from './state' import {Shell} from 'view/shell/index' import {ToastContainer} from 'view/com/util/Toast.web' import {ThemeProvider} from 'lib/ThemeContext' import {queryClient} from 'lib/react-query' import {Provider as ShellStateProvider} from 'state/shell' +import {Provider as ModalStateProvider} from 'state/modals' +import {Provider as LightboxStateProvider} from 'state/lightbox' +import {Provider as MutedThreadsProvider} from 'state/muted-threads' +import {Provider as InvitesStateProvider} from 'state/invites' +import {Provider as PrefsStateProvider} from 'state/preferences' +import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out' +import I18nProvider from './locale/i18nProvider' +import { + Provider as SessionProvider, + useSession, + useSessionApi, +} from 'state/session' +import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread' +import * as persisted from '#/state/persisted' -const InnerApp = observer(function AppImpl() { +enableFreeze(true) + +function InnerApp() { + const {isInitialLoad, currentAccount} = useSession() + const {resumeSession} = useSessionApi() const colorMode = useColorMode() - const [rootStore, setRootStore] = useState<RootStoreModel | undefined>( - undefined, - ) // init useEffect(() => { - setupState().then(store => { - setRootStore(store) - analytics.init(store) - }) - }, []) + initReminders() + analytics.init() + const account = persisted.get('session').currentAccount + resumeSession(account) + }, [resumeSession]) // show nothing prior to init - if (!rootStore) { + if (isInitialLoad) { + // TODO add a loading state return null } + /* + * Session and initial state should be loaded prior to rendering below. + */ + return ( - <QueryClientProvider client={queryClient}> - <ThemeProvider theme={colorMode}> - <RootSiblingParent> - <analytics.Provider> - <RootStoreProvider value={rootStore}> - <SafeAreaProvider> - <Shell /> - </SafeAreaProvider> + <React.Fragment + // Resets the entire tree below when it changes: + key={currentAccount?.did}> + <LoggedOutViewProvider> + <UnreadNotifsProvider> + <ThemeProvider theme={colorMode}> + <analytics.Provider> + {/* All components should be within this provider */} + <RootSiblingParent> + <SafeAreaProvider> + <Shell /> + </SafeAreaProvider> + </RootSiblingParent> <ToastContainer /> - </RootStoreProvider> - </analytics.Provider> - </RootSiblingParent> - </ThemeProvider> - </QueryClientProvider> + </analytics.Provider> + </ThemeProvider> + </UnreadNotifsProvider> + </LoggedOutViewProvider> + </React.Fragment> ) -}) +} function App() { const [isReady, setReady] = useState(false) @@ -66,10 +91,30 @@ function App() { return null } + /* + * NOTE: only nothing here can depend on other data or session state, since + * that is set up in the InnerApp component above. + */ return ( - <ShellStateProvider> - <InnerApp /> - </ShellStateProvider> + <QueryClientProvider client={queryClient}> + <SessionProvider> + <ShellStateProvider> + <PrefsStateProvider> + <MutedThreadsProvider> + <InvitesStateProvider> + <ModalStateProvider> + <LightboxStateProvider> + <I18nProvider> + <InnerApp /> + </I18nProvider> + </LightboxStateProvider> + </ModalStateProvider> + </InvitesStateProvider> + </MutedThreadsProvider> + </PrefsStateProvider> + </ShellStateProvider> + </SessionProvider> + </QueryClientProvider> ) } diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 381f33cf9..4718349b5 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -1,7 +1,6 @@ import * as React from 'react' import {StyleSheet} from 'react-native' import * as SplashScreen from 'expo-splash-screen' -import {observer} from 'mobx-react-lite' import { NavigationContainer, createNavigationContainerRef, @@ -10,7 +9,6 @@ import { DefaultTheme, DarkTheme, } from '@react-navigation/native' -import {createNativeStackNavigator} from '@react-navigation/native-stack' import { BottomTabBarProps, createBottomTabNavigator, @@ -33,10 +31,10 @@ import {isNative} from 'platform/detection' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {router} from './routes' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from './state' import {bskyTitle} from 'lib/strings/headings' import {JSX} from 'react/jsx-runtime' import {timeout} from 'lib/async/timeout' +import {useUnreadNotifications} from './state/queries/notifications/unread' import {HomeScreen} from './view/screens/Home' import {SearchScreen} from './view/screens/Search' @@ -70,16 +68,18 @@ import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts' import {SavedFeeds} from 'view/screens/SavedFeeds' import {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed' import {PreferencesThreads} from 'view/screens/PreferencesThreads' +import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth' const navigationRef = createNavigationContainerRef<AllNavigatorParams>() -const HomeTab = createNativeStackNavigator<HomeTabNavigatorParams>() -const SearchTab = createNativeStackNavigator<SearchTabNavigatorParams>() -const FeedsTab = createNativeStackNavigator<FeedsTabNavigatorParams>() +const HomeTab = createNativeStackNavigatorWithAuth<HomeTabNavigatorParams>() +const SearchTab = createNativeStackNavigatorWithAuth<SearchTabNavigatorParams>() +const FeedsTab = createNativeStackNavigatorWithAuth<FeedsTabNavigatorParams>() const NotificationsTab = - createNativeStackNavigator<NotificationsTabNavigatorParams>() -const MyProfileTab = createNativeStackNavigator<MyProfileTabNavigatorParams>() -const Flat = createNativeStackNavigator<FlatNavigatorParams>() + createNativeStackNavigatorWithAuth<NotificationsTabNavigatorParams>() +const MyProfileTab = + createNativeStackNavigatorWithAuth<MyProfileTabNavigatorParams>() +const Flat = createNativeStackNavigatorWithAuth<FlatNavigatorParams>() const Tab = createBottomTabNavigator<BottomTabNavigatorParams>() /** @@ -98,37 +98,37 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { <Stack.Screen name="Lists" component={ListsScreen} - options={{title: title('Lists')}} + options={{title: title('Lists'), requireAuth: true}} /> <Stack.Screen name="Moderation" getComponent={() => ModerationScreen} - options={{title: title('Moderation')}} + options={{title: title('Moderation'), requireAuth: true}} /> <Stack.Screen name="ModerationModlists" getComponent={() => ModerationModlistsScreen} - options={{title: title('Moderation Lists')}} + options={{title: title('Moderation Lists'), requireAuth: true}} /> <Stack.Screen name="ModerationMutedAccounts" getComponent={() => ModerationMutedAccounts} - options={{title: title('Muted Accounts')}} + options={{title: title('Muted Accounts'), requireAuth: true}} /> <Stack.Screen name="ModerationBlockedAccounts" getComponent={() => ModerationBlockedAccounts} - options={{title: title('Blocked Accounts')}} + options={{title: title('Blocked Accounts'), requireAuth: true}} /> <Stack.Screen name="Settings" getComponent={() => SettingsScreen} - options={{title: title('Settings')}} + options={{title: title('Settings'), requireAuth: true}} /> <Stack.Screen name="LanguageSettings" getComponent={() => LanguageSettingsScreen} - options={{title: title('Language Settings')}} + options={{title: title('Language Settings'), requireAuth: true}} /> <Stack.Screen name="Profile" @@ -155,7 +155,7 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { <Stack.Screen name="ProfileList" getComponent={() => ProfileListScreen} - options={{title: title('List')}} + options={{title: title('List'), requireAuth: true}} /> <Stack.Screen name="PostThread" @@ -185,12 +185,12 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { <Stack.Screen name="Debug" getComponent={() => DebugScreen} - options={{title: title('Debug')}} + options={{title: title('Debug'), requireAuth: true}} /> <Stack.Screen name="Log" getComponent={() => LogScreen} - options={{title: title('Log')}} + options={{title: title('Log'), requireAuth: true}} /> <Stack.Screen name="Support" @@ -220,22 +220,22 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { <Stack.Screen name="AppPasswords" getComponent={() => AppPasswords} - options={{title: title('App Passwords')}} + options={{title: title('App Passwords'), requireAuth: true}} /> <Stack.Screen name="SavedFeeds" getComponent={() => SavedFeeds} - options={{title: title('Edit My Feeds')}} + options={{title: title('Edit My Feeds'), requireAuth: true}} /> <Stack.Screen name="PreferencesHomeFeed" getComponent={() => PreferencesHomeFeed} - options={{title: title('Home Feed Preferences')}} + options={{title: title('Home Feed Preferences'), requireAuth: true}} /> <Stack.Screen name="PreferencesThreads" getComponent={() => PreferencesThreads} - options={{title: title('Threads Preferences')}} + options={{title: title('Threads Preferences'), requireAuth: true}} /> </> ) @@ -340,13 +340,14 @@ function NotificationsTabNavigator() { <NotificationsTab.Screen name="Notifications" getComponent={() => NotificationsScreen} + options={{requireAuth: true}} /> {commonScreens(NotificationsTab as typeof HomeTab)} </NotificationsTab.Navigator> ) } -const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() { +function MyProfileTabNavigator() { const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark) return ( <MyProfileTab.Navigator @@ -358,8 +359,8 @@ const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() { contentStyle, }}> <MyProfileTab.Screen - name="MyProfile" // @ts-ignore // TODO: fix this broken type in ProfileScreen + name="MyProfile" getComponent={() => ProfileScreen} initialParams={{ name: 'me', @@ -368,18 +369,17 @@ const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() { {commonScreens(MyProfileTab as typeof HomeTab)} </MyProfileTab.Navigator> ) -}) +} /** * The FlatNavigator is used by Web to represent the routes * in a single ("flat") stack. */ -const FlatNavigator = observer(function FlatNavigatorImpl() { +const FlatNavigator = () => { const pal = usePalette('default') - const store = useStores() - const unreadCountLabel = store.me.notifications.unreadCountLabel + const numUnread = useUnreadNotifications() - const title = (page: string) => bskyTitle(page, unreadCountLabel) + const title = (page: string) => bskyTitle(page, numUnread) return ( <Flat.Navigator screenOptions={{ @@ -407,12 +407,12 @@ const FlatNavigator = observer(function FlatNavigatorImpl() { <Flat.Screen name="Notifications" getComponent={() => NotificationsScreen} - options={{title: title('Notifications')}} + options={{title: title('Notifications'), requireAuth: true}} /> - {commonScreens(Flat as typeof HomeTab, unreadCountLabel)} + {commonScreens(Flat as typeof HomeTab, numUnread)} </Flat.Navigator> ) -}) +} /** * The RoutesContainer should wrap all components which need access diff --git a/src/lib/analytics/analytics.tsx b/src/lib/analytics/analytics.tsx index 71bb8569a..3a8254eb1 100644 --- a/src/lib/analytics/analytics.tsx +++ b/src/lib/analytics/analytics.tsx @@ -1,16 +1,26 @@ import React from 'react' import {AppState, AppStateStatus} from 'react-native' +import AsyncStorage from '@react-native-async-storage/async-storage' import { createClient, AnalyticsProvider, useAnalytics as useAnalyticsOrig, ClientMethods, } from '@segment/analytics-react-native' -import {RootStoreModel, AppInfo} from 'state/models/root-store' -import {useStores} from 'state/models/root-store' +import {z} from 'zod' +import {useSession} from '#/state/session' import {sha256} from 'js-sha256' import {ScreenEvent, TrackEvent} from './types' import {logger} from '#/logger' +import {listenSessionLoaded} from '#/state/events' + +export const appInfo = z.object({ + build: z.string().optional(), + name: z.string().optional(), + namespace: z.string().optional(), + version: z.string().optional(), +}) +export type AppInfo = z.infer<typeof appInfo> const segmentClient = createClient({ writeKey: '8I6DsgfiSLuoONyaunGoiQM7A6y2ybdI', @@ -21,10 +31,10 @@ const segmentClient = createClient({ export const track = segmentClient?.track?.bind?.(segmentClient) as TrackEvent export function useAnalytics() { - const store = useStores() + const {hasSession} = useSession() const methods: ClientMethods = useAnalyticsOrig() return React.useMemo(() => { - if (store.session.hasSession) { + if (hasSession) { return { screen: methods.screen as ScreenEvent, // ScreenEvents defines all the possible screen names track: methods.track as TrackEvent, // TrackEvents defines all the possible track events and their properties @@ -45,21 +55,18 @@ export function useAnalytics() { alias: () => Promise<void>, reset: () => Promise<void>, } - }, [store, methods]) + }, [hasSession, methods]) } -export function init(store: RootStoreModel) { - store.onSessionLoaded(() => { - const sess = store.session.currentSession - if (sess) { - if (sess.did) { - const did_hashed = sha256(sess.did) - segmentClient.identify(did_hashed, {did_hashed}) - logger.debug('Ping w/hash') - } else { - logger.debug('Ping w/o hash') - segmentClient.identify() - } +export function init() { + listenSessionLoaded(account => { + if (account.did) { + const did_hashed = sha256(account.did) + segmentClient.identify(did_hashed, {did_hashed}) + logger.debug('Ping w/hash') + } else { + logger.debug('Ping w/o hash') + segmentClient.identify() } }) @@ -67,7 +74,7 @@ export function init(store: RootStoreModel) { // this is a copy of segment's own lifecycle event tracking // we handle it manually to ensure that it never fires while the app is backgrounded // -prf - segmentClient.isReady.onChange(() => { + segmentClient.isReady.onChange(async () => { if (AppState.currentState !== 'active') { logger.debug('Prevented a metrics ping while the app was backgrounded') return @@ -78,35 +85,29 @@ export function init(store: RootStoreModel) { return } - const oldAppInfo = store.appInfo + const oldAppInfo = await readAppInfo() const newAppInfo = context.app as AppInfo - store.setAppInfo(newAppInfo) + writeAppInfo(newAppInfo) logger.debug('Recording app info', {new: newAppInfo, old: oldAppInfo}) if (typeof oldAppInfo === 'undefined') { - if (store.session.hasSession) { - segmentClient.track('Application Installed', { - version: newAppInfo.version, - build: newAppInfo.build, - }) - } + segmentClient.track('Application Installed', { + version: newAppInfo.version, + build: newAppInfo.build, + }) } else if (newAppInfo.version !== oldAppInfo.version) { - if (store.session.hasSession) { - segmentClient.track('Application Updated', { - version: newAppInfo.version, - build: newAppInfo.build, - previous_version: oldAppInfo.version, - previous_build: oldAppInfo.build, - }) - } - } - if (store.session.hasSession) { - segmentClient.track('Application Opened', { - from_background: false, + segmentClient.track('Application Updated', { version: newAppInfo.version, build: newAppInfo.build, + previous_version: oldAppInfo.version, + previous_build: oldAppInfo.build, }) } + segmentClient.track('Application Opened', { + from_background: false, + version: newAppInfo.version, + build: newAppInfo.build, + }) }) let lastState: AppStateStatus = AppState.currentState @@ -130,3 +131,16 @@ export function Provider({children}: React.PropsWithChildren<{}>) { <AnalyticsProvider client={segmentClient}>{children}</AnalyticsProvider> ) } + +async function writeAppInfo(value: AppInfo) { + await AsyncStorage.setItem('BSKY_APP_INFO', JSON.stringify(value)) +} + +async function readAppInfo(): Promise<AppInfo | undefined> { + const rawData = await AsyncStorage.getItem('BSKY_APP_INFO') + const obj = rawData ? JSON.parse(rawData) : undefined + if (!obj || typeof obj !== 'object') { + return undefined + } + return obj +} diff --git a/src/lib/analytics/analytics.web.tsx b/src/lib/analytics/analytics.web.tsx index fe90d1328..0a5d5d689 100644 --- a/src/lib/analytics/analytics.web.tsx +++ b/src/lib/analytics/analytics.web.tsx @@ -4,10 +4,11 @@ import { AnalyticsProvider, useAnalytics as useAnalyticsOrig, } from '@segment/analytics-react' -import {RootStoreModel} from 'state/models/root-store' -import {useStores} from 'state/models/root-store' import {sha256} from 'js-sha256' + +import {useSession} from '#/state/session' import {logger} from '#/logger' +import {listenSessionLoaded} from '#/state/events' const segmentClient = createClient( { @@ -24,10 +25,10 @@ const segmentClient = createClient( export const track = segmentClient?.track?.bind?.(segmentClient) export function useAnalytics() { - const store = useStores() + const {hasSession} = useSession() const methods = useAnalyticsOrig() return React.useMemo(() => { - if (store.session.hasSession) { + if (hasSession) { return methods } // dont send analytics pings for anonymous users @@ -40,15 +41,14 @@ export function useAnalytics() { alias: () => {}, reset: () => {}, } - }, [store, methods]) + }, [hasSession, methods]) } -export function init(store: RootStoreModel) { - store.onSessionLoaded(() => { - const sess = store.session.currentSession - if (sess) { - if (sess.did) { - const did_hashed = sha256(sess.did) +export function init() { + listenSessionLoaded(account => { + if (account.did) { + if (account.did) { + const did_hashed = sha256(account.did) segmentClient.identify(did_hashed, {did_hashed}) logger.debug('Ping w/hash') } else { diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index 8f259a910..1123c4e23 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -4,7 +4,7 @@ import { AppBskyEmbedRecordWithMedia, AppBskyEmbedRecord, } from '@atproto/api' -import {FeedSourceInfo} from './feed/types' +import {ReasonFeedSource} from './feed/types' import {isPostInLanguage} from '../../locale/helpers' type FeedViewPost = AppBskyFeedDefs.FeedViewPost @@ -16,13 +16,7 @@ export type FeedTunerFn = ( export class FeedViewPostsSlice { isFlattenedReply = false - constructor(public items: FeedViewPost[] = []) {} - - get _reactKey() { - return `slice-${this.items[0].post.uri}-${ - this.items[0].reason?.indexedAt || this.items[0].post.indexedAt - }` - } + constructor(public items: FeedViewPost[], public _reactKey: string) {} get uri() { if (this.isFlattenedReply) { @@ -65,9 +59,9 @@ export class FeedViewPostsSlice { ) } - get source(): FeedSourceInfo | undefined { + get source(): ReasonFeedSource | undefined { return this.items.find(item => '__source' in item && !!item.__source) - ?.__source as FeedSourceInfo + ?.__source as ReasonFeedSource } containsUri(uri: string) { @@ -116,18 +110,35 @@ export class FeedViewPostsSlice { } } +export class NoopFeedTuner { + private keyCounter = 0 + + reset() { + this.keyCounter = 0 + } + tune( + feed: FeedViewPost[], + _opts?: {dryRun: boolean; maintainOrder: boolean}, + ): FeedViewPostsSlice[] { + return feed.map( + item => new FeedViewPostsSlice([item], `slice-${this.keyCounter++}`), + ) + } +} + export class FeedTuner { + private keyCounter = 0 seenUris: Set<string> = new Set() - constructor() {} + constructor(public tunerFns: FeedTunerFn[]) {} reset() { + this.keyCounter = 0 this.seenUris.clear() } tune( feed: FeedViewPost[], - tunerFns: FeedTunerFn[] = [], {dryRun, maintainOrder}: {dryRun: boolean; maintainOrder: boolean} = { dryRun: false, maintainOrder: false, @@ -136,7 +147,9 @@ export class FeedTuner { let slices: FeedViewPostsSlice[] = [] if (maintainOrder) { - slices = feed.map(item => new FeedViewPostsSlice([item])) + slices = feed.map( + item => new FeedViewPostsSlice([item], `slice-${this.keyCounter++}`), + ) } else { // arrange the posts into thread slices for (let i = feed.length - 1; i >= 0; i--) { @@ -152,12 +165,14 @@ export class FeedTuner { continue } } - slices.unshift(new FeedViewPostsSlice([item])) + slices.unshift( + new FeedViewPostsSlice([item], `slice-${this.keyCounter++}`), + ) } } // run the custom tuners - for (const tunerFn of tunerFns) { + for (const tunerFn of this.tunerFns) { slices = tunerFn(this, slices.slice()) } diff --git a/src/lib/api/feed/author.ts b/src/lib/api/feed/author.ts index ec8795e1a..92df84f8b 100644 --- a/src/lib/api/feed/author.ts +++ b/src/lib/api/feed/author.ts @@ -2,37 +2,33 @@ import { AppBskyFeedDefs, AppBskyFeedGetAuthorFeed as GetAuthorFeed, } from '@atproto/api' -import {RootStoreModel} from 'state/index' import {FeedAPI, FeedAPIResponse} from './types' +import {getAgent} from '#/state/session' export class AuthorFeedAPI implements FeedAPI { - cursor: string | undefined - - constructor( - public rootStore: RootStoreModel, - public params: GetAuthorFeed.QueryParams, - ) {} - - reset() { - this.cursor = undefined - } + constructor(public params: GetAuthorFeed.QueryParams) {} async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { - const res = await this.rootStore.agent.getAuthorFeed({ + const res = await getAgent().getAuthorFeed({ ...this.params, limit: 1, }) return res.data.feed[0] } - async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { - const res = await this.rootStore.agent.getAuthorFeed({ + async fetch({ + cursor, + limit, + }: { + cursor: string | undefined + limit: number + }): Promise<FeedAPIResponse> { + const res = await getAgent().getAuthorFeed({ ...this.params, - cursor: this.cursor, + cursor, limit, }) if (res.success) { - this.cursor = res.data.cursor return { cursor: res.data.cursor, feed: this._filter(res.data.feed), diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts index d05d5acd6..47ffc65ed 100644 --- a/src/lib/api/feed/custom.ts +++ b/src/lib/api/feed/custom.ts @@ -2,37 +2,33 @@ import { AppBskyFeedDefs, AppBskyFeedGetFeed as GetCustomFeed, } from '@atproto/api' -import {RootStoreModel} from 'state/index' import {FeedAPI, FeedAPIResponse} from './types' +import {getAgent} from '#/state/session' export class CustomFeedAPI implements FeedAPI { - cursor: string | undefined - - constructor( - public rootStore: RootStoreModel, - public params: GetCustomFeed.QueryParams, - ) {} - - reset() { - this.cursor = undefined - } + constructor(public params: GetCustomFeed.QueryParams) {} async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { - const res = await this.rootStore.agent.app.bsky.feed.getFeed({ + const res = await getAgent().app.bsky.feed.getFeed({ ...this.params, limit: 1, }) return res.data.feed[0] } - async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { - const res = await this.rootStore.agent.app.bsky.feed.getFeed({ + async fetch({ + cursor, + limit, + }: { + cursor: string | undefined + limit: number + }): Promise<FeedAPIResponse> { + const res = await getAgent().app.bsky.feed.getFeed({ ...this.params, - cursor: this.cursor, + cursor, limit, }) if (res.success) { - this.cursor = res.data.cursor // NOTE // some custom feeds fail to enforce the pagination limit // so we manually truncate here diff --git a/src/lib/api/feed/following.ts b/src/lib/api/feed/following.ts index f14807a57..24389b5ed 100644 --- a/src/lib/api/feed/following.ts +++ b/src/lib/api/feed/following.ts @@ -1,30 +1,29 @@ import {AppBskyFeedDefs} from '@atproto/api' -import {RootStoreModel} from 'state/index' import {FeedAPI, FeedAPIResponse} from './types' +import {getAgent} from '#/state/session' export class FollowingFeedAPI implements FeedAPI { - cursor: string | undefined - - constructor(public rootStore: RootStoreModel) {} - - reset() { - this.cursor = undefined - } + constructor() {} async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { - const res = await this.rootStore.agent.getTimeline({ + const res = await getAgent().getTimeline({ limit: 1, }) return res.data.feed[0] } - async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { - const res = await this.rootStore.agent.getTimeline({ - cursor: this.cursor, + async fetch({ + cursor, + limit, + }: { + cursor: string | undefined + limit: number + }): Promise<FeedAPIResponse> { + const res = await getAgent().getTimeline({ + cursor, limit, }) if (res.success) { - this.cursor = res.data.cursor return { cursor: res.data.cursor, feed: res.data.feed, diff --git a/src/lib/api/feed/likes.ts b/src/lib/api/feed/likes.ts index e9bb14b0b..2b0afdf11 100644 --- a/src/lib/api/feed/likes.ts +++ b/src/lib/api/feed/likes.ts @@ -2,37 +2,33 @@ import { AppBskyFeedDefs, AppBskyFeedGetActorLikes as GetActorLikes, } from '@atproto/api' -import {RootStoreModel} from 'state/index' import {FeedAPI, FeedAPIResponse} from './types' +import {getAgent} from '#/state/session' export class LikesFeedAPI implements FeedAPI { - cursor: string | undefined - - constructor( - public rootStore: RootStoreModel, - public params: GetActorLikes.QueryParams, - ) {} - - reset() { - this.cursor = undefined - } + constructor(public params: GetActorLikes.QueryParams) {} async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { - const res = await this.rootStore.agent.getActorLikes({ + const res = await getAgent().getActorLikes({ ...this.params, limit: 1, }) return res.data.feed[0] } - async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { - const res = await this.rootStore.agent.getActorLikes({ + async fetch({ + cursor, + limit, + }: { + cursor: string | undefined + limit: number + }): Promise<FeedAPIResponse> { + const res = await getAgent().getActorLikes({ ...this.params, - cursor: this.cursor, + cursor, limit, }) if (res.success) { - this.cursor = res.data.cursor return { cursor: res.data.cursor, feed: res.data.feed, diff --git a/src/lib/api/feed/list.ts b/src/lib/api/feed/list.ts index e58494675..19f2ff177 100644 --- a/src/lib/api/feed/list.ts +++ b/src/lib/api/feed/list.ts @@ -2,37 +2,33 @@ import { AppBskyFeedDefs, AppBskyFeedGetListFeed as GetListFeed, } from '@atproto/api' -import {RootStoreModel} from 'state/index' import {FeedAPI, FeedAPIResponse} from './types' +import {getAgent} from '#/state/session' export class ListFeedAPI implements FeedAPI { - cursor: string | undefined - - constructor( - public rootStore: RootStoreModel, - public params: GetListFeed.QueryParams, - ) {} - - reset() { - this.cursor = undefined - } + constructor(public params: GetListFeed.QueryParams) {} async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { - const res = await this.rootStore.agent.app.bsky.feed.getListFeed({ + const res = await getAgent().app.bsky.feed.getListFeed({ ...this.params, limit: 1, }) return res.data.feed[0] } - async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { - const res = await this.rootStore.agent.app.bsky.feed.getListFeed({ + async fetch({ + cursor, + limit, + }: { + cursor: string | undefined + limit: number + }): Promise<FeedAPIResponse> { + const res = await getAgent().app.bsky.feed.getListFeed({ ...this.params, - cursor: this.cursor, + cursor, limit, }) if (res.success) { - this.cursor = res.data.cursor return { cursor: res.data.cursor, feed: res.data.feed, diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts index e0fbcecd8..11e963f0a 100644 --- a/src/lib/api/feed/merge.ts +++ b/src/lib/api/feed/merge.ts @@ -1,11 +1,13 @@ import {AppBskyFeedDefs, AppBskyFeedGetTimeline} from '@atproto/api' import shuffle from 'lodash.shuffle' -import {RootStoreModel} from 'state/index' import {timeout} from 'lib/async/timeout' import {bundleAsync} from 'lib/async/bundle' import {feedUriToHref} from 'lib/strings/url-helpers' import {FeedTuner} from '../feed-manip' -import {FeedAPI, FeedAPIResponse, FeedSourceInfo} from './types' +import {FeedAPI, FeedAPIResponse, ReasonFeedSource} from './types' +import {FeedParams} from '#/state/queries/post-feed' +import {FeedTunerFn} from '../feed-manip' +import {getAgent} from '#/state/session' const REQUEST_WAIT_MS = 500 // 500ms const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours @@ -17,28 +19,44 @@ export class MergeFeedAPI implements FeedAPI { itemCursor = 0 sampleCursor = 0 - constructor(public rootStore: RootStoreModel) { - this.following = new MergeFeedSource_Following(this.rootStore) + constructor(public params: FeedParams, public feedTuners: FeedTunerFn[]) { + this.following = new MergeFeedSource_Following(this.feedTuners) } reset() { - this.following = new MergeFeedSource_Following(this.rootStore) + this.following = new MergeFeedSource_Following(this.feedTuners) this.customFeeds = [] // just empty the array, they will be captured in _fetchNext() this.feedCursor = 0 this.itemCursor = 0 this.sampleCursor = 0 + if (this.params.mergeFeedEnabled && this.params.mergeFeedSources) { + this.customFeeds = shuffle( + this.params.mergeFeedSources.map( + feedUri => new MergeFeedSource_Custom(feedUri, this.feedTuners), + ), + ) + } else { + this.customFeeds = [] + } } async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { - const res = await this.rootStore.agent.getTimeline({ + const res = await getAgent().getTimeline({ limit: 1, }) return res.data.feed[0] } - async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { - // we capture here to ensure the data has loaded - this._captureFeedsIfNeeded() + async fetch({ + cursor, + limit, + }: { + cursor: string | undefined + limit: number + }): Promise<FeedAPIResponse> { + if (!cursor) { + this.reset() + } const promises = [] @@ -76,7 +94,7 @@ export class MergeFeedAPI implements FeedAPI { } return { - cursor: posts.length ? 'fake' : undefined, + cursor: posts.length ? String(this.itemCursor) : undefined, feed: posts, } } @@ -107,28 +125,15 @@ export class MergeFeedAPI implements FeedAPI { // provide follow return this.following.take(1) } - - _captureFeedsIfNeeded() { - if (!this.rootStore.preferences.homeFeed.lab_mergeFeedEnabled) { - return - } - if (this.customFeeds.length === 0) { - this.customFeeds = shuffle( - this.rootStore.preferences.savedFeeds.map( - feedUri => new MergeFeedSource_Custom(this.rootStore, feedUri), - ), - ) - } - } } class MergeFeedSource { - sourceInfo: FeedSourceInfo | undefined + sourceInfo: ReasonFeedSource | undefined cursor: string | undefined = undefined queue: AppBskyFeedDefs.FeedViewPost[] = [] hasMore = true - constructor(public rootStore: RootStoreModel) {} + constructor(public feedTuners: FeedTunerFn[]) {} get numReady() { return this.queue.length @@ -175,7 +180,7 @@ class MergeFeedSource { } class MergeFeedSource_Following extends MergeFeedSource { - tuner = new FeedTuner() + tuner = new FeedTuner(this.feedTuners) reset() { super.reset() @@ -190,16 +195,12 @@ class MergeFeedSource_Following extends MergeFeedSource { cursor: string | undefined, limit: number, ): Promise<AppBskyFeedGetTimeline.Response> { - const res = await this.rootStore.agent.getTimeline({cursor, limit}) + const res = await getAgent().getTimeline({cursor, limit}) // run the tuner pre-emptively to ensure better mixing - const slices = this.tuner.tune( - res.data.feed, - this.rootStore.preferences.getFeedTuners('home'), - { - dryRun: false, - maintainOrder: true, - }, - ) + const slices = this.tuner.tune(res.data.feed, { + dryRun: false, + maintainOrder: true, + }) res.data.feed = slices.map(slice => slice.rootItem) return res } @@ -208,15 +209,16 @@ class MergeFeedSource_Following extends MergeFeedSource { class MergeFeedSource_Custom extends MergeFeedSource { minDate: Date - constructor(public rootStore: RootStoreModel, public feedUri: string) { - super(rootStore) + constructor(public feedUri: string, public feedTuners: FeedTunerFn[]) { + super(feedTuners) this.sourceInfo = { + $type: 'reasonFeedSource', displayName: feedUri.split('/').pop() || '', uri: feedUriToHref(feedUri), } this.minDate = new Date(Date.now() - POST_AGE_CUTOFF) - this.rootStore.agent.app.bsky.feed - .getFeedGenerator({ + getAgent() + .app.bsky.feed.getFeedGenerator({ feed: feedUri, }) .then( @@ -234,7 +236,7 @@ class MergeFeedSource_Custom extends MergeFeedSource { limit: number, ): Promise<AppBskyFeedGetTimeline.Response> { try { - const res = await this.rootStore.agent.app.bsky.feed.getFeed({ + const res = await getAgent().app.bsky.feed.getFeed({ cursor, limit, feed: this.feedUri, diff --git a/src/lib/api/feed/types.ts b/src/lib/api/feed/types.ts index 006344334..5d2a90c1d 100644 --- a/src/lib/api/feed/types.ts +++ b/src/lib/api/feed/types.ts @@ -6,12 +6,27 @@ export interface FeedAPIResponse { } export interface FeedAPI { - reset(): void peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> - fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> + fetch({ + cursor, + limit, + }: { + cursor: string | undefined + limit: number + }): Promise<FeedAPIResponse> } -export interface FeedSourceInfo { +export interface ReasonFeedSource { + $type: 'reasonFeedSource' uri: string displayName: string } + +export function isReasonFeedSource(v: unknown): v is ReasonFeedSource { + return ( + !!v && + typeof v === 'object' && + '$type' in v && + v.$type === 'reasonFeedSource' + ) +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 9d48a78c0..a78abcacd 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -4,12 +4,12 @@ import { AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, AppBskyRichtextFacet, + BskyAgent, ComAtprotoLabelDefs, ComAtprotoRepoUploadBlob, RichText, } from '@atproto/api' import {AtUri} from '@atproto/api' -import {RootStoreModel} from 'state/models/root-store' import {isNetworkError} from 'lib/strings/errors' import {LinkMeta} from '../link-meta/link-meta' import {isWeb} from 'platform/detection' @@ -25,46 +25,19 @@ export interface ExternalEmbedDraft { localThumb?: ImageModel } -export async function resolveName(store: RootStoreModel, didOrHandle: string) { - if (!didOrHandle) { - throw new Error('Invalid handle: ""') - } - if (didOrHandle.startsWith('did:')) { - return didOrHandle - } - - // we run the resolution always to ensure freshness - const promise = store.agent - .resolveHandle({ - handle: didOrHandle, - }) - .then(res => { - store.handleResolutions.cache.set(didOrHandle, res.data.did) - return res.data.did - }) - - // but we can return immediately if it's cached - const cached = store.handleResolutions.cache.get(didOrHandle) - if (cached) { - return cached - } - - return promise -} - export async function uploadBlob( - store: RootStoreModel, + agent: BskyAgent, blob: string, encoding: string, ): Promise<ComAtprotoRepoUploadBlob.Response> { if (isWeb) { // `blob` should be a data uri - return store.agent.uploadBlob(convertDataURIToUint8Array(blob), { + return agent.uploadBlob(convertDataURIToUint8Array(blob), { encoding, }) } else { // `blob` should be a path to a file in the local FS - return store.agent.uploadBlob( + return agent.uploadBlob( blob, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts {encoding}, ) @@ -81,12 +54,11 @@ interface PostOpts { extLink?: ExternalEmbedDraft images?: ImageModel[] labels?: string[] - knownHandles?: Set<string> onStateChange?: (state: string) => void langs?: string[] } -export async function post(store: RootStoreModel, opts: PostOpts) { +export async function post(agent: BskyAgent, opts: PostOpts) { let embed: | AppBskyEmbedImages.Main | AppBskyEmbedExternal.Main @@ -102,7 +74,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) { ) opts.onStateChange?.('Processing...') - await rt.detectFacets(store.agent) + await rt.detectFacets(agent) rt = shortenLinks(rt) // filter out any mention facets that didn't map to a user @@ -135,7 +107,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) { await image.compress() const path = image.compressed?.path ?? image.path const {width, height} = image.compressed || image - const res = await uploadBlob(store, path, 'image/jpeg') + const res = await uploadBlob(agent, path, 'image/jpeg') images.push({ image: res.data.blob, alt: image.altText ?? '', @@ -185,7 +157,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) { } if (encoding) { const thumbUploadRes = await uploadBlob( - store, + agent, opts.extLink.localThumb.path, encoding, ) @@ -224,7 +196,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) { // add replyTo if post is a reply to another post if (opts.replyTo) { const replyToUrip = new AtUri(opts.replyTo) - const parentPost = await store.agent.getPost({ + const parentPost = await agent.getPost({ repo: replyToUrip.host, rkey: replyToUrip.rkey, }) @@ -257,7 +229,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) { try { opts.onStateChange?.('Posting...') - return await store.agent.post({ + return await agent.post({ text: rt.text, facets: rt.facets, reply, diff --git a/src/lib/async/revertible.ts b/src/lib/async/revertible.ts deleted file mode 100644 index 43383b61e..000000000 --- a/src/lib/async/revertible.ts +++ /dev/null @@ -1,68 +0,0 @@ -import {runInAction} from 'mobx' -import {deepObserve} from 'mobx-utils' -import set from 'lodash.set' - -const ongoingActions = new Set<any>() - -/** - * This is a TypeScript function that optimistically updates data on the client-side before sending a - * request to the server and rolling back changes if the request fails. - * @param {T} model - The object or record that needs to be updated optimistically. - * @param preUpdate - `preUpdate` is a function that is called before the server update is executed. It - * can be used to perform any necessary actions or updates on the model or UI before the server update - * is initiated. - * @param serverUpdate - `serverUpdate` is a function that returns a Promise representing the server - * update operation. This function is called after the previous state of the model has been recorded - * and the `preUpdate` function has been executed. If the server update is successful, the `postUpdate` - * function is called with the result - * @param [postUpdate] - `postUpdate` is an optional callback function that will be called after the - * server update is successful. It takes in the response from the server update as its parameter. If - * this parameter is not provided, nothing will happen after the server update. - * @returns A Promise that resolves to `void`. - */ -export const updateDataOptimistically = async < - T extends Record<string, any>, - U, ->( - model: T, - preUpdate: () => void, - serverUpdate: () => Promise<U>, - postUpdate?: (res: U) => void, -): Promise<void> => { - if (ongoingActions.has(model)) { - return - } - ongoingActions.add(model) - - const prevState: Map<string, any> = new Map<string, any>() - const dispose = deepObserve(model, (change, path) => { - if (change.observableKind === 'object') { - if (change.type === 'update') { - prevState.set( - [path, change.name].filter(Boolean).join('.'), - change.oldValue, - ) - } else if (change.type === 'add') { - prevState.set([path, change.name].filter(Boolean).join('.'), undefined) - } - } - }) - preUpdate() - dispose() - - try { - const res = await serverUpdate() - runInAction(() => { - postUpdate?.(res) - }) - } catch (error) { - runInAction(() => { - prevState.forEach((value, path) => { - set(model, path, value) - }) - }) - throw error - } finally { - ongoingActions.delete(model) - } -} diff --git a/src/lib/batchedUpdates.ts b/src/lib/batchedUpdates.ts new file mode 100644 index 000000000..2530d6ca9 --- /dev/null +++ b/src/lib/batchedUpdates.ts @@ -0,0 +1 @@ +export {unstable_batchedUpdates as batchedUpdates} from 'react-native' diff --git a/src/lib/batchedUpdates.web.ts b/src/lib/batchedUpdates.web.ts new file mode 100644 index 000000000..03147ed67 --- /dev/null +++ b/src/lib/batchedUpdates.web.ts @@ -0,0 +1,2 @@ +// @ts-ignore +export {unstable_batchedUpdates as batchedUpdates} from 'react-dom' diff --git a/src/state/persisted/broadcast/index.ts b/src/lib/broadcast/index.ts index e0e7f724b..aa3aef580 100644 --- a/src/state/persisted/broadcast/index.ts +++ b/src/lib/broadcast/index.ts @@ -3,4 +3,9 @@ export default class BroadcastChannel { postMessage(_data: any) {} close() {} onmessage: (event: MessageEvent) => void = () => {} + addEventListener(_type: string, _listener: (event: MessageEvent) => void) {} + removeEventListener( + _type: string, + _listener: (event: MessageEvent) => void, + ) {} } diff --git a/src/state/persisted/broadcast/index.web.ts b/src/lib/broadcast/index.web.ts index 33b3548ad..33b3548ad 100644 --- a/src/state/persisted/broadcast/index.web.ts +++ b/src/lib/broadcast/index.web.ts diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 472b59d76..aa5983be7 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,4 +1,10 @@ -import {Insets} from 'react-native' +import {Insets, Platform} from 'react-native' + +export const LOCAL_DEV_SERVICE = + Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583' +export const STAGING_SERVICE = 'https://staging.bsky.dev' +export const PROD_SERVICE = 'https://bsky.social' +export const DEFAULT_SERVICE = PROD_SERVICE const HELP_DESK_LANG = 'en-us' export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}` @@ -43,7 +49,10 @@ export function IS_PROD(url: string) { // until open federation, "production" is defined as the main server // this definition will not work once federation is enabled! // -prf - return url.startsWith('https://bsky.social') + return ( + url.startsWith('https://bsky.social') || + url.startsWith('https://api.bsky.app') + ) } export const PROD_TEAM_HANDLES = [ @@ -107,8 +116,8 @@ export async function DEFAULT_FEEDS( } else { // production return { - pinned: [PROD_DEFAULT_FEED('whats-hot')], - saved: [PROD_DEFAULT_FEED('whats-hot')], + pinned: [], + saved: [], } } } diff --git a/src/lib/hooks/useAccountSwitcher.ts b/src/lib/hooks/useAccountSwitcher.ts index 1ddb181a8..8a1dea5fe 100644 --- a/src/lib/hooks/useAccountSwitcher.ts +++ b/src/lib/hooks/useAccountSwitcher.ts @@ -1,43 +1,55 @@ -import {useCallback, useState} from 'react' -import {useStores} from 'state/index' -import {useAnalytics} from 'lib/analytics/analytics' -import {StackActions, useNavigation} from '@react-navigation/native' -import {NavigationProp} from 'lib/routes/types' -import {AccountData} from 'state/models/session' -import {reset as resetNavigation} from '../../Navigation' -import * as Toast from 'view/com/util/Toast' -import {useSetDrawerOpen} from '#/state/shell/drawer-open' +import {useCallback} from 'react' +import {useNavigation} from '@react-navigation/native' -export function useAccountSwitcher(): [ - boolean, - (v: boolean) => void, - (acct: AccountData) => Promise<void>, -] { +import {isWeb} from '#/platform/detection' +import {NavigationProp} from '#/lib/routes/types' +import {useAnalytics} from '#/lib/analytics/analytics' +import {useSessionApi, SessionAccount} from '#/state/session' +import * as Toast from '#/view/com/util/Toast' +import {useCloseAllActiveElements} from '#/state/util' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' + +export function useAccountSwitcher() { const {track} = useAnalytics() - const store = useStores() - const setDrawerOpen = useSetDrawerOpen() - const [isSwitching, setIsSwitching] = useState(false) + const {selectAccount, clearCurrentAccount} = useSessionApi() + const closeAllActiveElements = useCloseAllActiveElements() const navigation = useNavigation<NavigationProp>() + const {setShowLoggedOut} = useLoggedOutViewControls() const onPressSwitchAccount = useCallback( - async (acct: AccountData) => { + async (account: SessionAccount) => { track('Settings:SwitchAccountButtonClicked') - setIsSwitching(true) - const success = await store.session.resumeSession(acct) - setDrawerOpen(false) - store.shell.closeAllActiveElements() - if (success) { - resetNavigation() - Toast.show(`Signed in as ${acct.displayName || acct.handle}`) - } else { + + try { + if (account.accessJwt) { + closeAllActiveElements() + navigation.navigate(isWeb ? 'Home' : 'HomeTab') + await selectAccount(account) + setTimeout(() => { + Toast.show(`Signed in as @${account.handle}`) + }, 100) + } else { + closeAllActiveElements() + setShowLoggedOut(true) + Toast.show( + `Please sign in as @${account.handle}`, + 'circle-exclamation', + ) + } + } catch (e) { Toast.show('Sorry! We need you to enter your password.') - navigation.navigate('HomeTab') - navigation.dispatch(StackActions.popToTop()) - store.session.clear() + clearCurrentAccount() // back user out to login } }, - [track, setIsSwitching, navigation, store, setDrawerOpen], + [ + track, + clearCurrentAccount, + selectAccount, + closeAllActiveElements, + navigation, + setShowLoggedOut, + ], ) - return [isSwitching, setIsSwitching, onPressSwitchAccount] + return {onPressSwitchAccount} } diff --git a/src/lib/hooks/useAnimatedScrollHandler_FIXED.ts b/src/lib/hooks/useAnimatedScrollHandler_FIXED.ts new file mode 100644 index 000000000..56a1e8b11 --- /dev/null +++ b/src/lib/hooks/useAnimatedScrollHandler_FIXED.ts @@ -0,0 +1,15 @@ +// Be warned. This Hook is very buggy unless used in a very constrained way. +// To use it safely: +// +// - DO NOT pass its return value as a prop to any user-defined component. +// - DO NOT pass its return value to more than a single component. +// +// In other words, the only safe way to use it is next to the leaf Reanimated View. +// +// Relevant bug reports: +// - https://github.com/software-mansion/react-native-reanimated/issues/5345 +// - https://github.com/software-mansion/react-native-reanimated/issues/5360 +// - https://github.com/software-mansion/react-native-reanimated/issues/5364 +// +// It's great when it works though. +export {useAnimatedScrollHandler} from 'react-native-reanimated' diff --git a/src/lib/hooks/useAnimatedScrollHandler_FIXED.web.ts b/src/lib/hooks/useAnimatedScrollHandler_FIXED.web.ts new file mode 100644 index 000000000..98e05a8ce --- /dev/null +++ b/src/lib/hooks/useAnimatedScrollHandler_FIXED.web.ts @@ -0,0 +1,44 @@ +import {useRef, useEffect} from 'react' +import {useAnimatedScrollHandler as useAnimatedScrollHandler_BUGGY} from 'react-native-reanimated' + +export const useAnimatedScrollHandler: typeof useAnimatedScrollHandler_BUGGY = ( + config, + deps, +) => { + const ref = useRef(config) + useEffect(() => { + ref.current = config + }) + return useAnimatedScrollHandler_BUGGY( + { + onBeginDrag(e, ctx) { + if (typeof ref.current !== 'function' && ref.current.onBeginDrag) { + ref.current.onBeginDrag(e, ctx) + } + }, + onEndDrag(e, ctx) { + if (typeof ref.current !== 'function' && ref.current.onEndDrag) { + ref.current.onEndDrag(e, ctx) + } + }, + onMomentumBegin(e, ctx) { + if (typeof ref.current !== 'function' && ref.current.onMomentumBegin) { + ref.current.onMomentumBegin(e, ctx) + } + }, + onMomentumEnd(e, ctx) { + if (typeof ref.current !== 'function' && ref.current.onMomentumEnd) { + ref.current.onMomentumEnd(e, ctx) + } + }, + onScroll(e, ctx) { + if (typeof ref.current === 'function') { + ref.current(e, ctx) + } else if (ref.current.onScroll) { + ref.current.onScroll(e, ctx) + } + }, + }, + deps, + ) +} diff --git a/src/lib/hooks/useCustomFeed.ts b/src/lib/hooks/useCustomFeed.ts deleted file mode 100644 index 04201b9a1..000000000 --- a/src/lib/hooks/useCustomFeed.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {useEffect, useState} from 'react' -import {useStores} from 'state/index' -import {FeedSourceModel} from 'state/models/content/feed-source' - -export function useCustomFeed(uri: string): FeedSourceModel | undefined { - const store = useStores() - const [item, setItem] = useState<FeedSourceModel | undefined>() - useEffect(() => { - async function buildFeedItem() { - const model = new FeedSourceModel(store, uri) - await model.setup() - setItem(model) - } - buildFeedItem() - }, [store, uri]) - - return item -} diff --git a/src/lib/hooks/useDesktopRightNavItems.ts b/src/lib/hooks/useDesktopRightNavItems.ts deleted file mode 100644 index f27efd28f..000000000 --- a/src/lib/hooks/useDesktopRightNavItems.ts +++ /dev/null @@ -1,51 +0,0 @@ -import {useEffect, useState} from 'react' -import {useStores} from 'state/index' -import isEqual from 'lodash.isequal' -import {AtUri} from '@atproto/api' -import {FeedSourceModel} from 'state/models/content/feed-source' - -interface RightNavItem { - uri: string - href: string - hostname: string - collection: string - rkey: string - displayName: string -} - -export function useDesktopRightNavItems(uris: string[]): RightNavItem[] { - const store = useStores() - const [items, setItems] = useState<RightNavItem[]>([]) - const [lastUris, setLastUris] = useState<string[]>([]) - - useEffect(() => { - if (isEqual(uris, lastUris)) { - // no changes - return - } - - async function fetchFeedInfo() { - const models = uris - .slice(0, 25) - .map(uri => new FeedSourceModel(store, uri)) - await Promise.all(models.map(m => m.setup())) - setItems( - models.map(model => { - const {hostname, collection, rkey} = new AtUri(model.uri) - return { - uri: model.uri, - href: model.href, - hostname, - collection, - rkey, - displayName: model.displayName, - } - }), - ) - setLastUris(uris) - } - fetchFeedInfo() - }, [store, uris, lastUris, setLastUris, setItems]) - - return items -} diff --git a/src/lib/hooks/useFollowProfile.ts b/src/lib/hooks/useFollowProfile.ts deleted file mode 100644 index 98dd63f5f..000000000 --- a/src/lib/hooks/useFollowProfile.ts +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react' -import {AppBskyActorDefs} from '@atproto/api' -import {useStores} from 'state/index' -import {FollowState} from 'state/models/cache/my-follows' -import {logger} from '#/logger' - -export function useFollowProfile(profile: AppBskyActorDefs.ProfileViewBasic) { - const store = useStores() - const state = store.me.follows.getFollowState(profile.did) - - return { - state, - following: state === FollowState.Following, - toggle: React.useCallback(async () => { - if (state === FollowState.Following) { - try { - await store.agent.deleteFollow( - store.me.follows.getFollowUri(profile.did), - ) - store.me.follows.removeFollow(profile.did) - return { - state: FollowState.NotFollowing, - following: false, - } - } catch (e: any) { - logger.error('Failed to delete follow', {error: e}) - throw e - } - } else if (state === FollowState.NotFollowing) { - try { - const res = await store.agent.follow(profile.did) - store.me.follows.addFollow(profile.did, { - followRecordUri: res.uri, - did: profile.did, - handle: profile.handle, - displayName: profile.displayName, - avatar: profile.avatar, - }) - return { - state: FollowState.Following, - following: true, - } - } catch (e: any) { - logger.error('Failed to create follow', {error: e}) - throw e - } - } - - return { - state: FollowState.Unknown, - following: false, - } - }, [store, profile, state]), - } -} diff --git a/src/lib/hooks/useHomeTabs.ts b/src/lib/hooks/useHomeTabs.ts deleted file mode 100644 index 69183e627..000000000 --- a/src/lib/hooks/useHomeTabs.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {useEffect, useState} from 'react' -import {useStores} from 'state/index' -import isEqual from 'lodash.isequal' -import {FeedSourceModel} from 'state/models/content/feed-source' - -export function useHomeTabs(uris: string[]): string[] { - const store = useStores() - const [tabs, setTabs] = useState<string[]>(['Following']) - const [lastUris, setLastUris] = useState<string[]>([]) - - useEffect(() => { - if (isEqual(uris, lastUris)) { - // no changes - return - } - - async function fetchFeedInfo() { - const models = uris - .slice(0, 25) - .map(uri => new FeedSourceModel(store, uri)) - await Promise.all(models.map(m => m.setup())) - setTabs(['Following'].concat(models.map(f => f.displayName))) - setLastUris(uris) - } - fetchFeedInfo() - }, [store, uris, lastUris, setLastUris, setTabs]) - - return tabs -} diff --git a/src/lib/hooks/useMinimalShellMode.tsx b/src/lib/hooks/useMinimalShellMode.tsx index ada934a26..e81fc434f 100644 --- a/src/lib/hooks/useMinimalShellMode.tsx +++ b/src/lib/hooks/useMinimalShellMode.tsx @@ -1,60 +1,43 @@ -import React from 'react' -import {autorun} from 'mobx' -import { - Easing, - interpolate, - useAnimatedStyle, - useSharedValue, - withTiming, -} from 'react-native-reanimated' - +import {interpolate, useAnimatedStyle} from 'react-native-reanimated' import {useMinimalShellMode as useMinimalShellModeState} from '#/state/shell/minimal-mode' +import {useShellLayout} from '#/state/shell/shell-layout' export function useMinimalShellMode() { - const minimalShellMode = useMinimalShellModeState() - const minimalShellInterp = useSharedValue(0) + const mode = useMinimalShellModeState() + const {footerHeight, headerHeight} = useShellLayout() + const footerMinimalShellTransform = useAnimatedStyle(() => { return { - opacity: interpolate(minimalShellInterp.value, [0, 1], [1, 0]), + pointerEvents: mode.value === 0 ? 'auto' : 'none', + opacity: Math.pow(1 - mode.value, 2), transform: [ - {translateY: interpolate(minimalShellInterp.value, [0, 1], [0, 25])}, + { + translateY: interpolate(mode.value, [0, 1], [0, footerHeight.value]), + }, ], } }) const headerMinimalShellTransform = useAnimatedStyle(() => { return { - opacity: interpolate(minimalShellInterp.value, [0, 1], [1, 0]), + pointerEvents: mode.value === 0 ? 'auto' : 'none', + opacity: Math.pow(1 - mode.value, 2), transform: [ - {translateY: interpolate(minimalShellInterp.value, [0, 1], [0, -25])}, + { + translateY: interpolate(mode.value, [0, 1], [0, -headerHeight.value]), + }, ], } }) const fabMinimalShellTransform = useAnimatedStyle(() => { return { transform: [ - {translateY: interpolate(minimalShellInterp.value, [0, 1], [-44, 0])}, + { + translateY: interpolate(mode.value, [0, 1], [-44, 0]), + }, ], } }) - - React.useEffect(() => { - return autorun(() => { - if (minimalShellMode) { - minimalShellInterp.value = withTiming(1, { - duration: 125, - easing: Easing.bezier(0.25, 0.1, 0.25, 1), - }) - } else { - minimalShellInterp.value = withTiming(0, { - duration: 125, - easing: Easing.bezier(0.25, 0.1, 0.25, 1), - }) - } - }) - }, [minimalShellInterp, minimalShellMode]) - return { - minimalShellMode, footerMinimalShellTransform, headerMinimalShellTransform, fabMinimalShellTransform, diff --git a/src/lib/hooks/useNonReactiveCallback.ts b/src/lib/hooks/useNonReactiveCallback.ts new file mode 100644 index 000000000..4b3d6abb9 --- /dev/null +++ b/src/lib/hooks/useNonReactiveCallback.ts @@ -0,0 +1,23 @@ +import {useCallback, useInsertionEffect, useRef} from 'react' + +// This should be used sparingly. It erases reactivity, i.e. when the inputs +// change, the function itself will remain the same. This means that if you +// use this at a higher level of your tree, and then some state you read in it +// changes, there is no mechanism for anything below in the tree to "react" +// to this change (e.g. by knowing to call your function again). +// +// Also, you should avoid calling the returned function during rendering +// since the values captured by it are going to lag behind. +export function useNonReactiveCallback<T extends Function>(fn: T): T { + const ref = useRef(fn) + useInsertionEffect(() => { + ref.current = fn + }, [fn]) + return useCallback( + (...args: any) => { + const latestFn = ref.current + return latestFn(...args) + }, + [ref], + ) as unknown as T +} diff --git a/src/lib/hooks/useOTAUpdate.ts b/src/lib/hooks/useOTAUpdate.ts index 0ce97a4c8..55147329b 100644 --- a/src/lib/hooks/useOTAUpdate.ts +++ b/src/lib/hooks/useOTAUpdate.ts @@ -1,26 +1,26 @@ import * as Updates from 'expo-updates' import {useCallback, useEffect} from 'react' import {AppState} from 'react-native' -import {useStores} from 'state/index' import {logger} from '#/logger' +import {useModalControls} from '#/state/modals' +import {t} from '@lingui/macro' export function useOTAUpdate() { - const store = useStores() + const {openModal} = useModalControls() // HELPER FUNCTIONS const showUpdatePopup = useCallback(() => { - store.shell.openModal({ + openModal({ name: 'confirm', - title: 'Update Available', - message: - 'A new version of the app is available. Please update to continue using the app.', + title: t`Update Available`, + message: t`A new version of the app is available. Please update to continue using the app.`, onPressConfirm: async () => { Updates.reloadAsync().catch(err => { throw err }) }, }) - }, [store.shell]) + }, [openModal]) const checkForUpdate = useCallback(async () => { logger.debug('useOTAUpdate: Checking for update...') try { diff --git a/src/lib/hooks/useOnMainScroll.ts b/src/lib/hooks/useOnMainScroll.ts index 2eab4b250..2e7a79913 100644 --- a/src/lib/hooks/useOnMainScroll.ts +++ b/src/lib/hooks/useOnMainScroll.ts @@ -1,69 +1,125 @@ -import {useState, useCallback, useRef} from 'react' +import {useState, useCallback, useMemo} from 'react' import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native' -import {s} from 'lib/styles' -import {useWebMediaQueries} from './useWebMediaQueries' import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell' +import {useShellLayout} from '#/state/shell/shell-layout' +import {s} from 'lib/styles' +import {isWeb} from 'platform/detection' +import { + useSharedValue, + interpolate, + runOnJS, + ScrollHandlers, +} from 'react-native-reanimated' -const Y_LIMIT = 10 - -const useDeviceLimits = () => { - const {isDesktop} = useWebMediaQueries() - return { - dyLimitUp: isDesktop ? 30 : 10, - dyLimitDown: isDesktop ? 150 : 10, - } +function clamp(num: number, min: number, max: number) { + 'worklet' + return Math.min(Math.max(num, min), max) } export type OnScrollCb = ( event: NativeSyntheticEvent<NativeScrollEvent>, ) => void +export type OnScrollHandler = ScrollHandlers<any> export type ResetCb = () => void -export function useOnMainScroll(): [OnScrollCb, boolean, ResetCb] { - let lastY = useRef(0) - let [isScrolledDown, setIsScrolledDown] = useState(false) - const {dyLimitUp, dyLimitDown} = useDeviceLimits() - const minimalShellMode = useMinimalShellMode() - const setMinimalShellMode = useSetMinimalShellMode() +export function useOnMainScroll(): [OnScrollHandler, boolean, ResetCb] { + const {headerHeight} = useShellLayout() + const [isScrolledDown, setIsScrolledDown] = useState(false) + const mode = useMinimalShellMode() + const setMode = useSetMinimalShellMode() + const startDragOffset = useSharedValue<number | null>(null) + const startMode = useSharedValue<number | null>(null) - return [ - useCallback( - (event: NativeSyntheticEvent<NativeScrollEvent>) => { - const y = event.nativeEvent.contentOffset.y - const dy = y - (lastY.current || 0) - lastY.current = y + const onBeginDrag = useCallback( + (e: NativeScrollEvent) => { + 'worklet' + startDragOffset.value = e.contentOffset.y + startMode.value = mode.value + }, + [mode, startDragOffset, startMode], + ) - if (!minimalShellMode && dy > dyLimitDown && y > Y_LIMIT) { - setMinimalShellMode(true) - } else if (minimalShellMode && (dy < dyLimitUp * -1 || y <= Y_LIMIT)) { - setMinimalShellMode(false) - } + const onEndDrag = useCallback( + (e: NativeScrollEvent) => { + 'worklet' + startDragOffset.value = null + startMode.value = null + if (e.contentOffset.y < headerHeight.value / 2) { + // If we're close to the top, show the shell. + setMode(false) + } else { + // Snap to whichever state is the closest. + setMode(Math.round(mode.value) === 1) + } + }, + [startDragOffset, startMode, setMode, mode, headerHeight], + ) + + const onScroll = useCallback( + (e: NativeScrollEvent) => { + 'worklet' + // Keep track of whether we want to show "scroll to top". + if (!isScrolledDown && e.contentOffset.y > s.window.height) { + runOnJS(setIsScrolledDown)(true) + } else if (isScrolledDown && e.contentOffset.y < s.window.height) { + runOnJS(setIsScrolledDown)(false) + } - if ( - !isScrolledDown && - event.nativeEvent.contentOffset.y > s.window.height - ) { - setIsScrolledDown(true) - } else if ( - isScrolledDown && - event.nativeEvent.contentOffset.y < s.window.height - ) { - setIsScrolledDown(false) + if (startDragOffset.value === null || startMode.value === null) { + if (mode.value !== 0 && e.contentOffset.y < headerHeight.value) { + // If we're close enough to the top, always show the shell. + // Even if we're not dragging. + setMode(false) + return } - }, - [ - dyLimitDown, - dyLimitUp, - isScrolledDown, - minimalShellMode, - setMinimalShellMode, - ], - ), + if (isWeb) { + // On the web, there is no concept of "starting" the drag. + // When we get the first scroll event, we consider that the start. + startDragOffset.value = e.contentOffset.y + startMode.value = mode.value + } + return + } + + // The "mode" value is always between 0 and 1. + // Figure out how much to move it based on the current dragged distance. + const dy = e.contentOffset.y - startDragOffset.value + const dProgress = interpolate( + dy, + [-headerHeight.value, headerHeight.value], + [-1, 1], + ) + const newValue = clamp(startMode.value + dProgress, 0, 1) + if (newValue !== mode.value) { + // Manually adjust the value. This won't be (and shouldn't be) animated. + mode.value = newValue + } + if (isWeb) { + // On the web, there is no concept of "starting" the drag, + // so we don't have any specific anchor point to calculate the distance. + // Instead, update it continuosly along the way and diff with the last event. + startDragOffset.value = e.contentOffset.y + startMode.value = mode.value + } + }, + [headerHeight, mode, setMode, isScrolledDown, startDragOffset, startMode], + ) + + const scrollHandler: ScrollHandlers<any> = useMemo( + () => ({ + onBeginDrag, + onEndDrag, + onScroll, + }), + [onBeginDrag, onEndDrag, onScroll], + ) + + return [ + scrollHandler, isScrolledDown, useCallback(() => { setIsScrolledDown(false) - setMinimalShellMode(false) - lastY.current = 1e8 // NOTE we set this very high so that the onScroll logic works right -prf - }, [setIsScrolledDown, setMinimalShellMode]), + setMode(false) + }, [setMode]), ] } diff --git a/src/lib/hooks/useSetTitle.ts b/src/lib/hooks/useSetTitle.ts index c5c7a5ca1..129023f71 100644 --- a/src/lib/hooks/useSetTitle.ts +++ b/src/lib/hooks/useSetTitle.ts @@ -3,18 +3,14 @@ import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' import {bskyTitle} from 'lib/strings/headings' -import {useStores} from 'state/index' +import {useUnreadNotifications} from '#/state/queries/notifications/unread' -/** - * Requires consuming component to be wrapped in `observer`: - * https://stackoverflow.com/a/71488009 - */ export function useSetTitle(title?: string) { const navigation = useNavigation<NavigationProp>() - const {unreadCountLabel} = useStores().me.notifications + const numUnread = useUnreadNotifications() useEffect(() => { if (title) { - navigation.setOptions({title: bskyTitle(title, unreadCountLabel)}) + navigation.setOptions({title: bskyTitle(title, numUnread)}) } - }, [title, navigation, unreadCountLabel]) + }, [title, navigation, numUnread]) } diff --git a/src/lib/hooks/useToggleMutationQueue.ts b/src/lib/hooks/useToggleMutationQueue.ts new file mode 100644 index 000000000..28ae86142 --- /dev/null +++ b/src/lib/hooks/useToggleMutationQueue.ts @@ -0,0 +1,98 @@ +import {useState, useRef, useEffect, useCallback} from 'react' + +type Task<TServerState> = { + isOn: boolean + resolve: (serverState: TServerState) => void + reject: (e: unknown) => void +} + +type TaskQueue<TServerState> = { + activeTask: Task<TServerState> | null + queuedTask: Task<TServerState> | null +} + +function AbortError() { + const e = new Error() + e.name = 'AbortError' + return e +} + +export function useToggleMutationQueue<TServerState>({ + initialState, + runMutation, + onSuccess, +}: { + initialState: TServerState + runMutation: ( + prevState: TServerState, + nextIsOn: boolean, + ) => Promise<TServerState> + onSuccess: (finalState: TServerState) => void +}) { + // We use the queue as a mutable object. + // This is safe becuase it is not used for rendering. + const [queue] = useState<TaskQueue<TServerState>>({ + activeTask: null, + queuedTask: null, + }) + + async function processQueue() { + if (queue.activeTask) { + // There is another active processQueue call iterating over tasks. + // It will handle any newly added tasks, so we should exit early. + return + } + // To avoid relying on the rendered state, capture it once at the start. + // From that point on, and until the queue is drained, we'll use the real server state. + let confirmedState: TServerState = initialState + try { + while (queue.queuedTask) { + const prevTask = queue.activeTask + const nextTask = queue.queuedTask + queue.activeTask = nextTask + queue.queuedTask = null + if (prevTask?.isOn === nextTask.isOn) { + // Skip multiple requests to update to the same value in a row. + prevTask.reject(new (AbortError as any)()) + continue + } + try { + // The state received from the server feeds into the next task. + // This lets us queue deletions of not-yet-created resources. + confirmedState = await runMutation(confirmedState, nextTask.isOn) + nextTask.resolve(confirmedState) + } catch (e) { + nextTask.reject(e) + } + } + } finally { + onSuccess(confirmedState) + queue.activeTask = null + queue.queuedTask = null + } + } + + function queueToggle(isOn: boolean): Promise<TServerState> { + return new Promise((resolve, reject) => { + // This is a toggle, so the next queued value can safely replace the queued one. + if (queue.queuedTask) { + queue.queuedTask.reject(new (AbortError as any)()) + } + queue.queuedTask = {isOn, resolve, reject} + processQueue() + }) + } + + const queueToggleRef = useRef(queueToggle) + useEffect(() => { + queueToggleRef.current = queueToggle + }) + const queueToggleStable = useCallback( + (isOn: boolean): Promise<TServerState> => { + const queueToggleLatest = queueToggleRef.current + return queueToggleLatest(isOn) + }, + [], + ) + return queueToggleStable +} diff --git a/src/lib/hooks/useWebMediaQueries.tsx b/src/lib/hooks/useWebMediaQueries.tsx index 3f43a0aaf..71a96a89b 100644 --- a/src/lib/hooks/useWebMediaQueries.tsx +++ b/src/lib/hooks/useWebMediaQueries.tsx @@ -3,8 +3,8 @@ import {isNative} from 'platform/detection' export function useWebMediaQueries() { const isDesktop = useMediaQuery({minWidth: 1300}) - const isTablet = useMediaQuery({minWidth: 800, maxWidth: 1300}) - const isMobile = useMediaQuery({maxWidth: 800}) + const isTablet = useMediaQuery({minWidth: 800, maxWidth: 1300 - 1}) + const isMobile = useMediaQuery({maxWidth: 800 - 1}) const isTabletOrMobile = isMobile || isTablet const isTabletOrDesktop = isDesktop || isTablet if (isNative) { diff --git a/src/lib/labeling/const.ts b/src/lib/labeling/const.ts deleted file mode 100644 index 5c2e68137..000000000 --- a/src/lib/labeling/const.ts +++ /dev/null @@ -1,89 +0,0 @@ -import {LabelPreferencesModel} from 'state/models/ui/preferences' -import {LabelValGroup} from './types' - -export const ILLEGAL_LABEL_GROUP: LabelValGroup = { - id: 'illegal', - title: 'Illegal Content', - warning: 'Illegal Content', - values: ['csam', 'dmca-violation', 'nudity-nonconsensual'], -} - -export const ALWAYS_FILTER_LABEL_GROUP: LabelValGroup = { - id: 'always-filter', - title: 'Content Warning', - warning: 'Content Warning', - values: ['!filter'], -} - -export const ALWAYS_WARN_LABEL_GROUP: LabelValGroup = { - id: 'always-warn', - title: 'Content Warning', - warning: 'Content Warning', - values: ['!warn', 'account-security'], -} - -export const UNKNOWN_LABEL_GROUP: LabelValGroup = { - id: 'unknown', - title: 'Unknown Label', - warning: 'Content Warning', - values: [], -} - -export const CONFIGURABLE_LABEL_GROUPS: Record< - keyof LabelPreferencesModel, - LabelValGroup -> = { - nsfw: { - id: 'nsfw', - title: 'Explicit Sexual Images', - subtitle: 'i.e. pornography', - warning: 'Sexually Explicit', - values: ['porn', 'nsfl'], - isAdultImagery: true, - }, - nudity: { - id: 'nudity', - title: 'Other Nudity', - subtitle: 'Including non-sexual and artistic', - warning: 'Nudity', - values: ['nudity'], - isAdultImagery: true, - }, - suggestive: { - id: 'suggestive', - title: 'Sexually Suggestive', - subtitle: 'Does not include nudity', - warning: 'Sexually Suggestive', - values: ['sexual'], - isAdultImagery: true, - }, - gore: { - id: 'gore', - title: 'Violent / Bloody', - subtitle: 'Gore, self-harm, torture', - warning: 'Violence', - values: ['gore', 'self-harm', 'torture', 'nsfl', 'corpse'], - isAdultImagery: true, - }, - hate: { - id: 'hate', - title: 'Hate Group Iconography', - subtitle: 'Images of terror groups, articles covering events, etc.', - warning: 'Hate Groups', - values: ['icon-kkk', 'icon-nazi', 'icon-intolerant', 'behavior-intolerant'], - }, - spam: { - id: 'spam', - title: 'Spam', - subtitle: 'Excessive unwanted interactions', - warning: 'Spam', - values: ['spam'], - }, - impersonation: { - id: 'impersonation', - title: 'Impersonation', - subtitle: 'Accounts falsely claiming to be people or orgs', - warning: 'Impersonation', - values: ['impersonation'], - }, -} diff --git a/src/lib/labeling/types.ts b/src/lib/labeling/types.ts deleted file mode 100644 index 84d59be7f..000000000 --- a/src/lib/labeling/types.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {ComAtprotoLabelDefs} from '@atproto/api' -import {LabelPreferencesModel} from 'state/models/ui/preferences' - -export type Label = ComAtprotoLabelDefs.Label - -export interface LabelValGroup { - id: - | keyof LabelPreferencesModel - | 'illegal' - | 'always-filter' - | 'always-warn' - | 'unknown' - title: string - isAdultImagery?: boolean - subtitle?: string - warning: string - values: string[] -} diff --git a/src/lib/link-meta/bsky.ts b/src/lib/link-meta/bsky.ts index b052ed04b..322b02332 100644 --- a/src/lib/link-meta/bsky.ts +++ b/src/lib/link-meta/bsky.ts @@ -1,10 +1,10 @@ +import {AppBskyFeedPost, BskyAgent} from '@atproto/api' import * as apilib from 'lib/api/index' import {LikelyType, LinkMeta} from './link-meta' // import {match as matchRoute} from 'view/routes' import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers' -import {RootStoreModel} from 'state/index' -import {PostThreadModel} from 'state/models/content/post-thread' -import {ComposerOptsQuote} from 'state/models/ui/shell' +import {ComposerOptsQuote} from 'state/shell/composer' +import {useGetPost} from '#/state/queries/post' // TODO // import {Home} from 'view/screens/Home' @@ -22,7 +22,7 @@ import {ComposerOptsQuote} from 'state/models/ui/shell' // remove once that's implemented // -prf export async function extractBskyMeta( - store: RootStoreModel, + agent: BskyAgent, url: string, ): Promise<LinkMeta> { url = convertBskyAppUrlIfNeeded(url) @@ -102,38 +102,30 @@ export async function extractBskyMeta( } export async function getPostAsQuote( - store: RootStoreModel, + getPost: ReturnType<typeof useGetPost>, url: string, ): Promise<ComposerOptsQuote> { url = convertBskyAppUrlIfNeeded(url) const [_0, user, _1, rkey] = url.split('/').filter(Boolean) - const threadUri = makeRecordUri(user, 'app.bsky.feed.post', rkey) - - const threadView = new PostThreadModel(store, { - uri: threadUri, - depth: 0, - }) - await threadView.setup() - if (!threadView.thread || threadView.notFound) { - throw new Error('Not found') - } + const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey) + const post = await getPost({uri: uri}) return { - uri: threadView.thread.post.uri, - cid: threadView.thread.post.cid, - text: threadView.thread.postRecord?.text || '', - indexedAt: threadView.thread.post.indexedAt, - author: threadView.thread.post.author, + uri: post.uri, + cid: post.cid, + text: AppBskyFeedPost.isRecord(post.record) ? post.record.text : '', + indexedAt: post.indexedAt, + author: post.author, } } export async function getFeedAsEmbed( - store: RootStoreModel, + agent: BskyAgent, url: string, ): Promise<apilib.ExternalEmbedDraft> { url = convertBskyAppUrlIfNeeded(url) const [_0, user, _1, rkey] = url.split('/').filter(Boolean) const feed = makeRecordUri(user, 'app.bsky.feed.generator', rkey) - const res = await store.agent.app.bsky.feed.getFeedGenerator({feed}) + const res = await agent.app.bsky.feed.getFeedGenerator({feed}) return { isLoading: false, uri: feed, @@ -153,13 +145,13 @@ export async function getFeedAsEmbed( } export async function getListAsEmbed( - store: RootStoreModel, + agent: BskyAgent, url: string, ): Promise<apilib.ExternalEmbedDraft> { url = convertBskyAppUrlIfNeeded(url) const [_0, user, _1, rkey] = url.split('/').filter(Boolean) const list = makeRecordUri(user, 'app.bsky.graph.list', rkey) - const res = await store.agent.app.bsky.graph.getList({list}) + const res = await agent.app.bsky.graph.getList({list}) return { isLoading: false, uri: list, diff --git a/src/lib/link-meta/link-meta.ts b/src/lib/link-meta/link-meta.ts index c490fa292..c17dee51f 100644 --- a/src/lib/link-meta/link-meta.ts +++ b/src/lib/link-meta/link-meta.ts @@ -1,5 +1,5 @@ +import {BskyAgent} from '@atproto/api' import {isBskyAppUrl} from '../strings/url-helpers' -import {RootStoreModel} from 'state/index' import {extractBskyMeta} from './bsky' import {LINK_META_PROXY} from 'lib/constants' @@ -23,12 +23,12 @@ export interface LinkMeta { } export async function getLinkMeta( - store: RootStoreModel, + agent: BskyAgent, url: string, timeout = 5e3, ): Promise<LinkMeta> { if (isBskyAppUrl(url)) { - return extractBskyMeta(store, url) + return extractBskyMeta(agent, url) } let urlp @@ -55,9 +55,9 @@ export async function getLinkMeta( const to = setTimeout(() => controller.abort(), timeout || 5e3) const response = await fetch( - `${LINK_META_PROXY( - store.session.currentSession?.service || '', - )}${encodeURIComponent(url)}`, + `${LINK_META_PROXY(agent.service.toString() || '')}${encodeURIComponent( + url, + )}`, {signal: controller.signal}, ) diff --git a/src/lib/media/alt-text.ts b/src/lib/media/alt-text.ts deleted file mode 100644 index 4109f667a..000000000 --- a/src/lib/media/alt-text.ts +++ /dev/null @@ -1,12 +0,0 @@ -import {RootStoreModel} from 'state/index' -import {ImageModel} from 'state/models/media/image' - -export async function openAltTextModal( - store: RootStoreModel, - image: ImageModel, -) { - store.shell.openModal({ - name: 'alt-text-image', - image, - }) -} diff --git a/src/lib/media/image-sizes.ts b/src/lib/media/image-sizes.ts new file mode 100644 index 000000000..4ea95ea23 --- /dev/null +++ b/src/lib/media/image-sizes.ts @@ -0,0 +1,34 @@ +import {Image} from 'react-native' +import type {Dimensions} from 'lib/media/types' + +const sizes: Map<string, Dimensions> = new Map() +const activeRequests: Map<string, Promise<Dimensions>> = new Map() + +export function get(uri: string): Dimensions | undefined { + return sizes.get(uri) +} + +export async function fetch(uri: string): Promise<Dimensions> { + const Dimensions = sizes.get(uri) + if (Dimensions) { + return Dimensions + } + + const prom = + activeRequests.get(uri) || + new Promise<Dimensions>(resolve => { + Image.getSize( + uri, + (width: number, height: number) => resolve({width, height}), + (err: any) => { + console.error('Failed to fetch image dimensions for', uri, err) + resolve({width: 0, height: 0}) + }, + ) + }) + activeRequests.set(uri, prom) + const res = await prom + activeRequests.delete(uri) + sizes.set(uri, res) + return res +} diff --git a/src/lib/media/picker.e2e.tsx b/src/lib/media/picker.e2e.tsx index 9805c3464..096667479 100644 --- a/src/lib/media/picker.e2e.tsx +++ b/src/lib/media/picker.e2e.tsx @@ -1,4 +1,3 @@ -import {RootStoreModel} from 'state/index' import {Image as RNImage} from 'react-native-image-crop-picker' import RNFS from 'react-native-fs' import {CropperOptions} from './types' @@ -22,18 +21,15 @@ async function getFile() { }) } -export async function openPicker(_store: RootStoreModel): Promise<RNImage[]> { +export async function openPicker(): Promise<RNImage[]> { return [await getFile()] } -export async function openCamera(_store: RootStoreModel): Promise<RNImage> { +export async function openCamera(): Promise<RNImage> { return await getFile() } -export async function openCropper( - _store: RootStoreModel, - opts: CropperOptions, -): Promise<RNImage> { +export async function openCropper(opts: CropperOptions): Promise<RNImage> { return { path: opts.path, mime: 'image/jpeg', diff --git a/src/lib/media/picker.tsx b/src/lib/media/picker.tsx index d0ee1ae22..bf531c981 100644 --- a/src/lib/media/picker.tsx +++ b/src/lib/media/picker.tsx @@ -3,23 +3,10 @@ import { openCropper as openCropperFn, Image as RNImage, } from 'react-native-image-crop-picker' -import {RootStoreModel} from 'state/index' import {CameraOpts, CropperOptions} from './types' export {openPicker} from './picker.shared' -/** - * NOTE - * These methods all include the RootStoreModel as the first param - * because the web versions require it. The signatures have to remain - * equivalent between the different forms, but the store param is not - * used here. - * -prf - */ - -export async function openCamera( - _store: RootStoreModel, - opts: CameraOpts, -): Promise<RNImage> { +export async function openCamera(opts: CameraOpts): Promise<RNImage> { const item = await openCameraFn({ width: opts.width, height: opts.height, @@ -39,10 +26,7 @@ export async function openCamera( } } -export async function openCropper( - _store: RootStoreModel, - opts: CropperOptions, -) { +export async function openCropper(opts: CropperOptions) { const item = await openCropperFn({ ...opts, forceJpg: true, // ios only diff --git a/src/lib/media/picker.web.tsx b/src/lib/media/picker.web.tsx index d12685b0c..995a0c95f 100644 --- a/src/lib/media/picker.web.tsx +++ b/src/lib/media/picker.web.tsx @@ -1,25 +1,19 @@ /// <reference lib="dom" /> import {CameraOpts, CropperOptions} from './types' -import {RootStoreModel} from 'state/index' import {Image as RNImage} from 'react-native-image-crop-picker' export {openPicker} from './picker.shared' +import {unstable__openModal} from '#/state/modals' -export async function openCamera( - _store: RootStoreModel, - _opts: CameraOpts, -): Promise<RNImage> { +export async function openCamera(_opts: CameraOpts): Promise<RNImage> { // const mediaType = opts.mediaType || 'photo' TODO throw new Error('TODO') } -export async function openCropper( - store: RootStoreModel, - opts: CropperOptions, -): Promise<RNImage> { +export async function openCropper(opts: CropperOptions): Promise<RNImage> { // TODO handle more opts return new Promise((resolve, reject) => { - store.shell.openModal({ + unstable__openModal({ name: 'crop-image', uri: opts.path, onSelect: (img?: RNImage) => { diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts index 73f9c56f6..6e79e6b91 100644 --- a/src/lib/notifications/notifications.ts +++ b/src/lib/notifications/notifications.ts @@ -1,19 +1,20 @@ import * as Notifications from 'expo-notifications' -import {RootStoreModel} from '../../state' +import {QueryClient} from '@tanstack/react-query' import {resetToTab} from '../../Navigation' import {devicePlatform, isIOS} from 'platform/detection' import {track} from 'lib/analytics/analytics' import {logger} from '#/logger' +import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed' +import {truncateAndInvalidate} from '#/state/queries/util' +import {listenSessionLoaded} from '#/state/events' const SERVICE_DID = (serviceUrl?: string) => serviceUrl?.includes('staging') ? 'did:web:api.staging.bsky.dev' : 'did:web:api.bsky.app' -export function init(store: RootStoreModel) { - store.onUnreadNotifications(count => Notifications.setBadgeCountAsync(count)) - - store.onSessionLoaded(async () => { +export function init(queryClient: QueryClient) { + listenSessionLoaded(async (account, agent) => { // request notifications permission once the user has logged in const perms = await Notifications.getPermissionsAsync() if (!perms.granted) { @@ -24,8 +25,8 @@ export function init(store: RootStoreModel) { const token = await getPushToken() if (token) { try { - await store.agent.api.app.bsky.notification.registerPush({ - serviceDid: SERVICE_DID(store.session.data?.service), + await agent.api.app.bsky.notification.registerPush({ + serviceDid: SERVICE_DID(account.service), platform: devicePlatform, token: token.data, appId: 'xyz.blueskyweb.app', @@ -53,8 +54,8 @@ export function init(store: RootStoreModel) { ) if (t) { try { - await store.agent.api.app.bsky.notification.registerPush({ - serviceDid: SERVICE_DID(store.session.data?.service), + await agent.api.app.bsky.notification.registerPush({ + serviceDid: SERVICE_DID(account.service), platform: devicePlatform, token: t, appId: 'xyz.blueskyweb.app', @@ -83,7 +84,7 @@ export function init(store: RootStoreModel) { ) if (event.request.trigger.type === 'push') { // refresh notifications in the background - store.me.notifications.syncQueue() + truncateAndInvalidate(queryClient, RQKEY_NOTIFS()) // handle payload-based deeplinks let payload if (isIOS) { @@ -121,7 +122,7 @@ export function init(store: RootStoreModel) { logger.DebugContext.notifications, ) track('Notificatons:OpenApp') - store.me.notifications.refresh() // refresh notifications + truncateAndInvalidate(queryClient, RQKEY_NOTIFS()) resetToTab('NotificationsTab') // open notifications tab } }, diff --git a/src/lib/react-query.ts b/src/lib/react-query.ts index 2a8f1d759..6ec620f74 100644 --- a/src/lib/react-query.ts +++ b/src/lib/react-query.ts @@ -1,3 +1,22 @@ import {QueryClient} from '@tanstack/react-query' -export const queryClient = new QueryClient() +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // NOTE + // refetchOnWindowFocus breaks some UIs (like feeds) + // so we NEVER want to enable this + // -prf + refetchOnWindowFocus: false, + // Structural sharing between responses makes it impossible to rely on + // "first seen" timestamps on objects to determine if they're fresh. + // Disable this optimization so that we can rely on "first seen" timestamps. + structuralSharing: false, + // We don't want to retry queries by default, because in most cases we + // want to fail early and show a response to the user. There are + // exceptions, and those can be made on a per-query basis. For others, we + // should give users controls to retry. + retry: false, + }, + }, +}) diff --git a/src/lib/sentry.ts b/src/lib/sentry.ts index b080bcc5c..63a21a43c 100644 --- a/src/lib/sentry.ts +++ b/src/lib/sentry.ts @@ -1,8 +1,46 @@ +/** + * Importing these separately from `platform/detection` and `lib/app-info` to + * avoid future conflicts and/or circular deps + */ + +import {Platform} from 'react-native' +import app from 'react-native-version-number' +import * as info from 'expo-updates' import {init} from 'sentry-expo' +/** + * Matches the build profile `channel` props in `eas.json` + */ +const buildChannel = (info.channel || 'development') as + | 'development' + | 'preview' + | 'production' + +/** + * Examples: + * - `dev` + * - `1.57.0` + */ +const release = app.appVersion ?? 'dev' + +/** + * Examples: + * - `web.dev` + * - `ios.dev` + * - `android.dev` + * - `web.1.57.0` + * - `ios.1.57.0.3` + * - `android.1.57.0.46` + */ +const dist = `${Platform.OS}.${release}${ + app.buildVersion ? `.${app.buildVersion}` : '' +}` + init({ dsn: 'https://05bc3789bf994b81bd7ce20c86ccd3ae@o4505071687041024.ingest.sentry.io/4505071690514432', - enableInExpoDevelopment: false, // if true, Sentry will try to send events/errors in development mode. debug: false, // If `true`, Sentry will try to print out useful debugging information if something goes wrong with sending the event. Set it to `false` in production - environment: __DEV__ ? 'development' : 'production', // Set the environment + enableInExpoDevelopment: true, + environment: buildChannel, + dist, + release, }) diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index 106d2ca31..e9bf4111d 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -1,5 +1,5 @@ import {AtUri} from '@atproto/api' -import {PROD_SERVICE} from 'state/index' +import {PROD_SERVICE} from 'lib/constants' import TLDs from 'tlds' import psl from 'psl' @@ -168,8 +168,15 @@ export function getYoutubeVideoId(link: string): string | undefined { return videoId } +/** + * Checks if the label in the post text matches the host of the link facet. + * + * Hosts are case-insensitive, so should be lowercase for comparison. + * @see https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2 + */ export function linkRequiresWarning(uri: string, label: string) { const labelDomain = labelToDomain(label) + let urip try { urip = new URL(uri) @@ -177,7 +184,9 @@ export function linkRequiresWarning(uri: string, label: string) { return true } - if (urip.hostname === 'bsky.app') { + const host = urip.hostname.toLowerCase() + + if (host === 'bsky.app') { // if this is a link to internal content, // warn if it represents itself as a URL to another app if ( @@ -194,20 +203,26 @@ export function linkRequiresWarning(uri: string, label: string) { if (!labelDomain) { return true } - return labelDomain !== urip.hostname + return labelDomain !== host } } -function labelToDomain(label: string): string | undefined { +/** + * Returns a lowercase domain hostname if the label is a valid URL. + * + * Hosts are case-insensitive, so should be lowercase for comparison. + * @see https://www.rfc-editor.org/rfc/rfc3986#section-3.2.2 + */ +export function labelToDomain(label: string): string | undefined { // any spaces just immediately consider the label a non-url if (/\s/.test(label)) { return undefined } try { - return new URL(label).hostname + return new URL(label).hostname.toLowerCase() } catch {} try { - return new URL('https://' + label).hostname + return new URL('https://' + label).hostname.toLowerCase() } catch {} return undefined } diff --git a/src/locale/i18n.ts b/src/locale/i18n.ts new file mode 100644 index 000000000..73fa785ea --- /dev/null +++ b/src/locale/i18n.ts @@ -0,0 +1,38 @@ +import {useLanguagePrefs} from '#/state/preferences' +import {i18n} from '@lingui/core' +import {useEffect} from 'react' +import {messages as messagesEn} from './locales/en/messages' +import {messages as messagesHi} from './locales/hi/messages' + +export const locales = { + en: 'English', + cs: 'Česky', + fr: 'Français', + hi: 'हिंदी', + es: 'Español', +} +export const defaultLocale = 'en' + +/** + * We do a dynamic import of just the catalog that we need + * @param locale any locale string + */ +export async function dynamicActivate(locale: string) { + if (locale === 'en') { + i18n.loadAndActivate({locale, messages: messagesEn}) + return + } else if (locale === 'hi') { + i18n.loadAndActivate({locale, messages: messagesHi}) + return + } else { + i18n.loadAndActivate({locale, messages: messagesEn}) + return + } +} + +export async function useLocaleLanguage() { + const {appLanguage} = useLanguagePrefs() + useEffect(() => { + dynamicActivate(appLanguage) + }, [appLanguage]) +} diff --git a/src/locale/i18n.web.ts b/src/locale/i18n.web.ts new file mode 100644 index 000000000..0ea69d1ae --- /dev/null +++ b/src/locale/i18n.web.ts @@ -0,0 +1,29 @@ +import {useLanguagePrefs} from '#/state/preferences' +import {i18n} from '@lingui/core' +import {useEffect} from 'react' + +export const locales = { + en: 'English', + cs: 'Česky', + fr: 'Français', + hi: 'हिंदी', + es: 'Español', +} +export const defaultLocale = 'en' + +/** + * We do a dynamic import of just the catalog that we need + * @param locale any locale string + */ +export async function dynamicActivate(locale: string) { + const {messages} = await import(`./locales/${locale}/messages`) + i18n.load(locale, messages) + i18n.activate(locale) +} + +export async function useLocaleLanguage() { + const {appLanguage} = useLanguagePrefs() + useEffect(() => { + dynamicActivate(appLanguage) + }, [appLanguage]) +} diff --git a/src/locale/i18nProvider.tsx b/src/locale/i18nProvider.tsx new file mode 100644 index 000000000..3766f5b71 --- /dev/null +++ b/src/locale/i18nProvider.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import {I18nProvider as DefaultI18nProvider} from '@lingui/react' +import {i18n} from '@lingui/core' +import {useLocaleLanguage} from './i18n' + +export default function I18nProvider({children}: {children: React.ReactNode}) { + useLocaleLanguage() + return <DefaultI18nProvider i18n={i18n}>{children}</DefaultI18nProvider> +} diff --git a/src/locale/languages.ts b/src/locale/languages.ts index a61047e19..cfcc60c5a 100644 --- a/src/locale/languages.ts +++ b/src/locale/languages.ts @@ -4,6 +4,16 @@ interface Language { name: string } +interface AppLanguage { + code2: string + name: string +} + +export const APP_LANGUAGES: AppLanguage[] = [ + {code2: 'en', name: 'English'}, + {code2: 'hi', name: 'हिंदी'}, +] + export const LANGUAGES: Language[] = [ {code3: 'aar', code2: 'aa', name: 'Afar'}, {code3: 'abk', code2: 'ab', name: 'Abkhazian'}, diff --git a/src/locale/locales/cs/messages.po b/src/locale/locales/cs/messages.po new file mode 100644 index 000000000..6cd60a889 --- /dev/null +++ b/src/locale/locales/cs/messages.po @@ -0,0 +1,2297 @@ +msgid "" +msgstr "" +"POT-Creation-Date: 2023-11-05 16:01-0800\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: @lingui/cli\n" +"Language: cs\n" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Plural-Forms: \n" + +#: src/view/screens/Profile.tsx:214 +#~ msgid "- end of feed -" +#~ msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:138 +#~ msgid ". This warning is only available for posts with media attached." +#~ msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:158 +msgid "{0, plural, one {# invite code available} other {# invite codes available}}" +msgstr "" + +#: src/view/com/modals/Repost.tsx:44 +msgid "{0}" +msgstr "" + +#: src/view/com/modals/CreateOrEditList.tsx:176 +msgid "{0} {purposeLabel} List" +msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:141 +msgid "{invitesAvailable, plural, one {Invite codes: # available} other {Invite codes: # available}}" +msgstr "" + +#: src/view/screens/Settings.tsx:407 +#: src/view/shell/Drawer.tsx:521 +msgid "{invitesAvailable} invite code available" +msgstr "" + +#: src/view/screens/Settings.tsx:409 +#: src/view/shell/Drawer.tsx:523 +msgid "{invitesAvailable} invite codes available" +msgstr "" + +#: src/view/screens/Search/Search.tsx:86 +msgid "{message}" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:30 +msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:37 +msgid "<0>Follow some</0><1>Recommended</1><2>Users</2>" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:132 +#~ msgid "<0>Here is your app password.</0> Use this to sign into the other app along with your handle." +#~ msgstr "" + +#: src/lib/hooks/useOTAUpdate.ts:16 +msgid "A new version of the app is available. Please update to continue using the app." +msgstr "" + +#: src/view/com/modals/EditImage.tsx:299 +#: src/view/screens/Settings.tsx:417 +msgid "Accessibility" +msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:161 +#: src/view/screens/Settings.tsx:286 +msgid "Account" +msgstr "" + +#: src/view/com/util/AccountDropdownBtn.tsx:41 +msgid "Account options" +msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:264 +#: src/view/com/modals/UserAddRemoveLists.tsx:187 +#: src/view/screens/ProfileList.tsx:675 +msgid "Add" +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:56 +msgid "Add a content warning" +msgstr "" + +#: src/view/screens/ProfileList.tsx:665 +msgid "Add a user to this list" +msgstr "" + +#: src/view/screens/Settings.tsx:355 +#: src/view/screens/Settings.tsx:364 +msgid "Add account" +msgstr "" + +#: src/view/com/composer/photos/Gallery.tsx:119 +#: src/view/com/composer/photos/Gallery.tsx:180 +msgid "Add alt text" +msgstr "" + +#: src/view/com/modals/report/InputIssueDetails.tsx:41 +#: src/view/com/modals/report/Modal.tsx:191 +msgid "Add details" +msgstr "" + +#: src/view/com/modals/report/Modal.tsx:194 +msgid "Add details to report" +msgstr "" + +#: src/view/com/composer/Composer.tsx:418 +msgid "Add link card" +msgstr "" + +#: src/view/com/composer/Composer.tsx:421 +msgid "Add link card:" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:415 +msgid "Add the following DNS record to your domain:" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:327 +msgid "Add to Lists" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:279 +msgid "Add to my feeds" +msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:191 +#: src/view/com/modals/UserAddRemoveLists.tsx:122 +msgid "Added to list" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:164 +msgid "Adjust the number of likes a reply must have to be shown in your feed." +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:75 +msgid "Adult Content" +msgstr "" + +#: src/view/screens/Settings.tsx:569 +msgid "Advanced" +msgstr "" + +#: src/view/com/composer/photos/Gallery.tsx:130 +msgid "ALT" +msgstr "" + +#: src/view/com/modals/EditImage.tsx:315 +msgid "Alt text" +msgstr "" + +#: src/view/com/composer/photos/Gallery.tsx:209 +msgid "Alt text describes images for blind and low-vision users, and helps give context to everyone." +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:110 +msgid "An email has been sent to {0}. It includes a confirmation code which you can enter below." +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:119 +msgid "An email has been sent to your previous address, {0}. It includes a confirmation code which you can enter below." +msgstr "" + +#: src/view/com/notifications/FeedItem.tsx:236 +msgid "and" +msgstr "" + +#: src/view/screens/LanguageSettings.tsx:92 +msgid "App Language" +msgstr "" + +#: src/view/screens/Settings.tsx:589 +msgid "App passwords" +msgstr "" + +#: src/view/screens/AppPasswords.tsx:186 +msgid "App Passwords" +msgstr "" + +#: src/view/screens/Settings.tsx:432 +msgid "Appearance" +msgstr "" + +#: src/view/screens/AppPasswords.tsx:223 +msgid "Are you sure you want to delete the app password \"{name}\"?" +msgstr "" + +#: src/view/com/composer/Composer.tsx:137 +msgid "Are you sure you'd like to discard this draft?" +msgstr "" + +#: src/view/screens/ProfileList.tsx:345 +msgid "Are you sure?" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:188 +msgid "Are you sure? This cannot be undone." +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:123 +msgid "Artistic or non-erotic nudity." +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:145 +#: src/view/com/auth/login/ChooseAccountForm.tsx:151 +#: src/view/com/auth/login/ForgotPasswordForm.tsx:166 +#: src/view/com/auth/login/LoginForm.tsx:251 +#: src/view/com/auth/login/SetNewPasswordForm.tsx:148 +#: src/view/com/modals/report/InputIssueDetails.tsx:45 +#: src/view/com/post-thread/PostThread.tsx:376 +#: src/view/com/post-thread/PostThread.tsx:426 +#: src/view/com/post-thread/PostThread.tsx:434 +#: src/view/com/profile/ProfileHeader.tsx:633 +msgid "Back" +msgstr "" + +#: src/view/screens/Settings.tsx:461 +msgid "Basics" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:131 +#: src/view/com/modals/BirthDateSettings.tsx:72 +msgid "Birthday" +msgstr "" + +#: src/view/screens/Settings.tsx:312 +msgid "Birthday:" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:256 +#: src/view/com/profile/ProfileHeader.tsx:363 +msgid "Block Account" +msgstr "" + +#: src/view/screens/ProfileList.tsx:446 +msgid "Block accounts" +msgstr "" + +#: src/view/screens/ProfileList.tsx:302 +msgid "Block these accounts?" +msgstr "" + +#: src/view/screens/Moderation.tsx:109 +msgid "Blocked accounts" +msgstr "" + +#: src/view/screens/ModerationBlockedAccounts.tsx:106 +msgid "Blocked Accounts" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:258 +msgid "Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you." +msgstr "" + +#: src/view/screens/ModerationBlockedAccounts.tsx:114 +msgid "Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you. You will not see their content and they will be prevented from seeing yours." +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:237 +msgid "Blocked post." +msgstr "" + +#: src/view/screens/ProfileList.tsx:304 +msgid "Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you." +msgstr "" + +#: src/view/com/auth/SplashScreen.tsx:26 +msgid "Bluesky" +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:80 +msgid "Bluesky is flexible." +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:69 +msgid "Bluesky is open." +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:56 +msgid "Bluesky is public." +msgstr "" + +#: src/view/com/modals/Waitlist.tsx:70 +msgid "Bluesky uses invites to build a healthier community. If you don't know anybody with an invite, you can sign up for the waitlist and we'll send one soon." +msgstr "" + +#: src/view/com/modals/ServerInput.tsx:78 +msgid "Bluesky.Social" +msgstr "" + +#: src/view/screens/Settings.tsx:718 +msgid "Build version {0} {1}" +msgstr "" + +#: src/view/com/composer/photos/OpenCameraBtn.tsx:60 +#: src/view/com/util/UserAvatar.tsx:217 +#: src/view/com/util/UserBanner.tsx:38 +msgid "Camera" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:214 +msgid "Can only contain letters, numbers, spaces, dashes, and underscores. Must be at least 4 characters long, but no more than 32 characters long." +msgstr "" + +#: src/view/com/composer/Composer.tsx:271 +#: src/view/com/composer/Composer.tsx:274 +#: src/view/com/modals/AltImage.tsx:127 +#: src/view/com/modals/ChangeEmail.tsx:218 +#: src/view/com/modals/ChangeEmail.tsx:220 +#: src/view/com/modals/Confirm.tsx:88 +#: src/view/com/modals/CreateOrEditList.tsx:267 +#: src/view/com/modals/CreateOrEditList.tsx:272 +#: src/view/com/modals/DeleteAccount.tsx:150 +#: src/view/com/modals/DeleteAccount.tsx:223 +#: src/view/com/modals/EditImage.tsx:323 +#: src/view/com/modals/EditProfile.tsx:248 +#: src/view/com/modals/LinkWarning.tsx:85 +#: src/view/com/modals/Repost.tsx:73 +#: src/view/com/modals/Waitlist.tsx:136 +#: src/view/screens/Search/Search.tsx:586 +#: src/view/shell/desktop/Search.tsx:181 +msgid "Cancel" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:146 +#: src/view/com/modals/DeleteAccount.tsx:219 +msgid "Cancel account deletion" +msgstr "" + +#: src/view/com/modals/AltImage.tsx:122 +msgid "Cancel add image alt text" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:149 +msgid "Cancel change handle" +msgstr "" + +#: src/view/com/modals/crop-image/CropImage.web.tsx:134 +msgid "Cancel image crop" +msgstr "" + +#: src/view/com/modals/EditProfile.tsx:243 +msgid "Cancel profile editing" +msgstr "" + +#: src/view/com/modals/Repost.tsx:64 +msgid "Cancel quote post" +msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:87 +#: src/view/shell/desktop/Search.tsx:177 +msgid "Cancel search" +msgstr "" + +#: src/view/com/modals/Waitlist.tsx:132 +msgid "Cancel waitlist signup" +msgstr "" + +#: src/view/screens/Settings.tsx:306 +msgid "Change" +msgstr "" + +#: src/view/screens/Settings.tsx:601 +#: src/view/screens/Settings.tsx:610 +msgid "Change handle" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:161 +msgid "Change Handle" +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:133 +msgid "Change my email" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:109 +msgid "Change Your Email" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:121 +msgid "Check out some recommended feeds. Tap + to add them to your list of pinned feeds." +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:185 +msgid "Check out some recommended users. Follow them to see similar users." +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:163 +msgid "Check your inbox for an email with the confirmation code to enter below:" +msgstr "" + +#: src/view/com/modals/ServerInput.tsx:38 +msgid "Choose Service" +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:83 +msgid "Choose the algorithms that power your experience with custom feeds." +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:65 +#~ msgid "Choose your" +#~ msgstr "" + +#: src/view/com/auth/create/Step2.tsx:106 +msgid "Choose your password" +msgstr "" + +#: src/view/screens/Settings.tsx:694 +msgid "Clear all legacy storage data" +msgstr "" + +#: src/view/screens/Settings.tsx:696 +msgid "Clear all legacy storage data (restart after this)" +msgstr "" + +#: src/view/screens/Settings.tsx:706 +msgid "Clear all storage data" +msgstr "" + +#: src/view/screens/Settings.tsx:708 +msgid "Clear all storage data (restart after this)" +msgstr "" + +#: src/view/com/util/forms/SearchInput.tsx:73 +#: src/view/screens/Search/Search.tsx:571 +msgid "Clear search query" +msgstr "" + +#: src/view/com/auth/login/PasswordUpdatedForm.tsx:38 +msgid "Close alert" +msgstr "" + +#: src/view/com/util/BottomSheetCustomBackdrop.tsx:33 +msgid "Close bottom drawer" +msgstr "" + +#: src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx:26 +msgid "Close image" +msgstr "" + +#: src/view/com/lightbox/Lightbox.web.tsx:112 +msgid "Close image viewer" +msgstr "" + +#: src/view/shell/index.web.tsx:49 +msgid "Close navigation footer" +msgstr "" + +#: src/view/screens/CommunityGuidelines.tsx:32 +msgid "Community Guidelines" +msgstr "" + +#: src/view/com/composer/Prompt.tsx:24 +msgid "Compose reply" +msgstr "" + +#: src/view/com/modals/Confirm.tsx:75 +#: src/view/com/modals/SelfLabel.tsx:154 +#: src/view/com/modals/VerifyEmail.tsx:217 +#: src/view/screens/PreferencesHomeFeed.tsx:299 +#: src/view/screens/PreferencesThreads.tsx:153 +msgid "Confirm" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:193 +#: src/view/com/modals/ChangeEmail.tsx:195 +msgid "Confirm Change" +msgstr "" + +#: src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx:34 +msgid "Confirm content language settings" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:209 +msgid "Confirm delete account" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:157 +#: src/view/com/modals/DeleteAccount.tsx:176 +#: src/view/com/modals/VerifyEmail.tsx:151 +msgid "Confirmation code" +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:178 +#: src/view/com/auth/login/LoginForm.tsx:270 +msgid "Connecting..." +msgstr "" + +#: src/view/screens/Moderation.tsx:67 +msgid "Content filtering" +msgstr "" + +#: src/view/com/modals/ContentFilteringSettings.tsx:44 +msgid "Content Filtering" +msgstr "" + +#: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:74 +#: src/view/screens/LanguageSettings.tsx:273 +msgid "Content Languages" +msgstr "" + +#: src/view/com/util/moderation/ScreenHider.tsx:69 +msgid "Content Warning" +msgstr "" + +#: src/view/com/composer/labels/LabelsBtn.tsx:31 +msgid "Content warnings" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:148 +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:209 +msgid "Continue" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:193 +#: src/view/com/modals/InviteCodes.tsx:178 +msgid "Copied" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:186 +msgid "Copy" +msgstr "" + +#: src/view/screens/ProfileList.tsx:375 +msgid "Copy link to list" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:126 +msgid "Copy link to post" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:312 +msgid "Copy link to profile" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:112 +msgid "Copy post text" +msgstr "" + +#: src/view/screens/CopyrightPolicy.tsx:29 +msgid "Copyright Policy" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:102 +msgid "Could not load feed" +msgstr "" + +#: src/view/screens/ProfileList.tsx:752 +msgid "Could not load list" +msgstr "" + +#: src/view/com/auth/SplashScreen.tsx:41 +msgid "Create a new account" +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:124 +msgid "Create Account" +msgstr "" + +#: src/view/com/auth/SplashScreen.tsx:38 +msgid "Create new account" +msgstr "" + +#: src/view/screens/AppPasswords.tsx:248 +msgid "Created {0}" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:387 +#: src/view/com/modals/ServerInput.tsx:102 +msgid "Custom domain" +msgstr "" + +#: src/view/screens/Settings.tsx:615 +msgid "Danger Zone" +msgstr "" + +#: src/view/screens/Settings.tsx:411 +#~ msgid "Dark" +#~ msgstr "" + +#: src/view/screens/Settings.tsx:622 +msgid "Delete account" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:83 +msgid "Delete Account" +msgstr "" + +#: src/view/screens/AppPasswords.tsx:221 +#: src/view/screens/AppPasswords.tsx:241 +msgid "Delete app password" +msgstr "" + +#: src/view/screens/ProfileList.tsx:344 +#: src/view/screens/ProfileList.tsx:402 +msgid "Delete List" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:212 +msgid "Delete my account" +msgstr "" + +#: src/view/screens/Settings.tsx:632 +msgid "Delete my account…" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:183 +msgid "Delete post" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:187 +msgid "Delete this post?" +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:229 +msgid "Deleted post." +msgstr "" + +#: src/view/com/modals/CreateOrEditList.tsx:218 +#: src/view/com/modals/CreateOrEditList.tsx:234 +#: src/view/com/modals/EditProfile.tsx:197 +#: src/view/com/modals/EditProfile.tsx:209 +msgid "Description" +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:96 +msgid "Dev Server" +msgstr "" + +#: src/view/screens/Settings.tsx:637 +msgid "Developer Tools" +msgstr "" + +#: src/view/com/composer/Composer.tsx:138 +msgid "Discard" +msgstr "" + +#: src/view/com/composer/Composer.tsx:132 +msgid "Discard draft" +msgstr "" + +#: src/view/screens/Feeds.tsx:405 +msgid "Discover new feeds" +msgstr "" + +#: src/view/com/modals/EditProfile.tsx:191 +msgid "Display name" +msgstr "" + +#: src/view/com/modals/EditProfile.tsx:179 +msgid "Display Name" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:485 +msgid "Domain verified!" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:86 +#: src/view/com/modals/ContentFilteringSettings.tsx:88 +#: src/view/com/modals/ContentFilteringSettings.tsx:96 +#: src/view/com/modals/crop-image/CropImage.web.tsx:152 +#: src/view/com/modals/EditImage.tsx:333 +#: src/view/com/modals/ListAddRemoveUsers.tsx:142 +#: src/view/com/modals/SelfLabel.tsx:157 +#: src/view/com/modals/UserAddRemoveLists.tsx:75 +#: src/view/screens/PreferencesHomeFeed.tsx:302 +#: src/view/screens/PreferencesThreads.tsx:156 +msgid "Done" +msgstr "" + +#: src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx:42 +msgid "Done{extraText}" +msgstr "" + +#: src/view/com/modals/InviteCodes.tsx:94 +msgid "Each code works once. You'll receive more invite codes periodically." +msgstr "" + +#: src/view/com/composer/photos/Gallery.tsx:144 +#: src/view/com/modals/EditImage.tsx:207 +msgid "Edit image" +msgstr "" + +#: src/view/screens/ProfileList.tsx:390 +msgid "Edit list details" +msgstr "" + +#: src/view/screens/Feeds.tsx:367 +#: src/view/screens/SavedFeeds.tsx:85 +msgid "Edit My Feeds" +msgstr "" + +#: src/view/com/modals/EditProfile.tsx:151 +msgid "Edit my profile" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:425 +msgid "Edit profile" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:428 +msgid "Edit Profile" +msgstr "" + +#: src/view/screens/Feeds.tsx:330 +msgid "Edit Saved Feeds" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:90 +#: src/view/com/auth/login/ForgotPasswordForm.tsx:148 +#: src/view/com/modals/ChangeEmail.tsx:141 +#: src/view/com/modals/Waitlist.tsx:88 +msgid "Email" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:81 +msgid "Email address" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:111 +msgid "Email Updated" +msgstr "" + +#: src/view/screens/Settings.tsx:290 +msgid "Email:" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:138 +msgid "Enable this setting to only see replies between people you follow." +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:71 +msgid "Enter the address of your provider:" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:369 +msgid "Enter the domain you want to use" +msgstr "" + +#: src/view/com/auth/login/ForgotPasswordForm.tsx:101 +msgid "Enter the email you used to create your account. We'll send you a \"reset code\" so you can set a new password." +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:86 +msgid "Enter your email address" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:117 +msgid "Enter your new email address below." +msgstr "" + +#: src/view/com/auth/login/Login.tsx:83 +msgid "Enter your username and password" +msgstr "" + +#: src/view/screens/Search/Search.tsx:104 +msgid "Error:" +msgstr "" + +#: src/view/com/lightbox/Lightbox.web.tsx:156 +msgid "Expand alt text" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:109 +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:141 +msgid "Failed to load recommended feeds" +msgstr "" + +#: src/view/screens/Feeds.tsx:559 +msgid "Feed offline" +msgstr "" + +#: src/view/com/feeds/FeedPage.tsx:132 +msgid "Feed Preferences" +msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:64 +#: src/view/shell/Drawer.tsx:410 +msgid "Feedback" +msgstr "" + +#: src/view/screens/Feeds.tsx:475 +#: src/view/shell/bottom-bar/BottomBar.tsx:168 +#: src/view/shell/desktop/LeftNav.tsx:341 +#: src/view/shell/Drawer.tsx:327 +#: src/view/shell/Drawer.tsx:328 +msgid "Feeds" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:57 +msgid "Feeds are created by users to curate content. Choose some feeds that you find interesting." +msgstr "" + +#: src/view/screens/SavedFeeds.tsx:156 +msgid "Feeds are custom algorithms that users build with a little coding expertise. <0/> for more information." +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollowsItem.tsx:150 +msgid "Finding similar accounts..." +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:102 +msgid "Fine-tune the content you see on your home screen." +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:60 +msgid "Fine-tune the discussion threads." +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:510 +msgid "Follow" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:42 +#~ msgid "Follow some" +#~ msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:64 +msgid "Follow some users to get started. We can recommend you more users based on who you find interesting." +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:145 +msgid "Followed users only" +msgstr "" + +#: src/view/screens/ProfileFollowers.tsx:25 +msgid "Followers" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:596 +msgid "following" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:494 +#: src/view/screens/ProfileFollows.tsx:25 +msgid "Following" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:543 +msgid "Follows you" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:107 +msgid "For security reasons, we'll need to send a confirmation code to your email address." +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:207 +msgid "For security reasons, you won't be able to view this again. If you lose this password, you'll need to generate a new one." +msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:233 +msgid "Forgot" +msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:230 +msgid "Forgot password" +msgstr "" + +#: src/view/com/auth/login/Login.tsx:111 +#: src/view/com/auth/login/Login.tsx:127 +msgid "Forgot Password" +msgstr "" + +#: src/view/com/composer/photos/SelectPhotoBtn.tsx:43 +msgid "Gallery" +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:175 +msgid "Get Started" +msgstr "" + +#: src/view/com/auth/LoggedOut.tsx:53 +#: src/view/com/auth/LoggedOut.tsx:54 +#: src/view/com/util/moderation/ScreenHider.tsx:105 +#: src/view/shell/desktop/LeftNav.tsx:106 +msgid "Go back" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:111 +#: src/view/screens/ProfileFeed.tsx:116 +#: src/view/screens/ProfileList.tsx:761 +#: src/view/screens/ProfileList.tsx:766 +msgid "Go Back" +msgstr "" + +#: src/view/com/auth/login/ForgotPasswordForm.tsx:181 +#: src/view/com/auth/login/LoginForm.tsx:280 +#: src/view/com/auth/login/SetNewPasswordForm.tsx:163 +msgid "Go to next" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:265 +msgid "Handle" +msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:93 +#: src/view/shell/Drawer.tsx:420 +msgid "Help" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:148 +msgid "Here is your app password." +msgstr "" + +#: src/view/com/notifications/FeedItem.tsx:316 +msgid "Hide" +msgstr "" + +#: src/view/com/notifications/FeedItem.tsx:308 +msgid "Hide user list" +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:101 +msgid "Hmm, some kind of issue occured when contacting the feed server. Please let the feed owner know about this issue." +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:89 +msgid "Hmm, the feed server appears to be misconfigured. Please let the feed owner know about this issue." +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:95 +msgid "Hmm, the feed server appears to be offline. Please let the feed owner know about this issue." +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:92 +msgid "Hmm, the feed server gave a bad response. Please let the feed owner know about this issue." +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:86 +msgid "Hmmm, we're having trouble finding this feed. It may have been deleted." +msgstr "" + +#: src/view/shell/bottom-bar/BottomBar.tsx:124 +#: src/view/shell/desktop/LeftNav.tsx:305 +#: src/view/shell/Drawer.tsx:274 +#: src/view/shell/Drawer.tsx:275 +msgid "Home" +msgstr "" + +#: src/view/com/pager/FeedsTabBarMobile.tsx:99 +#: src/view/screens/PreferencesHomeFeed.tsx:95 +#: src/view/screens/Settings.tsx:481 +msgid "Home Feed Preferences" +msgstr "" + +#: src/view/com/auth/login/ForgotPasswordForm.tsx:114 +msgid "Hosting provider" +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:76 +#: src/view/com/auth/create/Step1.tsx:81 +msgid "Hosting provider address" +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:200 +msgid "I have a code" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:281 +msgid "I have my own domain" +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:127 +msgid "If none are selected, suitable for all ages." +msgstr "" + +#: src/view/com/modals/AltImage.tsx:96 +msgid "Image alt text" +msgstr "" + +#: src/view/com/util/UserAvatar.tsx:304 +#: src/view/com/util/UserBanner.tsx:116 +msgid "Image options" +msgstr "" + +#: src/view/com/search/Suggestions.tsx:104 +#: src/view/com/search/Suggestions.tsx:115 +#~ msgid "In Your Network" +#~ msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:113 +msgid "Invalid username or password" +msgstr "" + +#: src/view/screens/Settings.tsx:383 +msgid "Invite" +msgstr "" + +#: src/view/com/modals/InviteCodes.tsx:91 +#: src/view/screens/Settings.tsx:371 +msgid "Invite a Friend" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:57 +msgid "Invite code" +msgstr "" + +#: src/view/com/auth/create/state.ts:136 +msgid "Invite code not accepted. Check that you input it correctly and try again." +msgstr "" + +#: src/view/shell/Drawer.tsx:502 +msgid "Invite codes: {invitesAvailable} available" +msgstr "" + +#: src/view/com/modals/Waitlist.tsx:67 +msgid "Join the waitlist" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:68 +#: src/view/com/auth/create/Step2.tsx:72 +msgid "Join the waitlist." +msgstr "" + +#: src/view/com/modals/Waitlist.tsx:124 +msgid "Join Waitlist" +msgstr "" + +#: src/view/com/composer/select-language/SelectLangBtn.tsx:104 +msgid "Language selection" +msgstr "" + +#: src/view/screens/LanguageSettings.tsx:86 +msgid "Language Settings" +msgstr "" + +#: src/view/screens/Settings.tsx:541 +msgid "Languages" +msgstr "" + +#: src/view/com/util/moderation/PostAlerts.tsx:47 +#: src/view/com/util/moderation/ProfileHeaderAlerts.tsx:55 +#: src/view/com/util/moderation/ScreenHider.tsx:88 +msgid "Learn More" +msgstr "" + +#: src/view/com/util/moderation/ContentHider.tsx:75 +#: src/view/com/util/moderation/PostAlerts.tsx:40 +#: src/view/com/util/moderation/PostHider.tsx:76 +#: src/view/com/util/moderation/ProfileHeaderAlerts.tsx:47 +#: src/view/com/util/moderation/ScreenHider.tsx:85 +msgid "Learn more about this warning" +msgstr "" + +#: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:82 +msgid "Leave them all unchecked to see any language." +msgstr "" + +#: src/view/com/modals/LinkWarning.tsx:49 +msgid "Leaving Bluesky" +msgstr "" + +#: src/view/com/auth/login/Login.tsx:112 +#: src/view/com/auth/login/Login.tsx:128 +msgid "Let's get your password reset!" +msgstr "" + +#: src/view/com/util/UserAvatar.tsx:241 +#: src/view/com/util/UserBanner.tsx:60 +msgid "Library" +msgstr "" + +#: src/view/screens/Settings.tsx:405 +#~ msgid "Light" +#~ msgstr "" + +#: src/view/screens/ProfileFeed.tsx:627 +msgid "Like this feed" +msgstr "" + +#: src/view/screens/PostLikedBy.tsx:27 +#: src/view/screens/ProfileFeedLikedBy.tsx:27 +msgid "Liked by" +msgstr "" + +#: src/view/com/modals/CreateOrEditList.tsx:186 +msgid "List Avatar" +msgstr "" + +#: src/view/com/modals/CreateOrEditList.tsx:199 +msgid "List Name" +msgstr "" + +#: src/view/shell/desktop/LeftNav.tsx:381 +#: src/view/shell/Drawer.tsx:338 +#: src/view/shell/Drawer.tsx:339 +msgid "Lists" +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:246 +#: src/view/com/post-thread/PostThread.tsx:254 +msgid "Load more posts" +msgstr "" + +#: src/view/screens/Notifications.tsx:129 +msgid "Load new notifications" +msgstr "" + +#: src/view/com/feeds/FeedPage.tsx:177 +msgid "Load new posts" +msgstr "" + +#: src/view/com/composer/text-input/mobile/Autocomplete.tsx:95 +msgid "Loading..." +msgstr "" + +#: src/view/com/modals/ServerInput.tsx:50 +msgid "Local dev server" +msgstr "" + +#: src/view/com/auth/login/ChooseAccountForm.tsx:133 +msgid "Login to account that is not listed" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:479 +msgid "Looks like this feed is only available to users with a Bluesky account. Please sign up or sign in to view this feed!" +msgstr "" + +#: src/view/com/modals/LinkWarning.tsx:63 +msgid "Make sure this is where you intend to go!" +msgstr "" + +#: src/view/screens/Search/Search.tsx:531 +msgid "Menu" +msgstr "" + +#: src/view/screens/Moderation.tsx:51 +#: src/view/screens/Settings.tsx:563 +#: src/view/shell/desktop/LeftNav.tsx:399 +#: src/view/shell/Drawer.tsx:345 +#: src/view/shell/Drawer.tsx:346 +msgid "Moderation" +msgstr "" + +#: src/view/screens/Moderation.tsx:81 +msgid "Moderation lists" +msgstr "" + +#: src/view/shell/desktop/Feeds.tsx:53 +msgid "More feeds" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:520 +#: src/view/screens/ProfileFeed.tsx:369 +#: src/view/screens/ProfileList.tsx:506 +msgid "More options" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:158 +#~ msgid "More post options" +#~ msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:344 +msgid "Mute Account" +msgstr "" + +#: src/view/screens/ProfileList.tsx:434 +msgid "Mute accounts" +msgstr "" + +#: src/view/screens/ProfileList.tsx:267 +msgid "Mute these accounts?" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:144 +msgid "Mute thread" +msgstr "" + +#: src/view/screens/Moderation.tsx:95 +msgid "Muted accounts" +msgstr "" + +#: src/view/screens/ModerationMutedAccounts.tsx:106 +msgid "Muted Accounts" +msgstr "" + +#: src/view/screens/ModerationMutedAccounts.tsx:114 +msgid "Muted accounts have their posts removed from your feed and from your notifications. Mutes are completely private." +msgstr "" + +#: src/view/screens/ProfileList.tsx:269 +msgid "Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them." +msgstr "" + +#: src/view/com/modals/BirthDateSettings.tsx:56 +msgid "My Birthday" +msgstr "" + +#: src/view/screens/Feeds.tsx:363 +msgid "My Feeds" +msgstr "" + +#: src/view/shell/desktop/LeftNav.tsx:67 +msgid "My Profile" +msgstr "" + +#: src/view/screens/Settings.tsx:520 +msgid "My Saved Feeds" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:177 +#: src/view/com/modals/CreateOrEditList.tsx:211 +msgid "Name" +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:72 +msgid "Never lose access to your followers and data." +msgstr "" + +#: src/view/screens/Lists.tsx:76 +msgid "New" +msgstr "" + +#: src/view/com/feeds/FeedPage.tsx:188 +#: src/view/screens/Feeds.tsx:510 +#: src/view/screens/Profile.tsx:382 +#: src/view/screens/ProfileFeed.tsx:449 +#: src/view/screens/ProfileList.tsx:199 +#: src/view/screens/ProfileList.tsx:231 +#: src/view/shell/desktop/LeftNav.tsx:254 +msgid "New post" +msgstr "" + +#: src/view/shell/desktop/LeftNav.tsx:264 +msgid "New Post" +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:158 +#: src/view/com/auth/login/ForgotPasswordForm.tsx:174 +#: src/view/com/auth/login/ForgotPasswordForm.tsx:184 +#: src/view/com/auth/login/LoginForm.tsx:283 +#: src/view/com/auth/login/SetNewPasswordForm.tsx:156 +#: src/view/com/auth/login/SetNewPasswordForm.tsx:166 +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:79 +msgid "Next" +msgstr "" + +#: src/view/com/lightbox/Lightbox.web.tsx:142 +msgid "Next image" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:191 +#: src/view/screens/PreferencesHomeFeed.tsx:226 +#: src/view/screens/PreferencesHomeFeed.tsx:263 +msgid "No" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:620 +#: src/view/screens/ProfileList.tsx:632 +msgid "No description" +msgstr "" + +#: src/view/com/composer/text-input/mobile/Autocomplete.tsx:97 +msgid "No result" +msgstr "" + +#: src/view/screens/Feeds.tsx:452 +msgid "No results found for \"{query}\"" +msgstr "" + +#: src/view/com/modals/ListAddUser.tsx:142 +#: src/view/shell/desktop/Search.tsx:112 +#~ msgid "No results found for {0}" +#~ msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:127 +#: src/view/screens/Search/Search.tsx:269 +#: src/view/screens/Search/Search.tsx:326 +#: src/view/screens/Search/Search.tsx:609 +#: src/view/shell/desktop/Search.tsx:209 +msgid "No results found for {query}" +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:136 +#~ msgid "Not Applicable" +#~ msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:135 +msgid "Not Applicable." +msgstr "" + +#: src/view/screens/Notifications.tsx:96 +#: src/view/screens/Notifications.tsx:120 +#: src/view/shell/bottom-bar/BottomBar.tsx:195 +#: src/view/shell/desktop/LeftNav.tsx:363 +#: src/view/shell/Drawer.tsx:298 +#: src/view/shell/Drawer.tsx:299 +msgid "Notifications" +msgstr "" + +#: src/view/com/util/ErrorBoundary.tsx:34 +msgid "Oh no!" +msgstr "" + +#: src/view/com/auth/login/PasswordUpdatedForm.tsx:41 +msgid "Okay" +msgstr "" + +#: src/view/com/composer/Composer.tsx:334 +msgid "One or more images is missing alt text." +msgstr "" + +#: src/view/com/pager/FeedsTabBarMobile.tsx:79 +msgid "Open navigation" +msgstr "" + +#: src/view/screens/Settings.tsx:533 +msgid "Opens configurable language settings" +msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:146 +#: src/view/shell/Drawer.tsx:503 +msgid "Opens list of invite codes" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:279 +msgid "Opens modal for using custom domain" +msgstr "" + +#: src/view/screens/Settings.tsx:558 +msgid "Opens moderation settings" +msgstr "" + +#: src/view/screens/Settings.tsx:514 +msgid "Opens screen with all saved feeds" +msgstr "" + +#: src/view/screens/Settings.tsx:581 +msgid "Opens the app password settings page" +msgstr "" + +#: src/view/screens/Settings.tsx:473 +msgid "Opens the home feed preferences" +msgstr "" + +#: src/view/screens/Settings.tsx:664 +msgid "Opens the storybook page" +msgstr "" + +#: src/view/screens/Settings.tsx:644 +msgid "Opens the system log page" +msgstr "" + +#: src/view/screens/Settings.tsx:494 +msgid "Opens the threads preferences" +msgstr "" + +#: src/view/com/auth/login/ChooseAccountForm.tsx:138 +msgid "Other account" +msgstr "" + +#: src/view/com/modals/ServerInput.tsx:88 +msgid "Other service" +msgstr "" + +#: src/view/com/composer/select-language/SelectLangBtn.tsx:91 +msgid "Other..." +msgstr "" + +#: src/view/screens/NotFound.tsx:42 +#: src/view/screens/NotFound.tsx:45 +msgid "Page not found" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:101 +#: src/view/com/auth/create/Step2.tsx:111 +#: src/view/com/auth/login/LoginForm.tsx:218 +#: src/view/com/auth/login/SetNewPasswordForm.tsx:130 +#: src/view/com/modals/DeleteAccount.tsx:191 +msgid "Password" +msgstr "" + +#: src/view/com/auth/login/Login.tsx:141 +msgid "Password updated" +msgstr "" + +#: src/view/com/auth/login/PasswordUpdatedForm.tsx:28 +msgid "Password updated!" +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:121 +msgid "Pictures meant for adults." +msgstr "" + +#: src/view/screens/SavedFeeds.tsx:89 +msgid "Pinned Feeds" +msgstr "" + +#: src/view/com/auth/create/state.ts:116 +msgid "Please choose your handle." +msgstr "" + +#: src/view/com/auth/create/state.ts:109 +msgid "Please choose your password." +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:67 +msgid "Please confirm your email before changing it. This is a temporary requirement while email-updating tools are added, and it will soon be removed." +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:140 +msgid "Please enter a unique name for this App Password or use our randomly generated one." +msgstr "" + +#: src/view/com/auth/create/state.ts:95 +msgid "Please enter your email." +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:180 +msgid "Please enter your password as well:" +msgstr "" + +#: src/view/com/composer/Composer.tsx:317 +#: src/view/com/post-thread/PostThread.tsx:212 +#: src/view/screens/PostThread.tsx:77 +msgid "Post" +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:366 +msgid "Post hidden" +msgstr "" + +#: src/view/com/composer/select-language/SelectLangBtn.tsx:87 +msgid "Post language" +msgstr "" + +#: src/view/com/modals/lang-settings/PostLanguagesSettings.tsx:75 +msgid "Post Languages" +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:418 +msgid "Post not found" +msgstr "" + +#: src/view/com/modals/LinkWarning.tsx:44 +msgid "Potentially Misleading Link" +msgstr "" + +#: src/view/com/lightbox/Lightbox.web.tsx:128 +msgid "Previous image" +msgstr "" + +#: src/view/screens/LanguageSettings.tsx:183 +msgid "Primary Language" +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:91 +msgid "Prioritize Your Follows" +msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:75 +msgid "Privacy" +msgstr "" + +#: src/view/screens/PrivacyPolicy.tsx:29 +msgid "Privacy Policy" +msgstr "" + +#: src/view/com/auth/login/ForgotPasswordForm.tsx:190 +msgid "Processing..." +msgstr "" + +#: src/view/shell/bottom-bar/BottomBar.tsx:237 +#: src/view/shell/Drawer.tsx:72 +#: src/view/shell/Drawer.tsx:366 +#: src/view/shell/Drawer.tsx:367 +msgid "Profile" +msgstr "" + +#: src/view/screens/Settings.tsx:789 +msgid "Protect your account by verifying your email." +msgstr "" + +#: src/view/screens/Lists.tsx:61 +msgid "Public, shareable lists which can drive feeds." +msgstr "" + +#: src/view/com/modals/Repost.tsx:52 +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:58 +msgid "Quote post" +msgstr "" + +#: src/view/com/modals/Repost.tsx:56 +msgid "Quote Post" +msgstr "" + +#: src/view/com/modals/EditImage.tsx:236 +msgid "Ratios" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:73 +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:50 +#~ msgid "Recommended" +#~ msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:116 +msgid "Recommended Feeds" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:180 +msgid "Recommended Users" +msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:264 +#: src/view/com/modals/SelfLabel.tsx:83 +#: src/view/com/modals/UserAddRemoveLists.tsx:187 +#: src/view/com/util/UserAvatar.tsx:278 +#: src/view/com/util/UserBanner.tsx:89 +msgid "Remove" +msgstr "" + +#: src/view/com/feeds/FeedSourceCard.tsx:108 +msgid "Remove {0} from my feeds?" +msgstr "" + +#: src/view/com/util/AccountDropdownBtn.tsx:22 +msgid "Remove account" +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:118 +msgid "Remove feed" +msgstr "" + +#: src/view/com/feeds/FeedSourceCard.tsx:107 +#: src/view/screens/ProfileFeed.tsx:279 +msgid "Remove from my feeds" +msgstr "" + +#: src/view/com/composer/photos/Gallery.tsx:167 +msgid "Remove image" +msgstr "" + +#: src/view/com/composer/ExternalEmbed.tsx:70 +msgid "Remove image preview" +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:119 +msgid "Remove this feed from your saved feeds?" +msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:199 +#: src/view/com/modals/UserAddRemoveLists.tsx:130 +msgid "Removed from list" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:135 +msgid "Reply Filters" +msgstr "" + +#: src/view/com/modals/report/Modal.tsx:166 +msgid "Report {collectionName}" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:378 +msgid "Report Account" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:299 +msgid "Report feed" +msgstr "" + +#: src/view/screens/ProfileList.tsx:416 +msgid "Report List" +msgstr "" + +#: src/view/com/modals/report/SendReportButton.tsx:37 +#: src/view/com/util/forms/PostDropdownBtn.tsx:162 +msgid "Report post" +msgstr "" + +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:48 +msgid "Repost" +msgstr "" + +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:94 +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:105 +msgid "Repost or quote post" +msgstr "" + +#: src/view/screens/PostRepostedBy.tsx:27 +msgid "Reposted by" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:181 +#: src/view/com/modals/ChangeEmail.tsx:183 +msgid "Request Change" +msgstr "" + +#: src/view/screens/Settings.tsx:382 +#~ msgid "Require alt text before posting" +#~ msgstr "" + +#: src/view/com/auth/create/Step2.tsx:53 +msgid "Required for this provider" +msgstr "" + +#: src/view/com/auth/login/SetNewPasswordForm.tsx:108 +msgid "Reset code" +msgstr "" + +#: src/view/screens/Settings.tsx:686 +msgid "Reset onboarding state" +msgstr "" + +#: src/view/com/auth/login/ForgotPasswordForm.tsx:98 +msgid "Reset password" +msgstr "" + +#: src/view/screens/Settings.tsx:676 +msgid "Reset preferences state" +msgstr "" + +#: src/view/screens/Settings.tsx:684 +msgid "Resets the onboarding state" +msgstr "" + +#: src/view/screens/Settings.tsx:674 +msgid "Resets the preferences state" +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:167 +#: src/view/com/auth/create/CreateAccount.tsx:171 +#: src/view/com/auth/login/LoginForm.tsx:260 +#: src/view/com/auth/login/LoginForm.tsx:263 +#: src/view/com/util/error/ErrorMessage.tsx:55 +#: src/view/com/util/error/ErrorScreen.tsx:65 +msgid "Retry" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:169 +#~ msgid "Retry change handle" +#~ msgstr "" + +#: src/view/com/modals/AltImage.tsx:114 +#: src/view/com/modals/BirthDateSettings.tsx:93 +#: src/view/com/modals/BirthDateSettings.tsx:96 +#: src/view/com/modals/ChangeHandle.tsx:173 +#: src/view/com/modals/CreateOrEditList.tsx:249 +#: src/view/com/modals/CreateOrEditList.tsx:257 +#: src/view/com/modals/EditProfile.tsx:223 +msgid "Save" +msgstr "" + +#: src/view/com/modals/AltImage.tsx:105 +msgid "Save alt text" +msgstr "" + +#: src/view/com/modals/UserAddRemoveLists.tsx:212 +#~ msgid "Save changes" +#~ msgstr "" + +#: src/view/com/modals/EditProfile.tsx:231 +msgid "Save Changes" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:170 +msgid "Save handle change" +msgstr "" + +#: src/view/com/modals/crop-image/CropImage.web.tsx:144 +msgid "Save image crop" +msgstr "" + +#: src/view/screens/SavedFeeds.tsx:122 +msgid "Saved Feeds" +msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:75 +#: src/view/com/util/forms/SearchInput.tsx:64 +#: src/view/screens/Search/Search.tsx:409 +#: src/view/screens/Search/Search.tsx:561 +#: src/view/shell/bottom-bar/BottomBar.tsx:146 +#: src/view/shell/desktop/LeftNav.tsx:323 +#: src/view/shell/desktop/Search.tsx:160 +#: src/view/shell/desktop/Search.tsx:169 +#: src/view/shell/Drawer.tsx:252 +#: src/view/shell/Drawer.tsx:253 +msgid "Search" +msgstr "" + +#: src/view/screens/Search/Search.tsx:418 +msgid "Search for posts and users." +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:110 +msgid "Security Step Required" +msgstr "" + +#: src/view/com/auth/SplashScreen.tsx:29 +msgid "See what's next" +msgstr "" + +#: src/view/com/modals/ServerInput.tsx:75 +msgid "Select Bluesky Social" +msgstr "" + +#: src/view/com/auth/login/Login.tsx:101 +msgid "Select from an existing account" +msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:145 +msgid "Select service" +msgstr "" + +#: src/view/screens/LanguageSettings.tsx:276 +msgid "Select which languages you want your subscribed feeds to include. If none are selected, all languages will be shown." +msgstr "" + +#: src/view/screens/LanguageSettings.tsx:95 +msgid "Select your app language for the default text to display in the app" +msgstr "" + +#: src/view/screens/LanguageSettings.tsx:186 +msgid "Select your preferred language for translations in your feed." +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:188 +msgid "Send Confirmation Email" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:127 +msgid "Send email" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:138 +msgid "Send Email" +msgstr "" + +#: src/view/shell/Drawer.tsx:394 +#: src/view/shell/Drawer.tsx:415 +msgid "Send feedback" +msgstr "" + +#: src/view/com/modals/report/SendReportButton.tsx:45 +msgid "Send Report" +msgstr "" + +#: src/view/com/auth/login/SetNewPasswordForm.tsx:78 +msgid "Set new password" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:216 +msgid "Set this setting to \"No\" to hide all quote posts from your feed. Reposts will still be visible." +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:113 +msgid "Set this setting to \"No\" to hide all replies from your feed." +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:182 +msgid "Set this setting to \"No\" to hide all reposts from your feed." +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:116 +msgid "Set this setting to \"Yes\" to show replies in a threaded view. This is an experimental feature." +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:252 +msgid "Set this setting to \"Yes\" to show samples of your saved feeds in your following feed. This is an experimental feature." +msgstr "" + +#: src/view/screens/Settings.tsx:277 +#: src/view/shell/desktop/LeftNav.tsx:435 +#: src/view/shell/Drawer.tsx:379 +#: src/view/shell/Drawer.tsx:380 +msgid "Settings" +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:125 +msgid "Sexual activity or erotic nudity." +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:312 +#: src/view/com/util/forms/PostDropdownBtn.tsx:126 +#: src/view/screens/ProfileList.tsx:375 +msgid "Share" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:311 +msgid "Share feed" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:276 +#~ msgid "Share link" +#~ msgstr "" + +#: src/view/screens/Settings.tsx:316 +msgid "Show" +msgstr "" + +#: src/view/com/util/moderation/ScreenHider.tsx:114 +msgid "Show anyway" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:249 +msgid "Show Posts from My Feeds" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:213 +msgid "Show Quote Posts" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:110 +msgid "Show Replies" +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:94 +msgid "Show replies by people you follow before all other replies." +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:179 +msgid "Show Reposts" +msgstr "" + +#: src/view/com/notifications/FeedItem.tsx:337 +msgid "Show users" +msgstr "" + +#: src/view/com/auth/login/Login.tsx:82 +#: src/view/com/auth/SplashScreen.tsx:49 +#: src/view/shell/NavSignupCard.tsx:52 +#: src/view/shell/NavSignupCard.tsx:53 +msgid "Sign in" +msgstr "" + +#: src/view/com/auth/SplashScreen.tsx:52 +#: src/view/com/auth/SplashScreen.web.tsx:84 +msgid "Sign In" +msgstr "" + +#: src/view/com/auth/login/ChooseAccountForm.tsx:44 +msgid "Sign in as {0}" +msgstr "" + +#: src/view/com/auth/login/ChooseAccountForm.tsx:118 +#: src/view/com/auth/login/Login.tsx:100 +msgid "Sign in as..." +msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:132 +msgid "Sign into" +msgstr "" + +#: src/view/com/modals/SwitchAccount.tsx:60 +#: src/view/com/modals/SwitchAccount.tsx:63 +msgid "Sign out" +msgstr "" + +#: src/view/shell/NavSignupCard.tsx:43 +#: src/view/shell/NavSignupCard.tsx:44 +#: src/view/shell/NavSignupCard.tsx:46 +msgid "Sign up" +msgstr "" + +#: src/view/shell/NavSignupCard.tsx:36 +msgid "Sign up or sign in to join the conversation" +msgstr "" + +#: src/view/screens/Settings.tsx:327 +msgid "Signed in as" +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:33 +msgid "Skip" +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:69 +msgid "Sort Replies" +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:72 +msgid "Sort replies to the same post by:" +msgstr "" + +#: src/view/com/modals/crop-image/CropImage.web.tsx:122 +msgid "Square" +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:90 +#: src/view/com/modals/ServerInput.tsx:62 +msgid "Staging" +msgstr "" + +#: src/view/screens/Settings.tsx:730 +msgid "Status page" +msgstr "" + +#: src/view/screens/Settings.tsx:666 +msgid "Storybook" +msgstr "" + +#: src/view/screens/ProfileList.tsx:497 +msgid "Subscribe" +msgstr "" + +#: src/view/screens/ProfileList.tsx:493 +msgid "Subscribe to this list" +msgstr "" + +#: src/view/screens/Search/Search.tsx:382 +msgid "Suggested Follows" +msgstr "" + +#: src/view/screens/Support.tsx:30 +#: src/view/screens/Support.tsx:33 +msgid "Support" +msgstr "" + +#: src/view/com/modals/SwitchAccount.tsx:111 +msgid "Switch Account" +msgstr "" + +#: src/view/screens/Settings.tsx:398 +#~ msgid "System" +#~ msgstr "" + +#: src/view/screens/Settings.tsx:646 +msgid "System log" +msgstr "" + +#: src/view/com/modals/crop-image/CropImage.web.tsx:112 +msgid "Tall" +msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:84 +msgid "Terms" +msgstr "" + +#: src/view/screens/TermsOfService.tsx:29 +msgid "Terms of Service" +msgstr "" + +#: src/view/com/modals/report/InputIssueDetails.tsx:50 +msgid "Text input field" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:280 +msgid "The account will be able to interact with you after unblocking." +msgstr "" + +#: src/view/screens/CommunityGuidelines.tsx:36 +msgid "The Community Guidelines have been moved to <0/>" +msgstr "" + +#: src/view/screens/CopyrightPolicy.tsx:33 +msgid "The Copyright Policy has been moved to <0/>" +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:421 +msgid "The post may have been deleted." +msgstr "" + +#: src/view/screens/PrivacyPolicy.tsx:33 +msgid "The Privacy Policy has been moved to <0/>" +msgstr "" + +#: src/view/screens/Support.tsx:36 +msgid "The support form has been moved. If you need help, please<0/> or visit {HELP_DESK_URL} to get in touch with us." +msgstr "" + +#: src/view/screens/TermsOfService.tsx:33 +msgid "The Terms of Service have been moved to" +msgstr "" + +#: src/view/com/util/ErrorBoundary.tsx:35 +msgid "There was an unexpected issue in the application. Please let us know if this happened to you!" +msgstr "" + +#: src/view/com/util/moderation/ScreenHider.tsx:72 +msgid "This {screenDescription} has been flagged:" +msgstr "" + +#: src/view/com/modals/BirthDateSettings.tsx:61 +msgid "This information is not shared with other users." +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:105 +msgid "This is important in case you ever need to change your email or reset your password." +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:55 +msgid "This is the service that keeps you online." +msgstr "" + +#: src/view/com/modals/LinkWarning.tsx:56 +msgid "This link is taking you to the following website:" +msgstr "" + +#: src/view/com/post-thread/PostThreadItem.tsx:114 +msgid "This post has been deleted." +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:137 +msgid "This warning is only available for posts with media attached." +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:53 +#: src/view/screens/Settings.tsx:503 +msgid "Thread Preferences" +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:113 +msgid "Threaded Mode" +msgstr "" + +#: src/view/com/util/forms/DropdownButton.tsx:230 +msgid "Toggle dropdown" +msgstr "" + +#: src/view/com/modals/EditImage.tsx:271 +msgid "Transformations" +msgstr "" + +#: src/view/com/post-thread/PostThreadItem.tsx:646 +#: src/view/com/post-thread/PostThreadItem.tsx:648 +#: src/view/com/util/forms/PostDropdownBtn.tsx:98 +msgid "Translate" +msgstr "" + +#: src/view/com/util/error/ErrorScreen.tsx:73 +msgid "Try again" +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:64 +#: src/view/com/auth/login/Login.tsx:60 +#: src/view/com/auth/login/LoginForm.tsx:117 +msgid "Unable to contact your service. Please check your Internet connection." +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:438 +#: src/view/com/profile/ProfileHeader.tsx:441 +msgid "Unblock" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:278 +#: src/view/com/profile/ProfileHeader.tsx:362 +msgid "Unblock Account" +msgstr "" + +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:48 +msgid "Undo repost" +msgstr "" + +#: src/view/com/auth/create/state.ts:210 +msgid "Unfortunately, you do not meet the requirements to create an account." +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:343 +msgid "Unmute Account" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:144 +msgid "Unmute thread" +msgstr "" + +#: src/view/com/modals/UserAddRemoveLists.tsx:52 +msgid "Update {displayName} in Lists" +msgstr "" + +#: src/lib/hooks/useOTAUpdate.ts:15 +msgid "Update Available" +msgstr "" + +#: src/view/com/auth/login/SetNewPasswordForm.tsx:172 +msgid "Updating..." +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:453 +msgid "Upload a text file to:" +msgstr "" + +#: src/view/screens/AppPasswords.tsx:194 +msgid "Use app passwords to login to other Bluesky clients without giving full access to your account or password." +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:513 +msgid "Use default provider" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:150 +msgid "Use this to sign into the other app along with your handle." +msgstr "" + +#: src/view/com/modals/InviteCodes.tsx:196 +msgid "Used by:" +msgstr "" + +#: src/view/com/auth/create/Step3.tsx:38 +msgid "User handle" +msgstr "" + +#: src/view/screens/Lists.tsx:58 +msgid "User Lists" +msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:172 +#: src/view/com/auth/login/LoginForm.tsx:189 +msgid "Username or email address" +msgstr "" + +#: src/view/screens/ProfileList.tsx:659 +msgid "Users" +msgstr "" + +#: src/view/screens/Settings.tsx:750 +msgid "Verify email" +msgstr "" + +#: src/view/screens/Settings.tsx:775 +msgid "Verify my email" +msgstr "" + +#: src/view/screens/Settings.tsx:784 +msgid "Verify My Email" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:205 +#: src/view/com/modals/ChangeEmail.tsx:207 +msgid "Verify New Email" +msgstr "" + +#: src/view/screens/Log.tsx:52 +msgid "View debug entry" +msgstr "" + +#: src/view/com/profile/ProfileSubpageHeader.tsx:128 +msgid "View the avatar" +msgstr "" + +#: src/view/com/modals/LinkWarning.tsx:73 +msgid "Visit Site" +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:125 +msgid "We're so excited to have you join us!" +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:98 +msgid "We're sorry, but this content is not viewable without a Bluesky account." +msgstr "" + +#: src/view/screens/Search/Search.tsx:236 +msgid "We're sorry, but your search could not be completed. Please try again in a few minutes." +msgstr "" + +#: src/view/screens/NotFound.tsx:48 +msgid "We're sorry! We can't find the page you were looking for." +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:46 +msgid "Welcome to <0>Bluesky</0>" +msgstr "" + +#: src/view/com/modals/report/Modal.tsx:169 +msgid "What is the issue with this {collectionName}?" +msgstr "" + +#: src/view/com/modals/lang-settings/PostLanguagesSettings.tsx:78 +msgid "Which languages are used in this post?" +msgstr "" + +#: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:77 +msgid "Which languages would you like to see in your algorithmic feeds?" +msgstr "" + +#: src/view/com/modals/crop-image/CropImage.web.tsx:102 +msgid "Wide" +msgstr "" + +#: src/view/com/composer/Composer.tsx:389 +msgid "Write post" +msgstr "" + +#: src/view/com/composer/Prompt.tsx:33 +msgid "Write your reply" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:192 +#: src/view/screens/PreferencesHomeFeed.tsx:227 +#: src/view/screens/PreferencesHomeFeed.tsx:262 +msgid "Yes" +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:106 +msgid "You can change hosting providers at any time." +msgstr "" + +#: src/view/com/auth/login/Login.tsx:142 +#: src/view/com/auth/login/PasswordUpdatedForm.tsx:31 +msgid "You can now sign in with your new password." +msgstr "" + +#: src/view/com/modals/InviteCodes.tsx:64 +msgid "You don't have any invite codes yet! We'll send you some when you've been on Bluesky for a little longer." +msgstr "" + +#: src/view/screens/SavedFeeds.tsx:102 +msgid "You don't have any pinned feeds." +msgstr "" + +#: src/view/screens/Feeds.tsx:383 +msgid "You don't have any saved feeds!" +msgstr "" + +#: src/view/screens/SavedFeeds.tsx:135 +msgid "You don't have any saved feeds." +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:369 +msgid "You have blocked the author or you have been blocked by the author." +msgstr "" + +#: src/view/com/feeds/ProfileFeedgens.tsx:150 +msgid "You have no feeds." +msgstr "" + +#: src/view/com/lists/MyLists.tsx:88 +#: src/view/com/lists/ProfileLists.tsx:154 +msgid "You have no lists." +msgstr "" + +#: src/view/screens/ModerationBlockedAccounts.tsx:131 +msgid "You have not blocked any accounts yet. To block an account, go to their profile and selected \"Block account\" from the menu on their account." +msgstr "" + +#: src/view/screens/AppPasswords.tsx:86 +msgid "You have not created any app passwords yet. You can create one by pressing the button below." +msgstr "" + +#: src/view/screens/ModerationMutedAccounts.tsx:130 +msgid "You have not muted any accounts yet. To mute an account, go to their profile and selected \"Mute account\" from the menu on their account." +msgstr "" + +#: src/view/com/auth/login/SetNewPasswordForm.tsx:81 +msgid "You will receive an email with a \"reset code.\" Enter that code here, then enter your new password." +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:43 +msgid "Your account" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:122 +msgid "Your birth date" +msgstr "" + +#: src/view/com/auth/create/state.ts:102 +msgid "Your email appears to be invalid." +msgstr "" + +#: src/view/com/modals/Waitlist.tsx:107 +msgid "Your email has been saved! We'll be in touch soon." +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:125 +msgid "Your email has been updated but not verified. As a next step, please verify your new email." +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:100 +msgid "Your email has not yet been verified. This is an important security step which we recommend." +msgstr "" + +#: src/view/com/auth/create/Step3.tsx:42 +#: src/view/com/modals/ChangeHandle.tsx:270 +msgid "Your full handle will be" +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:53 +msgid "Your hosting provider" +msgstr "" + +#: src/view/screens/Settings.tsx:402 +#: src/view/shell/desktop/RightNav.tsx:127 +#: src/view/shell/Drawer.tsx:517 +msgid "Your invite codes are hidden when logged in using an App Password" +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:59 +msgid "Your posts, likes, and blocks are public. Mutes are private." +msgstr "" + +#: src/view/com/modals/SwitchAccount.tsx:78 +msgid "Your profile" +msgstr "" + +#: src/view/com/auth/create/Step3.tsx:28 +msgid "Your user handle" +msgstr "" diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po new file mode 100644 index 000000000..5e0aec468 --- /dev/null +++ b/src/locale/locales/en/messages.po @@ -0,0 +1,2297 @@ +msgid "" +msgstr "" +"POT-Creation-Date: 2023-11-05 16:01-0800\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: @lingui/cli\n" +"Language: en\n" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Plural-Forms: \n" + +#: src/view/screens/Profile.tsx:214 +#~ msgid "- end of feed -" +#~ msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:138 +#~ msgid ". This warning is only available for posts with media attached." +#~ msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:158 +msgid "{0, plural, one {# invite code available} other {# invite codes available}}" +msgstr "" + +#: src/view/com/modals/Repost.tsx:44 +msgid "{0}" +msgstr "" + +#: src/view/com/modals/CreateOrEditList.tsx:176 +msgid "{0} {purposeLabel} List" +msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:141 +msgid "{invitesAvailable, plural, one {Invite codes: # available} other {Invite codes: # available}}" +msgstr "" + +#: src/view/screens/Settings.tsx:407 +#: src/view/shell/Drawer.tsx:521 +msgid "{invitesAvailable} invite code available" +msgstr "" + +#: src/view/screens/Settings.tsx:409 +#: src/view/shell/Drawer.tsx:523 +msgid "{invitesAvailable} invite codes available" +msgstr "" + +#: src/view/screens/Search/Search.tsx:86 +msgid "{message}" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:30 +msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:37 +msgid "<0>Follow some</0><1>Recommended</1><2>Users</2>" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:132 +#~ msgid "<0>Here is your app password.</0> Use this to sign into the other app along with your handle." +#~ msgstr "" + +#: src/lib/hooks/useOTAUpdate.ts:16 +msgid "A new version of the app is available. Please update to continue using the app." +msgstr "" + +#: src/view/com/modals/EditImage.tsx:299 +#: src/view/screens/Settings.tsx:417 +msgid "Accessibility" +msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:161 +#: src/view/screens/Settings.tsx:286 +msgid "Account" +msgstr "" + +#: src/view/com/util/AccountDropdownBtn.tsx:41 +msgid "Account options" +msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:264 +#: src/view/com/modals/UserAddRemoveLists.tsx:187 +#: src/view/screens/ProfileList.tsx:675 +msgid "Add" +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:56 +msgid "Add a content warning" +msgstr "" + +#: src/view/screens/ProfileList.tsx:665 +msgid "Add a user to this list" +msgstr "" + +#: src/view/screens/Settings.tsx:355 +#: src/view/screens/Settings.tsx:364 +msgid "Add account" +msgstr "" + +#: src/view/com/composer/photos/Gallery.tsx:119 +#: src/view/com/composer/photos/Gallery.tsx:180 +msgid "Add alt text" +msgstr "" + +#: src/view/com/modals/report/InputIssueDetails.tsx:41 +#: src/view/com/modals/report/Modal.tsx:191 +msgid "Add details" +msgstr "" + +#: src/view/com/modals/report/Modal.tsx:194 +msgid "Add details to report" +msgstr "" + +#: src/view/com/composer/Composer.tsx:418 +msgid "Add link card" +msgstr "" + +#: src/view/com/composer/Composer.tsx:421 +msgid "Add link card:" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:415 +msgid "Add the following DNS record to your domain:" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:327 +msgid "Add to Lists" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:279 +msgid "Add to my feeds" +msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:191 +#: src/view/com/modals/UserAddRemoveLists.tsx:122 +msgid "Added to list" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:164 +msgid "Adjust the number of likes a reply must have to be shown in your feed." +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:75 +msgid "Adult Content" +msgstr "" + +#: src/view/screens/Settings.tsx:569 +msgid "Advanced" +msgstr "" + +#: src/view/com/composer/photos/Gallery.tsx:130 +msgid "ALT" +msgstr "" + +#: src/view/com/modals/EditImage.tsx:315 +msgid "Alt text" +msgstr "" + +#: src/view/com/composer/photos/Gallery.tsx:209 +msgid "Alt text describes images for blind and low-vision users, and helps give context to everyone." +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:110 +msgid "An email has been sent to {0}. It includes a confirmation code which you can enter below." +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:119 +msgid "An email has been sent to your previous address, {0}. It includes a confirmation code which you can enter below." +msgstr "" + +#: src/view/com/notifications/FeedItem.tsx:236 +msgid "and" +msgstr "" + +#: src/view/screens/LanguageSettings.tsx:92 +msgid "App Language" +msgstr "" + +#: src/view/screens/Settings.tsx:589 +msgid "App passwords" +msgstr "" + +#: src/view/screens/AppPasswords.tsx:186 +msgid "App Passwords" +msgstr "" + +#: src/view/screens/Settings.tsx:432 +msgid "Appearance" +msgstr "" + +#: src/view/screens/AppPasswords.tsx:223 +msgid "Are you sure you want to delete the app password \"{name}\"?" +msgstr "" + +#: src/view/com/composer/Composer.tsx:137 +msgid "Are you sure you'd like to discard this draft?" +msgstr "" + +#: src/view/screens/ProfileList.tsx:345 +msgid "Are you sure?" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:188 +msgid "Are you sure? This cannot be undone." +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:123 +msgid "Artistic or non-erotic nudity." +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:145 +#: src/view/com/auth/login/ChooseAccountForm.tsx:151 +#: src/view/com/auth/login/ForgotPasswordForm.tsx:166 +#: src/view/com/auth/login/LoginForm.tsx:251 +#: src/view/com/auth/login/SetNewPasswordForm.tsx:148 +#: src/view/com/modals/report/InputIssueDetails.tsx:45 +#: src/view/com/post-thread/PostThread.tsx:376 +#: src/view/com/post-thread/PostThread.tsx:426 +#: src/view/com/post-thread/PostThread.tsx:434 +#: src/view/com/profile/ProfileHeader.tsx:633 +msgid "Back" +msgstr "" + +#: src/view/screens/Settings.tsx:461 +msgid "Basics" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:131 +#: src/view/com/modals/BirthDateSettings.tsx:72 +msgid "Birthday" +msgstr "" + +#: src/view/screens/Settings.tsx:312 +msgid "Birthday:" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:256 +#: src/view/com/profile/ProfileHeader.tsx:363 +msgid "Block Account" +msgstr "" + +#: src/view/screens/ProfileList.tsx:446 +msgid "Block accounts" +msgstr "" + +#: src/view/screens/ProfileList.tsx:302 +msgid "Block these accounts?" +msgstr "" + +#: src/view/screens/Moderation.tsx:109 +msgid "Blocked accounts" +msgstr "" + +#: src/view/screens/ModerationBlockedAccounts.tsx:106 +msgid "Blocked Accounts" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:258 +msgid "Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you." +msgstr "" + +#: src/view/screens/ModerationBlockedAccounts.tsx:114 +msgid "Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you. You will not see their content and they will be prevented from seeing yours." +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:237 +msgid "Blocked post." +msgstr "" + +#: src/view/screens/ProfileList.tsx:304 +msgid "Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you." +msgstr "" + +#: src/view/com/auth/SplashScreen.tsx:26 +msgid "Bluesky" +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:80 +msgid "Bluesky is flexible." +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:69 +msgid "Bluesky is open." +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:56 +msgid "Bluesky is public." +msgstr "" + +#: src/view/com/modals/Waitlist.tsx:70 +msgid "Bluesky uses invites to build a healthier community. If you don't know anybody with an invite, you can sign up for the waitlist and we'll send one soon." +msgstr "" + +#: src/view/com/modals/ServerInput.tsx:78 +msgid "Bluesky.Social" +msgstr "" + +#: src/view/screens/Settings.tsx:718 +msgid "Build version {0} {1}" +msgstr "" + +#: src/view/com/composer/photos/OpenCameraBtn.tsx:60 +#: src/view/com/util/UserAvatar.tsx:217 +#: src/view/com/util/UserBanner.tsx:38 +msgid "Camera" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:214 +msgid "Can only contain letters, numbers, spaces, dashes, and underscores. Must be at least 4 characters long, but no more than 32 characters long." +msgstr "" + +#: src/view/com/composer/Composer.tsx:271 +#: src/view/com/composer/Composer.tsx:274 +#: src/view/com/modals/AltImage.tsx:127 +#: src/view/com/modals/ChangeEmail.tsx:218 +#: src/view/com/modals/ChangeEmail.tsx:220 +#: src/view/com/modals/Confirm.tsx:88 +#: src/view/com/modals/CreateOrEditList.tsx:267 +#: src/view/com/modals/CreateOrEditList.tsx:272 +#: src/view/com/modals/DeleteAccount.tsx:150 +#: src/view/com/modals/DeleteAccount.tsx:223 +#: src/view/com/modals/EditImage.tsx:323 +#: src/view/com/modals/EditProfile.tsx:248 +#: src/view/com/modals/LinkWarning.tsx:85 +#: src/view/com/modals/Repost.tsx:73 +#: src/view/com/modals/Waitlist.tsx:136 +#: src/view/screens/Search/Search.tsx:586 +#: src/view/shell/desktop/Search.tsx:181 +msgid "Cancel" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:146 +#: src/view/com/modals/DeleteAccount.tsx:219 +msgid "Cancel account deletion" +msgstr "" + +#: src/view/com/modals/AltImage.tsx:122 +msgid "Cancel add image alt text" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:149 +msgid "Cancel change handle" +msgstr "" + +#: src/view/com/modals/crop-image/CropImage.web.tsx:134 +msgid "Cancel image crop" +msgstr "" + +#: src/view/com/modals/EditProfile.tsx:243 +msgid "Cancel profile editing" +msgstr "" + +#: src/view/com/modals/Repost.tsx:64 +msgid "Cancel quote post" +msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:87 +#: src/view/shell/desktop/Search.tsx:177 +msgid "Cancel search" +msgstr "" + +#: src/view/com/modals/Waitlist.tsx:132 +msgid "Cancel waitlist signup" +msgstr "" + +#: src/view/screens/Settings.tsx:306 +msgid "Change" +msgstr "" + +#: src/view/screens/Settings.tsx:601 +#: src/view/screens/Settings.tsx:610 +msgid "Change handle" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:161 +msgid "Change Handle" +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:133 +msgid "Change my email" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:109 +msgid "Change Your Email" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:121 +msgid "Check out some recommended feeds. Tap + to add them to your list of pinned feeds." +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:185 +msgid "Check out some recommended users. Follow them to see similar users." +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:163 +msgid "Check your inbox for an email with the confirmation code to enter below:" +msgstr "" + +#: src/view/com/modals/ServerInput.tsx:38 +msgid "Choose Service" +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:83 +msgid "Choose the algorithms that power your experience with custom feeds." +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:65 +#~ msgid "Choose your" +#~ msgstr "" + +#: src/view/com/auth/create/Step2.tsx:106 +msgid "Choose your password" +msgstr "" + +#: src/view/screens/Settings.tsx:694 +msgid "Clear all legacy storage data" +msgstr "" + +#: src/view/screens/Settings.tsx:696 +msgid "Clear all legacy storage data (restart after this)" +msgstr "" + +#: src/view/screens/Settings.tsx:706 +msgid "Clear all storage data" +msgstr "" + +#: src/view/screens/Settings.tsx:708 +msgid "Clear all storage data (restart after this)" +msgstr "" + +#: src/view/com/util/forms/SearchInput.tsx:73 +#: src/view/screens/Search/Search.tsx:571 +msgid "Clear search query" +msgstr "" + +#: src/view/com/auth/login/PasswordUpdatedForm.tsx:38 +msgid "Close alert" +msgstr "" + +#: src/view/com/util/BottomSheetCustomBackdrop.tsx:33 +msgid "Close bottom drawer" +msgstr "" + +#: src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx:26 +msgid "Close image" +msgstr "" + +#: src/view/com/lightbox/Lightbox.web.tsx:112 +msgid "Close image viewer" +msgstr "" + +#: src/view/shell/index.web.tsx:49 +msgid "Close navigation footer" +msgstr "" + +#: src/view/screens/CommunityGuidelines.tsx:32 +msgid "Community Guidelines" +msgstr "" + +#: src/view/com/composer/Prompt.tsx:24 +msgid "Compose reply" +msgstr "" + +#: src/view/com/modals/Confirm.tsx:75 +#: src/view/com/modals/SelfLabel.tsx:154 +#: src/view/com/modals/VerifyEmail.tsx:217 +#: src/view/screens/PreferencesHomeFeed.tsx:299 +#: src/view/screens/PreferencesThreads.tsx:153 +msgid "Confirm" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:193 +#: src/view/com/modals/ChangeEmail.tsx:195 +msgid "Confirm Change" +msgstr "" + +#: src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx:34 +msgid "Confirm content language settings" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:209 +msgid "Confirm delete account" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:157 +#: src/view/com/modals/DeleteAccount.tsx:176 +#: src/view/com/modals/VerifyEmail.tsx:151 +msgid "Confirmation code" +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:178 +#: src/view/com/auth/login/LoginForm.tsx:270 +msgid "Connecting..." +msgstr "" + +#: src/view/screens/Moderation.tsx:67 +msgid "Content filtering" +msgstr "" + +#: src/view/com/modals/ContentFilteringSettings.tsx:44 +msgid "Content Filtering" +msgstr "" + +#: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:74 +#: src/view/screens/LanguageSettings.tsx:273 +msgid "Content Languages" +msgstr "" + +#: src/view/com/util/moderation/ScreenHider.tsx:69 +msgid "Content Warning" +msgstr "" + +#: src/view/com/composer/labels/LabelsBtn.tsx:31 +msgid "Content warnings" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:148 +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:209 +msgid "Continue" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:193 +#: src/view/com/modals/InviteCodes.tsx:178 +msgid "Copied" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:186 +msgid "Copy" +msgstr "" + +#: src/view/screens/ProfileList.tsx:375 +msgid "Copy link to list" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:126 +msgid "Copy link to post" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:312 +msgid "Copy link to profile" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:112 +msgid "Copy post text" +msgstr "" + +#: src/view/screens/CopyrightPolicy.tsx:29 +msgid "Copyright Policy" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:102 +msgid "Could not load feed" +msgstr "" + +#: src/view/screens/ProfileList.tsx:752 +msgid "Could not load list" +msgstr "" + +#: src/view/com/auth/SplashScreen.tsx:41 +msgid "Create a new account" +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:124 +msgid "Create Account" +msgstr "" + +#: src/view/com/auth/SplashScreen.tsx:38 +msgid "Create new account" +msgstr "" + +#: src/view/screens/AppPasswords.tsx:248 +msgid "Created {0}" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:387 +#: src/view/com/modals/ServerInput.tsx:102 +msgid "Custom domain" +msgstr "" + +#: src/view/screens/Settings.tsx:615 +msgid "Danger Zone" +msgstr "" + +#: src/view/screens/Settings.tsx:411 +#~ msgid "Dark" +#~ msgstr "" + +#: src/view/screens/Settings.tsx:622 +msgid "Delete account" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:83 +msgid "Delete Account" +msgstr "" + +#: src/view/screens/AppPasswords.tsx:221 +#: src/view/screens/AppPasswords.tsx:241 +msgid "Delete app password" +msgstr "" + +#: src/view/screens/ProfileList.tsx:344 +#: src/view/screens/ProfileList.tsx:402 +msgid "Delete List" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:212 +msgid "Delete my account" +msgstr "" + +#: src/view/screens/Settings.tsx:632 +msgid "Delete my account…" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:183 +msgid "Delete post" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:187 +msgid "Delete this post?" +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:229 +msgid "Deleted post." +msgstr "" + +#: src/view/com/modals/CreateOrEditList.tsx:218 +#: src/view/com/modals/CreateOrEditList.tsx:234 +#: src/view/com/modals/EditProfile.tsx:197 +#: src/view/com/modals/EditProfile.tsx:209 +msgid "Description" +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:96 +msgid "Dev Server" +msgstr "" + +#: src/view/screens/Settings.tsx:637 +msgid "Developer Tools" +msgstr "" + +#: src/view/com/composer/Composer.tsx:138 +msgid "Discard" +msgstr "" + +#: src/view/com/composer/Composer.tsx:132 +msgid "Discard draft" +msgstr "" + +#: src/view/screens/Feeds.tsx:405 +msgid "Discover new feeds" +msgstr "" + +#: src/view/com/modals/EditProfile.tsx:191 +msgid "Display name" +msgstr "" + +#: src/view/com/modals/EditProfile.tsx:179 +msgid "Display Name" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:485 +msgid "Domain verified!" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:86 +#: src/view/com/modals/ContentFilteringSettings.tsx:88 +#: src/view/com/modals/ContentFilteringSettings.tsx:96 +#: src/view/com/modals/crop-image/CropImage.web.tsx:152 +#: src/view/com/modals/EditImage.tsx:333 +#: src/view/com/modals/ListAddRemoveUsers.tsx:142 +#: src/view/com/modals/SelfLabel.tsx:157 +#: src/view/com/modals/UserAddRemoveLists.tsx:75 +#: src/view/screens/PreferencesHomeFeed.tsx:302 +#: src/view/screens/PreferencesThreads.tsx:156 +msgid "Done" +msgstr "" + +#: src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx:42 +msgid "Done{extraText}" +msgstr "" + +#: src/view/com/modals/InviteCodes.tsx:94 +msgid "Each code works once. You'll receive more invite codes periodically." +msgstr "" + +#: src/view/com/composer/photos/Gallery.tsx:144 +#: src/view/com/modals/EditImage.tsx:207 +msgid "Edit image" +msgstr "" + +#: src/view/screens/ProfileList.tsx:390 +msgid "Edit list details" +msgstr "" + +#: src/view/screens/Feeds.tsx:367 +#: src/view/screens/SavedFeeds.tsx:85 +msgid "Edit My Feeds" +msgstr "" + +#: src/view/com/modals/EditProfile.tsx:151 +msgid "Edit my profile" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:425 +msgid "Edit profile" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:428 +msgid "Edit Profile" +msgstr "" + +#: src/view/screens/Feeds.tsx:330 +msgid "Edit Saved Feeds" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:90 +#: src/view/com/auth/login/ForgotPasswordForm.tsx:148 +#: src/view/com/modals/ChangeEmail.tsx:141 +#: src/view/com/modals/Waitlist.tsx:88 +msgid "Email" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:81 +msgid "Email address" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:111 +msgid "Email Updated" +msgstr "" + +#: src/view/screens/Settings.tsx:290 +msgid "Email:" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:138 +msgid "Enable this setting to only see replies between people you follow." +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:71 +msgid "Enter the address of your provider:" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:369 +msgid "Enter the domain you want to use" +msgstr "" + +#: src/view/com/auth/login/ForgotPasswordForm.tsx:101 +msgid "Enter the email you used to create your account. We'll send you a \"reset code\" so you can set a new password." +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:86 +msgid "Enter your email address" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:117 +msgid "Enter your new email address below." +msgstr "" + +#: src/view/com/auth/login/Login.tsx:83 +msgid "Enter your username and password" +msgstr "" + +#: src/view/screens/Search/Search.tsx:104 +msgid "Error:" +msgstr "" + +#: src/view/com/lightbox/Lightbox.web.tsx:156 +msgid "Expand alt text" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:109 +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:141 +msgid "Failed to load recommended feeds" +msgstr "" + +#: src/view/screens/Feeds.tsx:559 +msgid "Feed offline" +msgstr "" + +#: src/view/com/feeds/FeedPage.tsx:132 +msgid "Feed Preferences" +msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:64 +#: src/view/shell/Drawer.tsx:410 +msgid "Feedback" +msgstr "" + +#: src/view/screens/Feeds.tsx:475 +#: src/view/shell/bottom-bar/BottomBar.tsx:168 +#: src/view/shell/desktop/LeftNav.tsx:341 +#: src/view/shell/Drawer.tsx:327 +#: src/view/shell/Drawer.tsx:328 +msgid "Feeds" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:57 +msgid "Feeds are created by users to curate content. Choose some feeds that you find interesting." +msgstr "" + +#: src/view/screens/SavedFeeds.tsx:156 +msgid "Feeds are custom algorithms that users build with a little coding expertise. <0/> for more information." +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollowsItem.tsx:150 +msgid "Finding similar accounts..." +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:102 +msgid "Fine-tune the content you see on your home screen." +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:60 +msgid "Fine-tune the discussion threads." +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:510 +msgid "Follow" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:42 +#~ msgid "Follow some" +#~ msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:64 +msgid "Follow some users to get started. We can recommend you more users based on who you find interesting." +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:145 +msgid "Followed users only" +msgstr "" + +#: src/view/screens/ProfileFollowers.tsx:25 +msgid "Followers" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:596 +msgid "following" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:494 +#: src/view/screens/ProfileFollows.tsx:25 +msgid "Following" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:543 +msgid "Follows you" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:107 +msgid "For security reasons, we'll need to send a confirmation code to your email address." +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:207 +msgid "For security reasons, you won't be able to view this again. If you lose this password, you'll need to generate a new one." +msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:233 +msgid "Forgot" +msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:230 +msgid "Forgot password" +msgstr "" + +#: src/view/com/auth/login/Login.tsx:111 +#: src/view/com/auth/login/Login.tsx:127 +msgid "Forgot Password" +msgstr "" + +#: src/view/com/composer/photos/SelectPhotoBtn.tsx:43 +msgid "Gallery" +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:175 +msgid "Get Started" +msgstr "" + +#: src/view/com/auth/LoggedOut.tsx:53 +#: src/view/com/auth/LoggedOut.tsx:54 +#: src/view/com/util/moderation/ScreenHider.tsx:105 +#: src/view/shell/desktop/LeftNav.tsx:106 +msgid "Go back" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:111 +#: src/view/screens/ProfileFeed.tsx:116 +#: src/view/screens/ProfileList.tsx:761 +#: src/view/screens/ProfileList.tsx:766 +msgid "Go Back" +msgstr "" + +#: src/view/com/auth/login/ForgotPasswordForm.tsx:181 +#: src/view/com/auth/login/LoginForm.tsx:280 +#: src/view/com/auth/login/SetNewPasswordForm.tsx:163 +msgid "Go to next" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:265 +msgid "Handle" +msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:93 +#: src/view/shell/Drawer.tsx:420 +msgid "Help" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:148 +msgid "Here is your app password." +msgstr "" + +#: src/view/com/notifications/FeedItem.tsx:316 +msgid "Hide" +msgstr "" + +#: src/view/com/notifications/FeedItem.tsx:308 +msgid "Hide user list" +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:101 +msgid "Hmm, some kind of issue occured when contacting the feed server. Please let the feed owner know about this issue." +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:89 +msgid "Hmm, the feed server appears to be misconfigured. Please let the feed owner know about this issue." +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:95 +msgid "Hmm, the feed server appears to be offline. Please let the feed owner know about this issue." +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:92 +msgid "Hmm, the feed server gave a bad response. Please let the feed owner know about this issue." +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:86 +msgid "Hmmm, we're having trouble finding this feed. It may have been deleted." +msgstr "" + +#: src/view/shell/bottom-bar/BottomBar.tsx:124 +#: src/view/shell/desktop/LeftNav.tsx:305 +#: src/view/shell/Drawer.tsx:274 +#: src/view/shell/Drawer.tsx:275 +msgid "Home" +msgstr "" + +#: src/view/com/pager/FeedsTabBarMobile.tsx:99 +#: src/view/screens/PreferencesHomeFeed.tsx:95 +#: src/view/screens/Settings.tsx:481 +msgid "Home Feed Preferences" +msgstr "" + +#: src/view/com/auth/login/ForgotPasswordForm.tsx:114 +msgid "Hosting provider" +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:76 +#: src/view/com/auth/create/Step1.tsx:81 +msgid "Hosting provider address" +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:200 +msgid "I have a code" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:281 +msgid "I have my own domain" +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:127 +msgid "If none are selected, suitable for all ages." +msgstr "" + +#: src/view/com/modals/AltImage.tsx:96 +msgid "Image alt text" +msgstr "" + +#: src/view/com/util/UserAvatar.tsx:304 +#: src/view/com/util/UserBanner.tsx:116 +msgid "Image options" +msgstr "" + +#: src/view/com/search/Suggestions.tsx:104 +#: src/view/com/search/Suggestions.tsx:115 +#~ msgid "In Your Network" +#~ msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:113 +msgid "Invalid username or password" +msgstr "" + +#: src/view/screens/Settings.tsx:383 +msgid "Invite" +msgstr "" + +#: src/view/com/modals/InviteCodes.tsx:91 +#: src/view/screens/Settings.tsx:371 +msgid "Invite a Friend" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:57 +msgid "Invite code" +msgstr "" + +#: src/view/com/auth/create/state.ts:136 +msgid "Invite code not accepted. Check that you input it correctly and try again." +msgstr "" + +#: src/view/shell/Drawer.tsx:502 +msgid "Invite codes: {invitesAvailable} available" +msgstr "" + +#: src/view/com/modals/Waitlist.tsx:67 +msgid "Join the waitlist" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:68 +#: src/view/com/auth/create/Step2.tsx:72 +msgid "Join the waitlist." +msgstr "" + +#: src/view/com/modals/Waitlist.tsx:124 +msgid "Join Waitlist" +msgstr "" + +#: src/view/com/composer/select-language/SelectLangBtn.tsx:104 +msgid "Language selection" +msgstr "" + +#: src/view/screens/LanguageSettings.tsx:86 +msgid "Language Settings" +msgstr "" + +#: src/view/screens/Settings.tsx:541 +msgid "Languages" +msgstr "" + +#: src/view/com/util/moderation/PostAlerts.tsx:47 +#: src/view/com/util/moderation/ProfileHeaderAlerts.tsx:55 +#: src/view/com/util/moderation/ScreenHider.tsx:88 +msgid "Learn More" +msgstr "" + +#: src/view/com/util/moderation/ContentHider.tsx:75 +#: src/view/com/util/moderation/PostAlerts.tsx:40 +#: src/view/com/util/moderation/PostHider.tsx:76 +#: src/view/com/util/moderation/ProfileHeaderAlerts.tsx:47 +#: src/view/com/util/moderation/ScreenHider.tsx:85 +msgid "Learn more about this warning" +msgstr "" + +#: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:82 +msgid "Leave them all unchecked to see any language." +msgstr "" + +#: src/view/com/modals/LinkWarning.tsx:49 +msgid "Leaving Bluesky" +msgstr "" + +#: src/view/com/auth/login/Login.tsx:112 +#: src/view/com/auth/login/Login.tsx:128 +msgid "Let's get your password reset!" +msgstr "" + +#: src/view/com/util/UserAvatar.tsx:241 +#: src/view/com/util/UserBanner.tsx:60 +msgid "Library" +msgstr "" + +#: src/view/screens/Settings.tsx:405 +#~ msgid "Light" +#~ msgstr "" + +#: src/view/screens/ProfileFeed.tsx:627 +msgid "Like this feed" +msgstr "" + +#: src/view/screens/PostLikedBy.tsx:27 +#: src/view/screens/ProfileFeedLikedBy.tsx:27 +msgid "Liked by" +msgstr "" + +#: src/view/com/modals/CreateOrEditList.tsx:186 +msgid "List Avatar" +msgstr "" + +#: src/view/com/modals/CreateOrEditList.tsx:199 +msgid "List Name" +msgstr "" + +#: src/view/shell/desktop/LeftNav.tsx:381 +#: src/view/shell/Drawer.tsx:338 +#: src/view/shell/Drawer.tsx:339 +msgid "Lists" +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:246 +#: src/view/com/post-thread/PostThread.tsx:254 +msgid "Load more posts" +msgstr "" + +#: src/view/screens/Notifications.tsx:129 +msgid "Load new notifications" +msgstr "" + +#: src/view/com/feeds/FeedPage.tsx:177 +msgid "Load new posts" +msgstr "" + +#: src/view/com/composer/text-input/mobile/Autocomplete.tsx:95 +msgid "Loading..." +msgstr "" + +#: src/view/com/modals/ServerInput.tsx:50 +msgid "Local dev server" +msgstr "" + +#: src/view/com/auth/login/ChooseAccountForm.tsx:133 +msgid "Login to account that is not listed" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:479 +msgid "Looks like this feed is only available to users with a Bluesky account. Please sign up or sign in to view this feed!" +msgstr "" + +#: src/view/com/modals/LinkWarning.tsx:63 +msgid "Make sure this is where you intend to go!" +msgstr "" + +#: src/view/screens/Search/Search.tsx:531 +msgid "Menu" +msgstr "" + +#: src/view/screens/Moderation.tsx:51 +#: src/view/screens/Settings.tsx:563 +#: src/view/shell/desktop/LeftNav.tsx:399 +#: src/view/shell/Drawer.tsx:345 +#: src/view/shell/Drawer.tsx:346 +msgid "Moderation" +msgstr "" + +#: src/view/screens/Moderation.tsx:81 +msgid "Moderation lists" +msgstr "" + +#: src/view/shell/desktop/Feeds.tsx:53 +msgid "More feeds" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:520 +#: src/view/screens/ProfileFeed.tsx:369 +#: src/view/screens/ProfileList.tsx:506 +msgid "More options" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:158 +#~ msgid "More post options" +#~ msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:344 +msgid "Mute Account" +msgstr "" + +#: src/view/screens/ProfileList.tsx:434 +msgid "Mute accounts" +msgstr "" + +#: src/view/screens/ProfileList.tsx:267 +msgid "Mute these accounts?" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:144 +msgid "Mute thread" +msgstr "" + +#: src/view/screens/Moderation.tsx:95 +msgid "Muted accounts" +msgstr "" + +#: src/view/screens/ModerationMutedAccounts.tsx:106 +msgid "Muted Accounts" +msgstr "" + +#: src/view/screens/ModerationMutedAccounts.tsx:114 +msgid "Muted accounts have their posts removed from your feed and from your notifications. Mutes are completely private." +msgstr "" + +#: src/view/screens/ProfileList.tsx:269 +msgid "Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them." +msgstr "" + +#: src/view/com/modals/BirthDateSettings.tsx:56 +msgid "My Birthday" +msgstr "" + +#: src/view/screens/Feeds.tsx:363 +msgid "My Feeds" +msgstr "" + +#: src/view/shell/desktop/LeftNav.tsx:67 +msgid "My Profile" +msgstr "" + +#: src/view/screens/Settings.tsx:520 +msgid "My Saved Feeds" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:177 +#: src/view/com/modals/CreateOrEditList.tsx:211 +msgid "Name" +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:72 +msgid "Never lose access to your followers and data." +msgstr "" + +#: src/view/screens/Lists.tsx:76 +msgid "New" +msgstr "" + +#: src/view/com/feeds/FeedPage.tsx:188 +#: src/view/screens/Feeds.tsx:510 +#: src/view/screens/Profile.tsx:382 +#: src/view/screens/ProfileFeed.tsx:449 +#: src/view/screens/ProfileList.tsx:199 +#: src/view/screens/ProfileList.tsx:231 +#: src/view/shell/desktop/LeftNav.tsx:254 +msgid "New post" +msgstr "" + +#: src/view/shell/desktop/LeftNav.tsx:264 +msgid "New Post" +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:158 +#: src/view/com/auth/login/ForgotPasswordForm.tsx:174 +#: src/view/com/auth/login/ForgotPasswordForm.tsx:184 +#: src/view/com/auth/login/LoginForm.tsx:283 +#: src/view/com/auth/login/SetNewPasswordForm.tsx:156 +#: src/view/com/auth/login/SetNewPasswordForm.tsx:166 +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:79 +msgid "Next" +msgstr "" + +#: src/view/com/lightbox/Lightbox.web.tsx:142 +msgid "Next image" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:191 +#: src/view/screens/PreferencesHomeFeed.tsx:226 +#: src/view/screens/PreferencesHomeFeed.tsx:263 +msgid "No" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:620 +#: src/view/screens/ProfileList.tsx:632 +msgid "No description" +msgstr "" + +#: src/view/com/composer/text-input/mobile/Autocomplete.tsx:97 +msgid "No result" +msgstr "" + +#: src/view/screens/Feeds.tsx:452 +msgid "No results found for \"{query}\"" +msgstr "" + +#: src/view/com/modals/ListAddUser.tsx:142 +#: src/view/shell/desktop/Search.tsx:112 +#~ msgid "No results found for {0}" +#~ msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:127 +#: src/view/screens/Search/Search.tsx:269 +#: src/view/screens/Search/Search.tsx:326 +#: src/view/screens/Search/Search.tsx:609 +#: src/view/shell/desktop/Search.tsx:209 +msgid "No results found for {query}" +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:136 +#~ msgid "Not Applicable" +#~ msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:135 +msgid "Not Applicable." +msgstr "" + +#: src/view/screens/Notifications.tsx:96 +#: src/view/screens/Notifications.tsx:120 +#: src/view/shell/bottom-bar/BottomBar.tsx:195 +#: src/view/shell/desktop/LeftNav.tsx:363 +#: src/view/shell/Drawer.tsx:298 +#: src/view/shell/Drawer.tsx:299 +msgid "Notifications" +msgstr "" + +#: src/view/com/util/ErrorBoundary.tsx:34 +msgid "Oh no!" +msgstr "" + +#: src/view/com/auth/login/PasswordUpdatedForm.tsx:41 +msgid "Okay" +msgstr "" + +#: src/view/com/composer/Composer.tsx:334 +msgid "One or more images is missing alt text." +msgstr "" + +#: src/view/com/pager/FeedsTabBarMobile.tsx:79 +msgid "Open navigation" +msgstr "" + +#: src/view/screens/Settings.tsx:533 +msgid "Opens configurable language settings" +msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:146 +#: src/view/shell/Drawer.tsx:503 +msgid "Opens list of invite codes" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:279 +msgid "Opens modal for using custom domain" +msgstr "" + +#: src/view/screens/Settings.tsx:558 +msgid "Opens moderation settings" +msgstr "" + +#: src/view/screens/Settings.tsx:514 +msgid "Opens screen with all saved feeds" +msgstr "" + +#: src/view/screens/Settings.tsx:581 +msgid "Opens the app password settings page" +msgstr "" + +#: src/view/screens/Settings.tsx:473 +msgid "Opens the home feed preferences" +msgstr "" + +#: src/view/screens/Settings.tsx:664 +msgid "Opens the storybook page" +msgstr "" + +#: src/view/screens/Settings.tsx:644 +msgid "Opens the system log page" +msgstr "" + +#: src/view/screens/Settings.tsx:494 +msgid "Opens the threads preferences" +msgstr "" + +#: src/view/com/auth/login/ChooseAccountForm.tsx:138 +msgid "Other account" +msgstr "" + +#: src/view/com/modals/ServerInput.tsx:88 +msgid "Other service" +msgstr "" + +#: src/view/com/composer/select-language/SelectLangBtn.tsx:91 +msgid "Other..." +msgstr "" + +#: src/view/screens/NotFound.tsx:42 +#: src/view/screens/NotFound.tsx:45 +msgid "Page not found" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:101 +#: src/view/com/auth/create/Step2.tsx:111 +#: src/view/com/auth/login/LoginForm.tsx:218 +#: src/view/com/auth/login/SetNewPasswordForm.tsx:130 +#: src/view/com/modals/DeleteAccount.tsx:191 +msgid "Password" +msgstr "" + +#: src/view/com/auth/login/Login.tsx:141 +msgid "Password updated" +msgstr "" + +#: src/view/com/auth/login/PasswordUpdatedForm.tsx:28 +msgid "Password updated!" +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:121 +msgid "Pictures meant for adults." +msgstr "" + +#: src/view/screens/SavedFeeds.tsx:89 +msgid "Pinned Feeds" +msgstr "" + +#: src/view/com/auth/create/state.ts:116 +msgid "Please choose your handle." +msgstr "" + +#: src/view/com/auth/create/state.ts:109 +msgid "Please choose your password." +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:67 +msgid "Please confirm your email before changing it. This is a temporary requirement while email-updating tools are added, and it will soon be removed." +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:140 +msgid "Please enter a unique name for this App Password or use our randomly generated one." +msgstr "" + +#: src/view/com/auth/create/state.ts:95 +msgid "Please enter your email." +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:180 +msgid "Please enter your password as well:" +msgstr "" + +#: src/view/com/composer/Composer.tsx:317 +#: src/view/com/post-thread/PostThread.tsx:212 +#: src/view/screens/PostThread.tsx:77 +msgid "Post" +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:366 +msgid "Post hidden" +msgstr "" + +#: src/view/com/composer/select-language/SelectLangBtn.tsx:87 +msgid "Post language" +msgstr "" + +#: src/view/com/modals/lang-settings/PostLanguagesSettings.tsx:75 +msgid "Post Languages" +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:418 +msgid "Post not found" +msgstr "" + +#: src/view/com/modals/LinkWarning.tsx:44 +msgid "Potentially Misleading Link" +msgstr "" + +#: src/view/com/lightbox/Lightbox.web.tsx:128 +msgid "Previous image" +msgstr "" + +#: src/view/screens/LanguageSettings.tsx:183 +msgid "Primary Language" +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:91 +msgid "Prioritize Your Follows" +msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:75 +msgid "Privacy" +msgstr "" + +#: src/view/screens/PrivacyPolicy.tsx:29 +msgid "Privacy Policy" +msgstr "" + +#: src/view/com/auth/login/ForgotPasswordForm.tsx:190 +msgid "Processing..." +msgstr "" + +#: src/view/shell/bottom-bar/BottomBar.tsx:237 +#: src/view/shell/Drawer.tsx:72 +#: src/view/shell/Drawer.tsx:366 +#: src/view/shell/Drawer.tsx:367 +msgid "Profile" +msgstr "" + +#: src/view/screens/Settings.tsx:789 +msgid "Protect your account by verifying your email." +msgstr "" + +#: src/view/screens/Lists.tsx:61 +msgid "Public, shareable lists which can drive feeds." +msgstr "" + +#: src/view/com/modals/Repost.tsx:52 +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:58 +msgid "Quote post" +msgstr "" + +#: src/view/com/modals/Repost.tsx:56 +msgid "Quote Post" +msgstr "" + +#: src/view/com/modals/EditImage.tsx:236 +msgid "Ratios" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:73 +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:50 +#~ msgid "Recommended" +#~ msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:116 +msgid "Recommended Feeds" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:180 +msgid "Recommended Users" +msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:264 +#: src/view/com/modals/SelfLabel.tsx:83 +#: src/view/com/modals/UserAddRemoveLists.tsx:187 +#: src/view/com/util/UserAvatar.tsx:278 +#: src/view/com/util/UserBanner.tsx:89 +msgid "Remove" +msgstr "" + +#: src/view/com/feeds/FeedSourceCard.tsx:108 +msgid "Remove {0} from my feeds?" +msgstr "" + +#: src/view/com/util/AccountDropdownBtn.tsx:22 +msgid "Remove account" +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:118 +msgid "Remove feed" +msgstr "" + +#: src/view/com/feeds/FeedSourceCard.tsx:107 +#: src/view/screens/ProfileFeed.tsx:279 +msgid "Remove from my feeds" +msgstr "" + +#: src/view/com/composer/photos/Gallery.tsx:167 +msgid "Remove image" +msgstr "" + +#: src/view/com/composer/ExternalEmbed.tsx:70 +msgid "Remove image preview" +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:119 +msgid "Remove this feed from your saved feeds?" +msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:199 +#: src/view/com/modals/UserAddRemoveLists.tsx:130 +msgid "Removed from list" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:135 +msgid "Reply Filters" +msgstr "" + +#: src/view/com/modals/report/Modal.tsx:166 +msgid "Report {collectionName}" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:378 +msgid "Report Account" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:299 +msgid "Report feed" +msgstr "" + +#: src/view/screens/ProfileList.tsx:416 +msgid "Report List" +msgstr "" + +#: src/view/com/modals/report/SendReportButton.tsx:37 +#: src/view/com/util/forms/PostDropdownBtn.tsx:162 +msgid "Report post" +msgstr "" + +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:48 +msgid "Repost" +msgstr "" + +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:94 +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:105 +msgid "Repost or quote post" +msgstr "" + +#: src/view/screens/PostRepostedBy.tsx:27 +msgid "Reposted by" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:181 +#: src/view/com/modals/ChangeEmail.tsx:183 +msgid "Request Change" +msgstr "" + +#: src/view/screens/Settings.tsx:382 +#~ msgid "Require alt text before posting" +#~ msgstr "" + +#: src/view/com/auth/create/Step2.tsx:53 +msgid "Required for this provider" +msgstr "" + +#: src/view/com/auth/login/SetNewPasswordForm.tsx:108 +msgid "Reset code" +msgstr "" + +#: src/view/screens/Settings.tsx:686 +msgid "Reset onboarding state" +msgstr "" + +#: src/view/com/auth/login/ForgotPasswordForm.tsx:98 +msgid "Reset password" +msgstr "" + +#: src/view/screens/Settings.tsx:676 +msgid "Reset preferences state" +msgstr "" + +#: src/view/screens/Settings.tsx:684 +msgid "Resets the onboarding state" +msgstr "" + +#: src/view/screens/Settings.tsx:674 +msgid "Resets the preferences state" +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:167 +#: src/view/com/auth/create/CreateAccount.tsx:171 +#: src/view/com/auth/login/LoginForm.tsx:260 +#: src/view/com/auth/login/LoginForm.tsx:263 +#: src/view/com/util/error/ErrorMessage.tsx:55 +#: src/view/com/util/error/ErrorScreen.tsx:65 +msgid "Retry" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:169 +#~ msgid "Retry change handle" +#~ msgstr "" + +#: src/view/com/modals/AltImage.tsx:114 +#: src/view/com/modals/BirthDateSettings.tsx:93 +#: src/view/com/modals/BirthDateSettings.tsx:96 +#: src/view/com/modals/ChangeHandle.tsx:173 +#: src/view/com/modals/CreateOrEditList.tsx:249 +#: src/view/com/modals/CreateOrEditList.tsx:257 +#: src/view/com/modals/EditProfile.tsx:223 +msgid "Save" +msgstr "" + +#: src/view/com/modals/AltImage.tsx:105 +msgid "Save alt text" +msgstr "" + +#: src/view/com/modals/UserAddRemoveLists.tsx:212 +#~ msgid "Save changes" +#~ msgstr "" + +#: src/view/com/modals/EditProfile.tsx:231 +msgid "Save Changes" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:170 +msgid "Save handle change" +msgstr "" + +#: src/view/com/modals/crop-image/CropImage.web.tsx:144 +msgid "Save image crop" +msgstr "" + +#: src/view/screens/SavedFeeds.tsx:122 +msgid "Saved Feeds" +msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:75 +#: src/view/com/util/forms/SearchInput.tsx:64 +#: src/view/screens/Search/Search.tsx:409 +#: src/view/screens/Search/Search.tsx:561 +#: src/view/shell/bottom-bar/BottomBar.tsx:146 +#: src/view/shell/desktop/LeftNav.tsx:323 +#: src/view/shell/desktop/Search.tsx:160 +#: src/view/shell/desktop/Search.tsx:169 +#: src/view/shell/Drawer.tsx:252 +#: src/view/shell/Drawer.tsx:253 +msgid "Search" +msgstr "" + +#: src/view/screens/Search/Search.tsx:418 +msgid "Search for posts and users." +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:110 +msgid "Security Step Required" +msgstr "" + +#: src/view/com/auth/SplashScreen.tsx:29 +msgid "See what's next" +msgstr "" + +#: src/view/com/modals/ServerInput.tsx:75 +msgid "Select Bluesky Social" +msgstr "" + +#: src/view/com/auth/login/Login.tsx:101 +msgid "Select from an existing account" +msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:145 +msgid "Select service" +msgstr "" + +#: src/view/screens/LanguageSettings.tsx:276 +msgid "Select which languages you want your subscribed feeds to include. If none are selected, all languages will be shown." +msgstr "" + +#: src/view/screens/LanguageSettings.tsx:95 +msgid "Select your app language for the default text to display in the app" +msgstr "" + +#: src/view/screens/LanguageSettings.tsx:186 +msgid "Select your preferred language for translations in your feed." +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:188 +msgid "Send Confirmation Email" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:127 +msgid "Send email" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:138 +msgid "Send Email" +msgstr "" + +#: src/view/shell/Drawer.tsx:394 +#: src/view/shell/Drawer.tsx:415 +msgid "Send feedback" +msgstr "" + +#: src/view/com/modals/report/SendReportButton.tsx:45 +msgid "Send Report" +msgstr "" + +#: src/view/com/auth/login/SetNewPasswordForm.tsx:78 +msgid "Set new password" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:216 +msgid "Set this setting to \"No\" to hide all quote posts from your feed. Reposts will still be visible." +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:113 +msgid "Set this setting to \"No\" to hide all replies from your feed." +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:182 +msgid "Set this setting to \"No\" to hide all reposts from your feed." +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:116 +msgid "Set this setting to \"Yes\" to show replies in a threaded view. This is an experimental feature." +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:252 +msgid "Set this setting to \"Yes\" to show samples of your saved feeds in your following feed. This is an experimental feature." +msgstr "" + +#: src/view/screens/Settings.tsx:277 +#: src/view/shell/desktop/LeftNav.tsx:435 +#: src/view/shell/Drawer.tsx:379 +#: src/view/shell/Drawer.tsx:380 +msgid "Settings" +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:125 +msgid "Sexual activity or erotic nudity." +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:312 +#: src/view/com/util/forms/PostDropdownBtn.tsx:126 +#: src/view/screens/ProfileList.tsx:375 +msgid "Share" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:311 +msgid "Share feed" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:276 +#~ msgid "Share link" +#~ msgstr "" + +#: src/view/screens/Settings.tsx:316 +msgid "Show" +msgstr "" + +#: src/view/com/util/moderation/ScreenHider.tsx:114 +msgid "Show anyway" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:249 +msgid "Show Posts from My Feeds" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:213 +msgid "Show Quote Posts" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:110 +msgid "Show Replies" +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:94 +msgid "Show replies by people you follow before all other replies." +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:179 +msgid "Show Reposts" +msgstr "" + +#: src/view/com/notifications/FeedItem.tsx:337 +msgid "Show users" +msgstr "" + +#: src/view/com/auth/login/Login.tsx:82 +#: src/view/com/auth/SplashScreen.tsx:49 +#: src/view/shell/NavSignupCard.tsx:52 +#: src/view/shell/NavSignupCard.tsx:53 +msgid "Sign in" +msgstr "" + +#: src/view/com/auth/SplashScreen.tsx:52 +#: src/view/com/auth/SplashScreen.web.tsx:84 +msgid "Sign In" +msgstr "" + +#: src/view/com/auth/login/ChooseAccountForm.tsx:44 +msgid "Sign in as {0}" +msgstr "" + +#: src/view/com/auth/login/ChooseAccountForm.tsx:118 +#: src/view/com/auth/login/Login.tsx:100 +msgid "Sign in as..." +msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:132 +msgid "Sign into" +msgstr "" + +#: src/view/com/modals/SwitchAccount.tsx:60 +#: src/view/com/modals/SwitchAccount.tsx:63 +msgid "Sign out" +msgstr "" + +#: src/view/shell/NavSignupCard.tsx:43 +#: src/view/shell/NavSignupCard.tsx:44 +#: src/view/shell/NavSignupCard.tsx:46 +msgid "Sign up" +msgstr "" + +#: src/view/shell/NavSignupCard.tsx:36 +msgid "Sign up or sign in to join the conversation" +msgstr "" + +#: src/view/screens/Settings.tsx:327 +msgid "Signed in as" +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:33 +msgid "Skip" +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:69 +msgid "Sort Replies" +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:72 +msgid "Sort replies to the same post by:" +msgstr "" + +#: src/view/com/modals/crop-image/CropImage.web.tsx:122 +msgid "Square" +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:90 +#: src/view/com/modals/ServerInput.tsx:62 +msgid "Staging" +msgstr "" + +#: src/view/screens/Settings.tsx:730 +msgid "Status page" +msgstr "" + +#: src/view/screens/Settings.tsx:666 +msgid "Storybook" +msgstr "" + +#: src/view/screens/ProfileList.tsx:497 +msgid "Subscribe" +msgstr "" + +#: src/view/screens/ProfileList.tsx:493 +msgid "Subscribe to this list" +msgstr "" + +#: src/view/screens/Search/Search.tsx:382 +msgid "Suggested Follows" +msgstr "" + +#: src/view/screens/Support.tsx:30 +#: src/view/screens/Support.tsx:33 +msgid "Support" +msgstr "" + +#: src/view/com/modals/SwitchAccount.tsx:111 +msgid "Switch Account" +msgstr "" + +#: src/view/screens/Settings.tsx:398 +#~ msgid "System" +#~ msgstr "" + +#: src/view/screens/Settings.tsx:646 +msgid "System log" +msgstr "" + +#: src/view/com/modals/crop-image/CropImage.web.tsx:112 +msgid "Tall" +msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:84 +msgid "Terms" +msgstr "" + +#: src/view/screens/TermsOfService.tsx:29 +msgid "Terms of Service" +msgstr "" + +#: src/view/com/modals/report/InputIssueDetails.tsx:50 +msgid "Text input field" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:280 +msgid "The account will be able to interact with you after unblocking." +msgstr "" + +#: src/view/screens/CommunityGuidelines.tsx:36 +msgid "The Community Guidelines have been moved to <0/>" +msgstr "" + +#: src/view/screens/CopyrightPolicy.tsx:33 +msgid "The Copyright Policy has been moved to <0/>" +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:421 +msgid "The post may have been deleted." +msgstr "" + +#: src/view/screens/PrivacyPolicy.tsx:33 +msgid "The Privacy Policy has been moved to <0/>" +msgstr "" + +#: src/view/screens/Support.tsx:36 +msgid "The support form has been moved. If you need help, please<0/> or visit {HELP_DESK_URL} to get in touch with us." +msgstr "" + +#: src/view/screens/TermsOfService.tsx:33 +msgid "The Terms of Service have been moved to" +msgstr "" + +#: src/view/com/util/ErrorBoundary.tsx:35 +msgid "There was an unexpected issue in the application. Please let us know if this happened to you!" +msgstr "" + +#: src/view/com/util/moderation/ScreenHider.tsx:72 +msgid "This {screenDescription} has been flagged:" +msgstr "" + +#: src/view/com/modals/BirthDateSettings.tsx:61 +msgid "This information is not shared with other users." +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:105 +msgid "This is important in case you ever need to change your email or reset your password." +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:55 +msgid "This is the service that keeps you online." +msgstr "" + +#: src/view/com/modals/LinkWarning.tsx:56 +msgid "This link is taking you to the following website:" +msgstr "" + +#: src/view/com/post-thread/PostThreadItem.tsx:114 +msgid "This post has been deleted." +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:137 +msgid "This warning is only available for posts with media attached." +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:53 +#: src/view/screens/Settings.tsx:503 +msgid "Thread Preferences" +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:113 +msgid "Threaded Mode" +msgstr "" + +#: src/view/com/util/forms/DropdownButton.tsx:230 +msgid "Toggle dropdown" +msgstr "" + +#: src/view/com/modals/EditImage.tsx:271 +msgid "Transformations" +msgstr "" + +#: src/view/com/post-thread/PostThreadItem.tsx:646 +#: src/view/com/post-thread/PostThreadItem.tsx:648 +#: src/view/com/util/forms/PostDropdownBtn.tsx:98 +msgid "Translate" +msgstr "" + +#: src/view/com/util/error/ErrorScreen.tsx:73 +msgid "Try again" +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:64 +#: src/view/com/auth/login/Login.tsx:60 +#: src/view/com/auth/login/LoginForm.tsx:117 +msgid "Unable to contact your service. Please check your Internet connection." +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:438 +#: src/view/com/profile/ProfileHeader.tsx:441 +msgid "Unblock" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:278 +#: src/view/com/profile/ProfileHeader.tsx:362 +msgid "Unblock Account" +msgstr "" + +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:48 +msgid "Undo repost" +msgstr "" + +#: src/view/com/auth/create/state.ts:210 +msgid "Unfortunately, you do not meet the requirements to create an account." +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:343 +msgid "Unmute Account" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:144 +msgid "Unmute thread" +msgstr "" + +#: src/view/com/modals/UserAddRemoveLists.tsx:52 +msgid "Update {displayName} in Lists" +msgstr "" + +#: src/lib/hooks/useOTAUpdate.ts:15 +msgid "Update Available" +msgstr "" + +#: src/view/com/auth/login/SetNewPasswordForm.tsx:172 +msgid "Updating..." +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:453 +msgid "Upload a text file to:" +msgstr "" + +#: src/view/screens/AppPasswords.tsx:194 +msgid "Use app passwords to login to other Bluesky clients without giving full access to your account or password." +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:513 +msgid "Use default provider" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:150 +msgid "Use this to sign into the other app along with your handle." +msgstr "" + +#: src/view/com/modals/InviteCodes.tsx:196 +msgid "Used by:" +msgstr "" + +#: src/view/com/auth/create/Step3.tsx:38 +msgid "User handle" +msgstr "" + +#: src/view/screens/Lists.tsx:58 +msgid "User Lists" +msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:172 +#: src/view/com/auth/login/LoginForm.tsx:189 +msgid "Username or email address" +msgstr "" + +#: src/view/screens/ProfileList.tsx:659 +msgid "Users" +msgstr "" + +#: src/view/screens/Settings.tsx:750 +msgid "Verify email" +msgstr "" + +#: src/view/screens/Settings.tsx:775 +msgid "Verify my email" +msgstr "" + +#: src/view/screens/Settings.tsx:784 +msgid "Verify My Email" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:205 +#: src/view/com/modals/ChangeEmail.tsx:207 +msgid "Verify New Email" +msgstr "" + +#: src/view/screens/Log.tsx:52 +msgid "View debug entry" +msgstr "" + +#: src/view/com/profile/ProfileSubpageHeader.tsx:128 +msgid "View the avatar" +msgstr "" + +#: src/view/com/modals/LinkWarning.tsx:73 +msgid "Visit Site" +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:125 +msgid "We're so excited to have you join us!" +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:98 +msgid "We're sorry, but this content is not viewable without a Bluesky account." +msgstr "" + +#: src/view/screens/Search/Search.tsx:236 +msgid "We're sorry, but your search could not be completed. Please try again in a few minutes." +msgstr "" + +#: src/view/screens/NotFound.tsx:48 +msgid "We're sorry! We can't find the page you were looking for." +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:46 +msgid "Welcome to <0>Bluesky</0>" +msgstr "" + +#: src/view/com/modals/report/Modal.tsx:169 +msgid "What is the issue with this {collectionName}?" +msgstr "" + +#: src/view/com/modals/lang-settings/PostLanguagesSettings.tsx:78 +msgid "Which languages are used in this post?" +msgstr "" + +#: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:77 +msgid "Which languages would you like to see in your algorithmic feeds?" +msgstr "" + +#: src/view/com/modals/crop-image/CropImage.web.tsx:102 +msgid "Wide" +msgstr "" + +#: src/view/com/composer/Composer.tsx:389 +msgid "Write post" +msgstr "" + +#: src/view/com/composer/Prompt.tsx:33 +msgid "Write your reply" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:192 +#: src/view/screens/PreferencesHomeFeed.tsx:227 +#: src/view/screens/PreferencesHomeFeed.tsx:262 +msgid "Yes" +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:106 +msgid "You can change hosting providers at any time." +msgstr "" + +#: src/view/com/auth/login/Login.tsx:142 +#: src/view/com/auth/login/PasswordUpdatedForm.tsx:31 +msgid "You can now sign in with your new password." +msgstr "" + +#: src/view/com/modals/InviteCodes.tsx:64 +msgid "You don't have any invite codes yet! We'll send you some when you've been on Bluesky for a little longer." +msgstr "" + +#: src/view/screens/SavedFeeds.tsx:102 +msgid "You don't have any pinned feeds." +msgstr "" + +#: src/view/screens/Feeds.tsx:383 +msgid "You don't have any saved feeds!" +msgstr "" + +#: src/view/screens/SavedFeeds.tsx:135 +msgid "You don't have any saved feeds." +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:369 +msgid "You have blocked the author or you have been blocked by the author." +msgstr "" + +#: src/view/com/feeds/ProfileFeedgens.tsx:150 +msgid "You have no feeds." +msgstr "" + +#: src/view/com/lists/MyLists.tsx:88 +#: src/view/com/lists/ProfileLists.tsx:154 +msgid "You have no lists." +msgstr "" + +#: src/view/screens/ModerationBlockedAccounts.tsx:131 +msgid "You have not blocked any accounts yet. To block an account, go to their profile and selected \"Block account\" from the menu on their account." +msgstr "" + +#: src/view/screens/AppPasswords.tsx:86 +msgid "You have not created any app passwords yet. You can create one by pressing the button below." +msgstr "" + +#: src/view/screens/ModerationMutedAccounts.tsx:130 +msgid "You have not muted any accounts yet. To mute an account, go to their profile and selected \"Mute account\" from the menu on their account." +msgstr "" + +#: src/view/com/auth/login/SetNewPasswordForm.tsx:81 +msgid "You will receive an email with a \"reset code.\" Enter that code here, then enter your new password." +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:43 +msgid "Your account" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:122 +msgid "Your birth date" +msgstr "" + +#: src/view/com/auth/create/state.ts:102 +msgid "Your email appears to be invalid." +msgstr "" + +#: src/view/com/modals/Waitlist.tsx:107 +msgid "Your email has been saved! We'll be in touch soon." +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:125 +msgid "Your email has been updated but not verified. As a next step, please verify your new email." +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:100 +msgid "Your email has not yet been verified. This is an important security step which we recommend." +msgstr "" + +#: src/view/com/auth/create/Step3.tsx:42 +#: src/view/com/modals/ChangeHandle.tsx:270 +msgid "Your full handle will be" +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:53 +msgid "Your hosting provider" +msgstr "" + +#: src/view/screens/Settings.tsx:402 +#: src/view/shell/desktop/RightNav.tsx:127 +#: src/view/shell/Drawer.tsx:517 +msgid "Your invite codes are hidden when logged in using an App Password" +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:59 +msgid "Your posts, likes, and blocks are public. Mutes are private." +msgstr "" + +#: src/view/com/modals/SwitchAccount.tsx:78 +msgid "Your profile" +msgstr "" + +#: src/view/com/auth/create/Step3.tsx:28 +msgid "Your user handle" +msgstr "" diff --git a/src/locale/locales/es/messages.po b/src/locale/locales/es/messages.po new file mode 100644 index 000000000..baab22c72 --- /dev/null +++ b/src/locale/locales/es/messages.po @@ -0,0 +1,2297 @@ +msgid "" +msgstr "" +"POT-Creation-Date: 2023-11-06 12:28-0800\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: @lingui/cli\n" +"Language: es\n" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Plural-Forms: \n" + +#: src/view/screens/Profile.tsx:214 +#~ msgid "- end of feed -" +#~ msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:138 +#~ msgid ". This warning is only available for posts with media attached." +#~ msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:158 +msgid "{0, plural, one {# invite code available} other {# invite codes available}}" +msgstr "" + +#: src/view/com/modals/Repost.tsx:44 +msgid "{0}" +msgstr "" + +#: src/view/com/modals/CreateOrEditList.tsx:176 +msgid "{0} {purposeLabel} List" +msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:141 +msgid "{invitesAvailable, plural, one {Invite codes: # available} other {Invite codes: # available}}" +msgstr "" + +#: src/view/screens/Settings.tsx:407 +#: src/view/shell/Drawer.tsx:521 +msgid "{invitesAvailable} invite code available" +msgstr "" + +#: src/view/screens/Settings.tsx:409 +#: src/view/shell/Drawer.tsx:523 +msgid "{invitesAvailable} invite codes available" +msgstr "" + +#: src/view/screens/Search/Search.tsx:86 +msgid "{message}" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:30 +msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:37 +msgid "<0>Follow some</0><1>Recommended</1><2>Users</2>" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:132 +#~ msgid "<0>Here is your app password.</0> Use this to sign into the other app along with your handle." +#~ msgstr "" + +#: src/lib/hooks/useOTAUpdate.ts:16 +msgid "A new version of the app is available. Please update to continue using the app." +msgstr "" + +#: src/view/com/modals/EditImage.tsx:299 +#: src/view/screens/Settings.tsx:417 +msgid "Accessibility" +msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:161 +#: src/view/screens/Settings.tsx:286 +msgid "Account" +msgstr "" + +#: src/view/com/util/AccountDropdownBtn.tsx:41 +msgid "Account options" +msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:264 +#: src/view/com/modals/UserAddRemoveLists.tsx:187 +#: src/view/screens/ProfileList.tsx:675 +msgid "Add" +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:56 +msgid "Add a content warning" +msgstr "" + +#: src/view/screens/ProfileList.tsx:665 +msgid "Add a user to this list" +msgstr "" + +#: src/view/screens/Settings.tsx:355 +#: src/view/screens/Settings.tsx:364 +msgid "Add account" +msgstr "" + +#: src/view/com/composer/photos/Gallery.tsx:119 +#: src/view/com/composer/photos/Gallery.tsx:180 +msgid "Add alt text" +msgstr "" + +#: src/view/com/modals/report/InputIssueDetails.tsx:41 +#: src/view/com/modals/report/Modal.tsx:191 +msgid "Add details" +msgstr "" + +#: src/view/com/modals/report/Modal.tsx:194 +msgid "Add details to report" +msgstr "" + +#: src/view/com/composer/Composer.tsx:418 +msgid "Add link card" +msgstr "" + +#: src/view/com/composer/Composer.tsx:421 +msgid "Add link card:" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:415 +msgid "Add the following DNS record to your domain:" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:327 +msgid "Add to Lists" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:279 +msgid "Add to my feeds" +msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:191 +#: src/view/com/modals/UserAddRemoveLists.tsx:122 +msgid "Added to list" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:164 +msgid "Adjust the number of likes a reply must have to be shown in your feed." +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:75 +msgid "Adult Content" +msgstr "" + +#: src/view/screens/Settings.tsx:569 +msgid "Advanced" +msgstr "" + +#: src/view/com/composer/photos/Gallery.tsx:130 +msgid "ALT" +msgstr "" + +#: src/view/com/modals/EditImage.tsx:315 +msgid "Alt text" +msgstr "" + +#: src/view/com/composer/photos/Gallery.tsx:209 +msgid "Alt text describes images for blind and low-vision users, and helps give context to everyone." +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:110 +msgid "An email has been sent to {0}. It includes a confirmation code which you can enter below." +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:119 +msgid "An email has been sent to your previous address, {0}. It includes a confirmation code which you can enter below." +msgstr "" + +#: src/view/com/notifications/FeedItem.tsx:236 +msgid "and" +msgstr "" + +#: src/view/screens/LanguageSettings.tsx:92 +msgid "App Language" +msgstr "" + +#: src/view/screens/Settings.tsx:589 +msgid "App passwords" +msgstr "" + +#: src/view/screens/AppPasswords.tsx:186 +msgid "App Passwords" +msgstr "" + +#: src/view/screens/Settings.tsx:432 +msgid "Appearance" +msgstr "" + +#: src/view/screens/AppPasswords.tsx:223 +msgid "Are you sure you want to delete the app password \"{name}\"?" +msgstr "" + +#: src/view/com/composer/Composer.tsx:137 +msgid "Are you sure you'd like to discard this draft?" +msgstr "" + +#: src/view/screens/ProfileList.tsx:345 +msgid "Are you sure?" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:188 +msgid "Are you sure? This cannot be undone." +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:123 +msgid "Artistic or non-erotic nudity." +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:145 +#: src/view/com/auth/login/ChooseAccountForm.tsx:151 +#: src/view/com/auth/login/ForgotPasswordForm.tsx:166 +#: src/view/com/auth/login/LoginForm.tsx:251 +#: src/view/com/auth/login/SetNewPasswordForm.tsx:148 +#: src/view/com/modals/report/InputIssueDetails.tsx:45 +#: src/view/com/post-thread/PostThread.tsx:376 +#: src/view/com/post-thread/PostThread.tsx:426 +#: src/view/com/post-thread/PostThread.tsx:434 +#: src/view/com/profile/ProfileHeader.tsx:633 +msgid "Back" +msgstr "" + +#: src/view/screens/Settings.tsx:461 +msgid "Basics" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:131 +#: src/view/com/modals/BirthDateSettings.tsx:72 +msgid "Birthday" +msgstr "" + +#: src/view/screens/Settings.tsx:312 +msgid "Birthday:" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:256 +#: src/view/com/profile/ProfileHeader.tsx:363 +msgid "Block Account" +msgstr "" + +#: src/view/screens/ProfileList.tsx:446 +msgid "Block accounts" +msgstr "" + +#: src/view/screens/ProfileList.tsx:302 +msgid "Block these accounts?" +msgstr "" + +#: src/view/screens/Moderation.tsx:109 +msgid "Blocked accounts" +msgstr "" + +#: src/view/screens/ModerationBlockedAccounts.tsx:106 +msgid "Blocked Accounts" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:258 +msgid "Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you." +msgstr "" + +#: src/view/screens/ModerationBlockedAccounts.tsx:114 +msgid "Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you. You will not see their content and they will be prevented from seeing yours." +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:237 +msgid "Blocked post." +msgstr "" + +#: src/view/screens/ProfileList.tsx:304 +msgid "Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you." +msgstr "" + +#: src/view/com/auth/SplashScreen.tsx:26 +msgid "Bluesky" +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:80 +msgid "Bluesky is flexible." +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:69 +msgid "Bluesky is open." +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:56 +msgid "Bluesky is public." +msgstr "" + +#: src/view/com/modals/Waitlist.tsx:70 +msgid "Bluesky uses invites to build a healthier community. If you don't know anybody with an invite, you can sign up for the waitlist and we'll send one soon." +msgstr "" + +#: src/view/com/modals/ServerInput.tsx:78 +msgid "Bluesky.Social" +msgstr "" + +#: src/view/screens/Settings.tsx:718 +msgid "Build version {0} {1}" +msgstr "" + +#: src/view/com/composer/photos/OpenCameraBtn.tsx:60 +#: src/view/com/util/UserAvatar.tsx:217 +#: src/view/com/util/UserBanner.tsx:38 +msgid "Camera" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:214 +msgid "Can only contain letters, numbers, spaces, dashes, and underscores. Must be at least 4 characters long, but no more than 32 characters long." +msgstr "" + +#: src/view/com/composer/Composer.tsx:271 +#: src/view/com/composer/Composer.tsx:274 +#: src/view/com/modals/AltImage.tsx:127 +#: src/view/com/modals/ChangeEmail.tsx:218 +#: src/view/com/modals/ChangeEmail.tsx:220 +#: src/view/com/modals/Confirm.tsx:88 +#: src/view/com/modals/CreateOrEditList.tsx:267 +#: src/view/com/modals/CreateOrEditList.tsx:272 +#: src/view/com/modals/DeleteAccount.tsx:150 +#: src/view/com/modals/DeleteAccount.tsx:223 +#: src/view/com/modals/EditImage.tsx:323 +#: src/view/com/modals/EditProfile.tsx:248 +#: src/view/com/modals/LinkWarning.tsx:85 +#: src/view/com/modals/Repost.tsx:73 +#: src/view/com/modals/Waitlist.tsx:136 +#: src/view/screens/Search/Search.tsx:586 +#: src/view/shell/desktop/Search.tsx:181 +msgid "Cancel" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:146 +#: src/view/com/modals/DeleteAccount.tsx:219 +msgid "Cancel account deletion" +msgstr "" + +#: src/view/com/modals/AltImage.tsx:122 +msgid "Cancel add image alt text" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:149 +msgid "Cancel change handle" +msgstr "" + +#: src/view/com/modals/crop-image/CropImage.web.tsx:134 +msgid "Cancel image crop" +msgstr "" + +#: src/view/com/modals/EditProfile.tsx:243 +msgid "Cancel profile editing" +msgstr "" + +#: src/view/com/modals/Repost.tsx:64 +msgid "Cancel quote post" +msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:87 +#: src/view/shell/desktop/Search.tsx:177 +msgid "Cancel search" +msgstr "" + +#: src/view/com/modals/Waitlist.tsx:132 +msgid "Cancel waitlist signup" +msgstr "" + +#: src/view/screens/Settings.tsx:306 +msgid "Change" +msgstr "" + +#: src/view/screens/Settings.tsx:601 +#: src/view/screens/Settings.tsx:610 +msgid "Change handle" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:161 +msgid "Change Handle" +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:133 +msgid "Change my email" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:109 +msgid "Change Your Email" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:121 +msgid "Check out some recommended feeds. Tap + to add them to your list of pinned feeds." +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:185 +msgid "Check out some recommended users. Follow them to see similar users." +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:163 +msgid "Check your inbox for an email with the confirmation code to enter below:" +msgstr "" + +#: src/view/com/modals/ServerInput.tsx:38 +msgid "Choose Service" +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:83 +msgid "Choose the algorithms that power your experience with custom feeds." +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:65 +#~ msgid "Choose your" +#~ msgstr "" + +#: src/view/com/auth/create/Step2.tsx:106 +msgid "Choose your password" +msgstr "" + +#: src/view/screens/Settings.tsx:694 +msgid "Clear all legacy storage data" +msgstr "" + +#: src/view/screens/Settings.tsx:696 +msgid "Clear all legacy storage data (restart after this)" +msgstr "" + +#: src/view/screens/Settings.tsx:706 +msgid "Clear all storage data" +msgstr "" + +#: src/view/screens/Settings.tsx:708 +msgid "Clear all storage data (restart after this)" +msgstr "" + +#: src/view/com/util/forms/SearchInput.tsx:73 +#: src/view/screens/Search/Search.tsx:571 +msgid "Clear search query" +msgstr "" + +#: src/view/com/auth/login/PasswordUpdatedForm.tsx:38 +msgid "Close alert" +msgstr "" + +#: src/view/com/util/BottomSheetCustomBackdrop.tsx:33 +msgid "Close bottom drawer" +msgstr "" + +#: src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx:26 +msgid "Close image" +msgstr "" + +#: src/view/com/lightbox/Lightbox.web.tsx:112 +msgid "Close image viewer" +msgstr "" + +#: src/view/shell/index.web.tsx:49 +msgid "Close navigation footer" +msgstr "" + +#: src/view/screens/CommunityGuidelines.tsx:32 +msgid "Community Guidelines" +msgstr "" + +#: src/view/com/composer/Prompt.tsx:24 +msgid "Compose reply" +msgstr "" + +#: src/view/com/modals/Confirm.tsx:75 +#: src/view/com/modals/SelfLabel.tsx:154 +#: src/view/com/modals/VerifyEmail.tsx:217 +#: src/view/screens/PreferencesHomeFeed.tsx:299 +#: src/view/screens/PreferencesThreads.tsx:153 +msgid "Confirm" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:193 +#: src/view/com/modals/ChangeEmail.tsx:195 +msgid "Confirm Change" +msgstr "" + +#: src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx:34 +msgid "Confirm content language settings" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:209 +msgid "Confirm delete account" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:157 +#: src/view/com/modals/DeleteAccount.tsx:176 +#: src/view/com/modals/VerifyEmail.tsx:151 +msgid "Confirmation code" +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:178 +#: src/view/com/auth/login/LoginForm.tsx:270 +msgid "Connecting..." +msgstr "" + +#: src/view/screens/Moderation.tsx:67 +msgid "Content filtering" +msgstr "" + +#: src/view/com/modals/ContentFilteringSettings.tsx:44 +msgid "Content Filtering" +msgstr "" + +#: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:74 +#: src/view/screens/LanguageSettings.tsx:273 +msgid "Content Languages" +msgstr "" + +#: src/view/com/util/moderation/ScreenHider.tsx:69 +msgid "Content Warning" +msgstr "" + +#: src/view/com/composer/labels/LabelsBtn.tsx:31 +msgid "Content warnings" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:148 +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:209 +msgid "Continue" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:193 +#: src/view/com/modals/InviteCodes.tsx:178 +msgid "Copied" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:186 +msgid "Copy" +msgstr "" + +#: src/view/screens/ProfileList.tsx:375 +msgid "Copy link to list" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:126 +msgid "Copy link to post" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:312 +msgid "Copy link to profile" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:112 +msgid "Copy post text" +msgstr "" + +#: src/view/screens/CopyrightPolicy.tsx:29 +msgid "Copyright Policy" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:102 +msgid "Could not load feed" +msgstr "" + +#: src/view/screens/ProfileList.tsx:752 +msgid "Could not load list" +msgstr "" + +#: src/view/com/auth/SplashScreen.tsx:41 +msgid "Create a new account" +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:124 +msgid "Create Account" +msgstr "" + +#: src/view/com/auth/SplashScreen.tsx:38 +msgid "Create new account" +msgstr "" + +#: src/view/screens/AppPasswords.tsx:248 +msgid "Created {0}" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:387 +#: src/view/com/modals/ServerInput.tsx:102 +msgid "Custom domain" +msgstr "" + +#: src/view/screens/Settings.tsx:615 +msgid "Danger Zone" +msgstr "" + +#: src/view/screens/Settings.tsx:411 +#~ msgid "Dark" +#~ msgstr "" + +#: src/view/screens/Settings.tsx:622 +msgid "Delete account" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:83 +msgid "Delete Account" +msgstr "" + +#: src/view/screens/AppPasswords.tsx:221 +#: src/view/screens/AppPasswords.tsx:241 +msgid "Delete app password" +msgstr "" + +#: src/view/screens/ProfileList.tsx:344 +#: src/view/screens/ProfileList.tsx:402 +msgid "Delete List" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:212 +msgid "Delete my account" +msgstr "" + +#: src/view/screens/Settings.tsx:632 +msgid "Delete my account…" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:183 +msgid "Delete post" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:187 +msgid "Delete this post?" +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:229 +msgid "Deleted post." +msgstr "" + +#: src/view/com/modals/CreateOrEditList.tsx:218 +#: src/view/com/modals/CreateOrEditList.tsx:234 +#: src/view/com/modals/EditProfile.tsx:197 +#: src/view/com/modals/EditProfile.tsx:209 +msgid "Description" +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:96 +msgid "Dev Server" +msgstr "" + +#: src/view/screens/Settings.tsx:637 +msgid "Developer Tools" +msgstr "" + +#: src/view/com/composer/Composer.tsx:138 +msgid "Discard" +msgstr "" + +#: src/view/com/composer/Composer.tsx:132 +msgid "Discard draft" +msgstr "" + +#: src/view/screens/Feeds.tsx:405 +msgid "Discover new feeds" +msgstr "" + +#: src/view/com/modals/EditProfile.tsx:191 +msgid "Display name" +msgstr "" + +#: src/view/com/modals/EditProfile.tsx:179 +msgid "Display Name" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:485 +msgid "Domain verified!" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:86 +#: src/view/com/modals/ContentFilteringSettings.tsx:88 +#: src/view/com/modals/ContentFilteringSettings.tsx:96 +#: src/view/com/modals/crop-image/CropImage.web.tsx:152 +#: src/view/com/modals/EditImage.tsx:333 +#: src/view/com/modals/ListAddRemoveUsers.tsx:142 +#: src/view/com/modals/SelfLabel.tsx:157 +#: src/view/com/modals/UserAddRemoveLists.tsx:75 +#: src/view/screens/PreferencesHomeFeed.tsx:302 +#: src/view/screens/PreferencesThreads.tsx:156 +msgid "Done" +msgstr "" + +#: src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx:42 +msgid "Done{extraText}" +msgstr "" + +#: src/view/com/modals/InviteCodes.tsx:94 +msgid "Each code works once. You'll receive more invite codes periodically." +msgstr "" + +#: src/view/com/composer/photos/Gallery.tsx:144 +#: src/view/com/modals/EditImage.tsx:207 +msgid "Edit image" +msgstr "" + +#: src/view/screens/ProfileList.tsx:390 +msgid "Edit list details" +msgstr "" + +#: src/view/screens/Feeds.tsx:367 +#: src/view/screens/SavedFeeds.tsx:85 +msgid "Edit My Feeds" +msgstr "" + +#: src/view/com/modals/EditProfile.tsx:151 +msgid "Edit my profile" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:425 +msgid "Edit profile" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:428 +msgid "Edit Profile" +msgstr "" + +#: src/view/screens/Feeds.tsx:330 +msgid "Edit Saved Feeds" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:90 +#: src/view/com/auth/login/ForgotPasswordForm.tsx:148 +#: src/view/com/modals/ChangeEmail.tsx:141 +#: src/view/com/modals/Waitlist.tsx:88 +msgid "Email" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:81 +msgid "Email address" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:111 +msgid "Email Updated" +msgstr "" + +#: src/view/screens/Settings.tsx:290 +msgid "Email:" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:138 +msgid "Enable this setting to only see replies between people you follow." +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:71 +msgid "Enter the address of your provider:" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:369 +msgid "Enter the domain you want to use" +msgstr "" + +#: src/view/com/auth/login/ForgotPasswordForm.tsx:101 +msgid "Enter the email you used to create your account. We'll send you a \"reset code\" so you can set a new password." +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:86 +msgid "Enter your email address" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:117 +msgid "Enter your new email address below." +msgstr "" + +#: src/view/com/auth/login/Login.tsx:83 +msgid "Enter your username and password" +msgstr "" + +#: src/view/screens/Search/Search.tsx:104 +msgid "Error:" +msgstr "" + +#: src/view/com/lightbox/Lightbox.web.tsx:156 +msgid "Expand alt text" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:109 +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:141 +msgid "Failed to load recommended feeds" +msgstr "" + +#: src/view/screens/Feeds.tsx:559 +msgid "Feed offline" +msgstr "" + +#: src/view/com/feeds/FeedPage.tsx:132 +msgid "Feed Preferences" +msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:64 +#: src/view/shell/Drawer.tsx:410 +msgid "Feedback" +msgstr "" + +#: src/view/screens/Feeds.tsx:475 +#: src/view/shell/bottom-bar/BottomBar.tsx:168 +#: src/view/shell/desktop/LeftNav.tsx:341 +#: src/view/shell/Drawer.tsx:327 +#: src/view/shell/Drawer.tsx:328 +msgid "Feeds" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:57 +msgid "Feeds are created by users to curate content. Choose some feeds that you find interesting." +msgstr "" + +#: src/view/screens/SavedFeeds.tsx:156 +msgid "Feeds are custom algorithms that users build with a little coding expertise. <0/> for more information." +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollowsItem.tsx:150 +msgid "Finding similar accounts..." +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:102 +msgid "Fine-tune the content you see on your home screen." +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:60 +msgid "Fine-tune the discussion threads." +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:510 +msgid "Follow" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:42 +#~ msgid "Follow some" +#~ msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:64 +msgid "Follow some users to get started. We can recommend you more users based on who you find interesting." +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:145 +msgid "Followed users only" +msgstr "" + +#: src/view/screens/ProfileFollowers.tsx:25 +msgid "Followers" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:596 +msgid "following" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:494 +#: src/view/screens/ProfileFollows.tsx:25 +msgid "Following" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:543 +msgid "Follows you" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:107 +msgid "For security reasons, we'll need to send a confirmation code to your email address." +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:207 +msgid "For security reasons, you won't be able to view this again. If you lose this password, you'll need to generate a new one." +msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:233 +msgid "Forgot" +msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:230 +msgid "Forgot password" +msgstr "" + +#: src/view/com/auth/login/Login.tsx:111 +#: src/view/com/auth/login/Login.tsx:127 +msgid "Forgot Password" +msgstr "" + +#: src/view/com/composer/photos/SelectPhotoBtn.tsx:43 +msgid "Gallery" +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:175 +msgid "Get Started" +msgstr "" + +#: src/view/com/auth/LoggedOut.tsx:53 +#: src/view/com/auth/LoggedOut.tsx:54 +#: src/view/com/util/moderation/ScreenHider.tsx:105 +#: src/view/shell/desktop/LeftNav.tsx:106 +msgid "Go back" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:111 +#: src/view/screens/ProfileFeed.tsx:116 +#: src/view/screens/ProfileList.tsx:761 +#: src/view/screens/ProfileList.tsx:766 +msgid "Go Back" +msgstr "" + +#: src/view/com/auth/login/ForgotPasswordForm.tsx:181 +#: src/view/com/auth/login/LoginForm.tsx:280 +#: src/view/com/auth/login/SetNewPasswordForm.tsx:163 +msgid "Go to next" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:265 +msgid "Handle" +msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:93 +#: src/view/shell/Drawer.tsx:420 +msgid "Help" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:148 +msgid "Here is your app password." +msgstr "" + +#: src/view/com/notifications/FeedItem.tsx:316 +msgid "Hide" +msgstr "" + +#: src/view/com/notifications/FeedItem.tsx:308 +msgid "Hide user list" +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:101 +msgid "Hmm, some kind of issue occured when contacting the feed server. Please let the feed owner know about this issue." +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:89 +msgid "Hmm, the feed server appears to be misconfigured. Please let the feed owner know about this issue." +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:95 +msgid "Hmm, the feed server appears to be offline. Please let the feed owner know about this issue." +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:92 +msgid "Hmm, the feed server gave a bad response. Please let the feed owner know about this issue." +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:86 +msgid "Hmmm, we're having trouble finding this feed. It may have been deleted." +msgstr "" + +#: src/view/shell/bottom-bar/BottomBar.tsx:124 +#: src/view/shell/desktop/LeftNav.tsx:305 +#: src/view/shell/Drawer.tsx:274 +#: src/view/shell/Drawer.tsx:275 +msgid "Home" +msgstr "" + +#: src/view/com/pager/FeedsTabBarMobile.tsx:99 +#: src/view/screens/PreferencesHomeFeed.tsx:95 +#: src/view/screens/Settings.tsx:481 +msgid "Home Feed Preferences" +msgstr "" + +#: src/view/com/auth/login/ForgotPasswordForm.tsx:114 +msgid "Hosting provider" +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:76 +#: src/view/com/auth/create/Step1.tsx:81 +msgid "Hosting provider address" +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:200 +msgid "I have a code" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:281 +msgid "I have my own domain" +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:127 +msgid "If none are selected, suitable for all ages." +msgstr "" + +#: src/view/com/modals/AltImage.tsx:96 +msgid "Image alt text" +msgstr "" + +#: src/view/com/util/UserAvatar.tsx:304 +#: src/view/com/util/UserBanner.tsx:116 +msgid "Image options" +msgstr "" + +#: src/view/com/search/Suggestions.tsx:104 +#: src/view/com/search/Suggestions.tsx:115 +#~ msgid "In Your Network" +#~ msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:113 +msgid "Invalid username or password" +msgstr "" + +#: src/view/screens/Settings.tsx:383 +msgid "Invite" +msgstr "" + +#: src/view/com/modals/InviteCodes.tsx:91 +#: src/view/screens/Settings.tsx:371 +msgid "Invite a Friend" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:57 +msgid "Invite code" +msgstr "" + +#: src/view/com/auth/create/state.ts:136 +msgid "Invite code not accepted. Check that you input it correctly and try again." +msgstr "" + +#: src/view/shell/Drawer.tsx:502 +msgid "Invite codes: {invitesAvailable} available" +msgstr "" + +#: src/view/com/modals/Waitlist.tsx:67 +msgid "Join the waitlist" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:68 +#: src/view/com/auth/create/Step2.tsx:72 +msgid "Join the waitlist." +msgstr "" + +#: src/view/com/modals/Waitlist.tsx:124 +msgid "Join Waitlist" +msgstr "" + +#: src/view/com/composer/select-language/SelectLangBtn.tsx:104 +msgid "Language selection" +msgstr "" + +#: src/view/screens/LanguageSettings.tsx:86 +msgid "Language Settings" +msgstr "" + +#: src/view/screens/Settings.tsx:541 +msgid "Languages" +msgstr "" + +#: src/view/com/util/moderation/PostAlerts.tsx:47 +#: src/view/com/util/moderation/ProfileHeaderAlerts.tsx:55 +#: src/view/com/util/moderation/ScreenHider.tsx:88 +msgid "Learn More" +msgstr "" + +#: src/view/com/util/moderation/ContentHider.tsx:75 +#: src/view/com/util/moderation/PostAlerts.tsx:40 +#: src/view/com/util/moderation/PostHider.tsx:76 +#: src/view/com/util/moderation/ProfileHeaderAlerts.tsx:47 +#: src/view/com/util/moderation/ScreenHider.tsx:85 +msgid "Learn more about this warning" +msgstr "" + +#: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:82 +msgid "Leave them all unchecked to see any language." +msgstr "" + +#: src/view/com/modals/LinkWarning.tsx:49 +msgid "Leaving Bluesky" +msgstr "" + +#: src/view/com/auth/login/Login.tsx:112 +#: src/view/com/auth/login/Login.tsx:128 +msgid "Let's get your password reset!" +msgstr "" + +#: src/view/com/util/UserAvatar.tsx:241 +#: src/view/com/util/UserBanner.tsx:60 +msgid "Library" +msgstr "" + +#: src/view/screens/Settings.tsx:405 +#~ msgid "Light" +#~ msgstr "" + +#: src/view/screens/ProfileFeed.tsx:627 +msgid "Like this feed" +msgstr "" + +#: src/view/screens/PostLikedBy.tsx:27 +#: src/view/screens/ProfileFeedLikedBy.tsx:27 +msgid "Liked by" +msgstr "" + +#: src/view/com/modals/CreateOrEditList.tsx:186 +msgid "List Avatar" +msgstr "" + +#: src/view/com/modals/CreateOrEditList.tsx:199 +msgid "List Name" +msgstr "" + +#: src/view/shell/desktop/LeftNav.tsx:381 +#: src/view/shell/Drawer.tsx:338 +#: src/view/shell/Drawer.tsx:339 +msgid "Lists" +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:246 +#: src/view/com/post-thread/PostThread.tsx:254 +msgid "Load more posts" +msgstr "" + +#: src/view/screens/Notifications.tsx:129 +msgid "Load new notifications" +msgstr "" + +#: src/view/com/feeds/FeedPage.tsx:177 +msgid "Load new posts" +msgstr "" + +#: src/view/com/composer/text-input/mobile/Autocomplete.tsx:95 +msgid "Loading..." +msgstr "" + +#: src/view/com/modals/ServerInput.tsx:50 +msgid "Local dev server" +msgstr "" + +#: src/view/com/auth/login/ChooseAccountForm.tsx:133 +msgid "Login to account that is not listed" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:479 +msgid "Looks like this feed is only available to users with a Bluesky account. Please sign up or sign in to view this feed!" +msgstr "" + +#: src/view/com/modals/LinkWarning.tsx:63 +msgid "Make sure this is where you intend to go!" +msgstr "" + +#: src/view/screens/Search/Search.tsx:531 +msgid "Menu" +msgstr "" + +#: src/view/screens/Moderation.tsx:51 +#: src/view/screens/Settings.tsx:563 +#: src/view/shell/desktop/LeftNav.tsx:399 +#: src/view/shell/Drawer.tsx:345 +#: src/view/shell/Drawer.tsx:346 +msgid "Moderation" +msgstr "" + +#: src/view/screens/Moderation.tsx:81 +msgid "Moderation lists" +msgstr "" + +#: src/view/shell/desktop/Feeds.tsx:53 +msgid "More feeds" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:520 +#: src/view/screens/ProfileFeed.tsx:369 +#: src/view/screens/ProfileList.tsx:506 +msgid "More options" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:158 +#~ msgid "More post options" +#~ msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:344 +msgid "Mute Account" +msgstr "" + +#: src/view/screens/ProfileList.tsx:434 +msgid "Mute accounts" +msgstr "" + +#: src/view/screens/ProfileList.tsx:267 +msgid "Mute these accounts?" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:144 +msgid "Mute thread" +msgstr "" + +#: src/view/screens/Moderation.tsx:95 +msgid "Muted accounts" +msgstr "" + +#: src/view/screens/ModerationMutedAccounts.tsx:106 +msgid "Muted Accounts" +msgstr "" + +#: src/view/screens/ModerationMutedAccounts.tsx:114 +msgid "Muted accounts have their posts removed from your feed and from your notifications. Mutes are completely private." +msgstr "" + +#: src/view/screens/ProfileList.tsx:269 +msgid "Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them." +msgstr "" + +#: src/view/com/modals/BirthDateSettings.tsx:56 +msgid "My Birthday" +msgstr "" + +#: src/view/screens/Feeds.tsx:363 +msgid "My Feeds" +msgstr "" + +#: src/view/shell/desktop/LeftNav.tsx:67 +msgid "My Profile" +msgstr "" + +#: src/view/screens/Settings.tsx:520 +msgid "My Saved Feeds" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:177 +#: src/view/com/modals/CreateOrEditList.tsx:211 +msgid "Name" +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:72 +msgid "Never lose access to your followers and data." +msgstr "" + +#: src/view/screens/Lists.tsx:76 +msgid "New" +msgstr "" + +#: src/view/com/feeds/FeedPage.tsx:188 +#: src/view/screens/Feeds.tsx:510 +#: src/view/screens/Profile.tsx:382 +#: src/view/screens/ProfileFeed.tsx:449 +#: src/view/screens/ProfileList.tsx:199 +#: src/view/screens/ProfileList.tsx:231 +#: src/view/shell/desktop/LeftNav.tsx:254 +msgid "New post" +msgstr "" + +#: src/view/shell/desktop/LeftNav.tsx:264 +msgid "New Post" +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:158 +#: src/view/com/auth/login/ForgotPasswordForm.tsx:174 +#: src/view/com/auth/login/ForgotPasswordForm.tsx:184 +#: src/view/com/auth/login/LoginForm.tsx:283 +#: src/view/com/auth/login/SetNewPasswordForm.tsx:156 +#: src/view/com/auth/login/SetNewPasswordForm.tsx:166 +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:79 +msgid "Next" +msgstr "" + +#: src/view/com/lightbox/Lightbox.web.tsx:142 +msgid "Next image" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:191 +#: src/view/screens/PreferencesHomeFeed.tsx:226 +#: src/view/screens/PreferencesHomeFeed.tsx:263 +msgid "No" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:620 +#: src/view/screens/ProfileList.tsx:632 +msgid "No description" +msgstr "" + +#: src/view/com/composer/text-input/mobile/Autocomplete.tsx:97 +msgid "No result" +msgstr "" + +#: src/view/screens/Feeds.tsx:452 +msgid "No results found for \"{query}\"" +msgstr "" + +#: src/view/com/modals/ListAddUser.tsx:142 +#: src/view/shell/desktop/Search.tsx:112 +#~ msgid "No results found for {0}" +#~ msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:127 +#: src/view/screens/Search/Search.tsx:269 +#: src/view/screens/Search/Search.tsx:326 +#: src/view/screens/Search/Search.tsx:609 +#: src/view/shell/desktop/Search.tsx:209 +msgid "No results found for {query}" +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:136 +#~ msgid "Not Applicable" +#~ msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:135 +msgid "Not Applicable." +msgstr "" + +#: src/view/screens/Notifications.tsx:96 +#: src/view/screens/Notifications.tsx:120 +#: src/view/shell/bottom-bar/BottomBar.tsx:195 +#: src/view/shell/desktop/LeftNav.tsx:363 +#: src/view/shell/Drawer.tsx:298 +#: src/view/shell/Drawer.tsx:299 +msgid "Notifications" +msgstr "" + +#: src/view/com/util/ErrorBoundary.tsx:34 +msgid "Oh no!" +msgstr "" + +#: src/view/com/auth/login/PasswordUpdatedForm.tsx:41 +msgid "Okay" +msgstr "" + +#: src/view/com/composer/Composer.tsx:334 +msgid "One or more images is missing alt text." +msgstr "" + +#: src/view/com/pager/FeedsTabBarMobile.tsx:79 +msgid "Open navigation" +msgstr "" + +#: src/view/screens/Settings.tsx:533 +msgid "Opens configurable language settings" +msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:146 +#: src/view/shell/Drawer.tsx:503 +msgid "Opens list of invite codes" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:279 +msgid "Opens modal for using custom domain" +msgstr "" + +#: src/view/screens/Settings.tsx:558 +msgid "Opens moderation settings" +msgstr "" + +#: src/view/screens/Settings.tsx:514 +msgid "Opens screen with all saved feeds" +msgstr "" + +#: src/view/screens/Settings.tsx:581 +msgid "Opens the app password settings page" +msgstr "" + +#: src/view/screens/Settings.tsx:473 +msgid "Opens the home feed preferences" +msgstr "" + +#: src/view/screens/Settings.tsx:664 +msgid "Opens the storybook page" +msgstr "" + +#: src/view/screens/Settings.tsx:644 +msgid "Opens the system log page" +msgstr "" + +#: src/view/screens/Settings.tsx:494 +msgid "Opens the threads preferences" +msgstr "" + +#: src/view/com/auth/login/ChooseAccountForm.tsx:138 +msgid "Other account" +msgstr "" + +#: src/view/com/modals/ServerInput.tsx:88 +msgid "Other service" +msgstr "" + +#: src/view/com/composer/select-language/SelectLangBtn.tsx:91 +msgid "Other..." +msgstr "" + +#: src/view/screens/NotFound.tsx:42 +#: src/view/screens/NotFound.tsx:45 +msgid "Page not found" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:101 +#: src/view/com/auth/create/Step2.tsx:111 +#: src/view/com/auth/login/LoginForm.tsx:218 +#: src/view/com/auth/login/SetNewPasswordForm.tsx:130 +#: src/view/com/modals/DeleteAccount.tsx:191 +msgid "Password" +msgstr "" + +#: src/view/com/auth/login/Login.tsx:141 +msgid "Password updated" +msgstr "" + +#: src/view/com/auth/login/PasswordUpdatedForm.tsx:28 +msgid "Password updated!" +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:121 +msgid "Pictures meant for adults." +msgstr "" + +#: src/view/screens/SavedFeeds.tsx:89 +msgid "Pinned Feeds" +msgstr "" + +#: src/view/com/auth/create/state.ts:116 +msgid "Please choose your handle." +msgstr "" + +#: src/view/com/auth/create/state.ts:109 +msgid "Please choose your password." +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:67 +msgid "Please confirm your email before changing it. This is a temporary requirement while email-updating tools are added, and it will soon be removed." +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:140 +msgid "Please enter a unique name for this App Password or use our randomly generated one." +msgstr "" + +#: src/view/com/auth/create/state.ts:95 +msgid "Please enter your email." +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:180 +msgid "Please enter your password as well:" +msgstr "" + +#: src/view/com/composer/Composer.tsx:317 +#: src/view/com/post-thread/PostThread.tsx:212 +#: src/view/screens/PostThread.tsx:77 +msgid "Post" +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:366 +msgid "Post hidden" +msgstr "" + +#: src/view/com/composer/select-language/SelectLangBtn.tsx:87 +msgid "Post language" +msgstr "" + +#: src/view/com/modals/lang-settings/PostLanguagesSettings.tsx:75 +msgid "Post Languages" +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:418 +msgid "Post not found" +msgstr "" + +#: src/view/com/modals/LinkWarning.tsx:44 +msgid "Potentially Misleading Link" +msgstr "" + +#: src/view/com/lightbox/Lightbox.web.tsx:128 +msgid "Previous image" +msgstr "" + +#: src/view/screens/LanguageSettings.tsx:183 +msgid "Primary Language" +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:91 +msgid "Prioritize Your Follows" +msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:75 +msgid "Privacy" +msgstr "" + +#: src/view/screens/PrivacyPolicy.tsx:29 +msgid "Privacy Policy" +msgstr "" + +#: src/view/com/auth/login/ForgotPasswordForm.tsx:190 +msgid "Processing..." +msgstr "" + +#: src/view/shell/bottom-bar/BottomBar.tsx:237 +#: src/view/shell/Drawer.tsx:72 +#: src/view/shell/Drawer.tsx:366 +#: src/view/shell/Drawer.tsx:367 +msgid "Profile" +msgstr "" + +#: src/view/screens/Settings.tsx:789 +msgid "Protect your account by verifying your email." +msgstr "" + +#: src/view/screens/Lists.tsx:61 +msgid "Public, shareable lists which can drive feeds." +msgstr "" + +#: src/view/com/modals/Repost.tsx:52 +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:58 +msgid "Quote post" +msgstr "" + +#: src/view/com/modals/Repost.tsx:56 +msgid "Quote Post" +msgstr "" + +#: src/view/com/modals/EditImage.tsx:236 +msgid "Ratios" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:73 +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:50 +#~ msgid "Recommended" +#~ msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:116 +msgid "Recommended Feeds" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:180 +msgid "Recommended Users" +msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:264 +#: src/view/com/modals/SelfLabel.tsx:83 +#: src/view/com/modals/UserAddRemoveLists.tsx:187 +#: src/view/com/util/UserAvatar.tsx:278 +#: src/view/com/util/UserBanner.tsx:89 +msgid "Remove" +msgstr "" + +#: src/view/com/feeds/FeedSourceCard.tsx:108 +msgid "Remove {0} from my feeds?" +msgstr "" + +#: src/view/com/util/AccountDropdownBtn.tsx:22 +msgid "Remove account" +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:118 +msgid "Remove feed" +msgstr "" + +#: src/view/com/feeds/FeedSourceCard.tsx:107 +#: src/view/screens/ProfileFeed.tsx:279 +msgid "Remove from my feeds" +msgstr "" + +#: src/view/com/composer/photos/Gallery.tsx:167 +msgid "Remove image" +msgstr "" + +#: src/view/com/composer/ExternalEmbed.tsx:70 +msgid "Remove image preview" +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:119 +msgid "Remove this feed from your saved feeds?" +msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:199 +#: src/view/com/modals/UserAddRemoveLists.tsx:130 +msgid "Removed from list" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:135 +msgid "Reply Filters" +msgstr "" + +#: src/view/com/modals/report/Modal.tsx:166 +msgid "Report {collectionName}" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:378 +msgid "Report Account" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:299 +msgid "Report feed" +msgstr "" + +#: src/view/screens/ProfileList.tsx:416 +msgid "Report List" +msgstr "" + +#: src/view/com/modals/report/SendReportButton.tsx:37 +#: src/view/com/util/forms/PostDropdownBtn.tsx:162 +msgid "Report post" +msgstr "" + +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:48 +msgid "Repost" +msgstr "" + +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:94 +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:105 +msgid "Repost or quote post" +msgstr "" + +#: src/view/screens/PostRepostedBy.tsx:27 +msgid "Reposted by" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:181 +#: src/view/com/modals/ChangeEmail.tsx:183 +msgid "Request Change" +msgstr "" + +#: src/view/screens/Settings.tsx:382 +#~ msgid "Require alt text before posting" +#~ msgstr "" + +#: src/view/com/auth/create/Step2.tsx:53 +msgid "Required for this provider" +msgstr "" + +#: src/view/com/auth/login/SetNewPasswordForm.tsx:108 +msgid "Reset code" +msgstr "" + +#: src/view/screens/Settings.tsx:686 +msgid "Reset onboarding state" +msgstr "" + +#: src/view/com/auth/login/ForgotPasswordForm.tsx:98 +msgid "Reset password" +msgstr "" + +#: src/view/screens/Settings.tsx:676 +msgid "Reset preferences state" +msgstr "" + +#: src/view/screens/Settings.tsx:684 +msgid "Resets the onboarding state" +msgstr "" + +#: src/view/screens/Settings.tsx:674 +msgid "Resets the preferences state" +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:167 +#: src/view/com/auth/create/CreateAccount.tsx:171 +#: src/view/com/auth/login/LoginForm.tsx:260 +#: src/view/com/auth/login/LoginForm.tsx:263 +#: src/view/com/util/error/ErrorMessage.tsx:55 +#: src/view/com/util/error/ErrorScreen.tsx:65 +msgid "Retry" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:169 +#~ msgid "Retry change handle" +#~ msgstr "" + +#: src/view/com/modals/AltImage.tsx:114 +#: src/view/com/modals/BirthDateSettings.tsx:93 +#: src/view/com/modals/BirthDateSettings.tsx:96 +#: src/view/com/modals/ChangeHandle.tsx:173 +#: src/view/com/modals/CreateOrEditList.tsx:249 +#: src/view/com/modals/CreateOrEditList.tsx:257 +#: src/view/com/modals/EditProfile.tsx:223 +msgid "Save" +msgstr "" + +#: src/view/com/modals/AltImage.tsx:105 +msgid "Save alt text" +msgstr "" + +#: src/view/com/modals/UserAddRemoveLists.tsx:212 +#~ msgid "Save changes" +#~ msgstr "" + +#: src/view/com/modals/EditProfile.tsx:231 +msgid "Save Changes" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:170 +msgid "Save handle change" +msgstr "" + +#: src/view/com/modals/crop-image/CropImage.web.tsx:144 +msgid "Save image crop" +msgstr "" + +#: src/view/screens/SavedFeeds.tsx:122 +msgid "Saved Feeds" +msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:75 +#: src/view/com/util/forms/SearchInput.tsx:64 +#: src/view/screens/Search/Search.tsx:409 +#: src/view/screens/Search/Search.tsx:561 +#: src/view/shell/bottom-bar/BottomBar.tsx:146 +#: src/view/shell/desktop/LeftNav.tsx:323 +#: src/view/shell/desktop/Search.tsx:160 +#: src/view/shell/desktop/Search.tsx:169 +#: src/view/shell/Drawer.tsx:252 +#: src/view/shell/Drawer.tsx:253 +msgid "Search" +msgstr "" + +#: src/view/screens/Search/Search.tsx:418 +msgid "Search for posts and users." +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:110 +msgid "Security Step Required" +msgstr "" + +#: src/view/com/auth/SplashScreen.tsx:29 +msgid "See what's next" +msgstr "" + +#: src/view/com/modals/ServerInput.tsx:75 +msgid "Select Bluesky Social" +msgstr "" + +#: src/view/com/auth/login/Login.tsx:101 +msgid "Select from an existing account" +msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:145 +msgid "Select service" +msgstr "" + +#: src/view/screens/LanguageSettings.tsx:276 +msgid "Select which languages you want your subscribed feeds to include. If none are selected, all languages will be shown." +msgstr "" + +#: src/view/screens/LanguageSettings.tsx:95 +msgid "Select your app language for the default text to display in the app" +msgstr "" + +#: src/view/screens/LanguageSettings.tsx:186 +msgid "Select your preferred language for translations in your feed." +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:188 +msgid "Send Confirmation Email" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:127 +msgid "Send email" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:138 +msgid "Send Email" +msgstr "" + +#: src/view/shell/Drawer.tsx:394 +#: src/view/shell/Drawer.tsx:415 +msgid "Send feedback" +msgstr "" + +#: src/view/com/modals/report/SendReportButton.tsx:45 +msgid "Send Report" +msgstr "" + +#: src/view/com/auth/login/SetNewPasswordForm.tsx:78 +msgid "Set new password" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:216 +msgid "Set this setting to \"No\" to hide all quote posts from your feed. Reposts will still be visible." +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:113 +msgid "Set this setting to \"No\" to hide all replies from your feed." +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:182 +msgid "Set this setting to \"No\" to hide all reposts from your feed." +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:116 +msgid "Set this setting to \"Yes\" to show replies in a threaded view. This is an experimental feature." +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:252 +msgid "Set this setting to \"Yes\" to show samples of your saved feeds in your following feed. This is an experimental feature." +msgstr "" + +#: src/view/screens/Settings.tsx:277 +#: src/view/shell/desktop/LeftNav.tsx:435 +#: src/view/shell/Drawer.tsx:379 +#: src/view/shell/Drawer.tsx:380 +msgid "Settings" +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:125 +msgid "Sexual activity or erotic nudity." +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:312 +#: src/view/com/util/forms/PostDropdownBtn.tsx:126 +#: src/view/screens/ProfileList.tsx:375 +msgid "Share" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:311 +msgid "Share feed" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:276 +#~ msgid "Share link" +#~ msgstr "" + +#: src/view/screens/Settings.tsx:316 +msgid "Show" +msgstr "" + +#: src/view/com/util/moderation/ScreenHider.tsx:114 +msgid "Show anyway" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:249 +msgid "Show Posts from My Feeds" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:213 +msgid "Show Quote Posts" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:110 +msgid "Show Replies" +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:94 +msgid "Show replies by people you follow before all other replies." +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:179 +msgid "Show Reposts" +msgstr "" + +#: src/view/com/notifications/FeedItem.tsx:337 +msgid "Show users" +msgstr "" + +#: src/view/com/auth/login/Login.tsx:82 +#: src/view/com/auth/SplashScreen.tsx:49 +#: src/view/shell/NavSignupCard.tsx:52 +#: src/view/shell/NavSignupCard.tsx:53 +msgid "Sign in" +msgstr "" + +#: src/view/com/auth/SplashScreen.tsx:52 +#: src/view/com/auth/SplashScreen.web.tsx:84 +msgid "Sign In" +msgstr "" + +#: src/view/com/auth/login/ChooseAccountForm.tsx:44 +msgid "Sign in as {0}" +msgstr "" + +#: src/view/com/auth/login/ChooseAccountForm.tsx:118 +#: src/view/com/auth/login/Login.tsx:100 +msgid "Sign in as..." +msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:132 +msgid "Sign into" +msgstr "" + +#: src/view/com/modals/SwitchAccount.tsx:60 +#: src/view/com/modals/SwitchAccount.tsx:63 +msgid "Sign out" +msgstr "" + +#: src/view/shell/NavSignupCard.tsx:43 +#: src/view/shell/NavSignupCard.tsx:44 +#: src/view/shell/NavSignupCard.tsx:46 +msgid "Sign up" +msgstr "" + +#: src/view/shell/NavSignupCard.tsx:36 +msgid "Sign up or sign in to join the conversation" +msgstr "" + +#: src/view/screens/Settings.tsx:327 +msgid "Signed in as" +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:33 +msgid "Skip" +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:69 +msgid "Sort Replies" +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:72 +msgid "Sort replies to the same post by:" +msgstr "" + +#: src/view/com/modals/crop-image/CropImage.web.tsx:122 +msgid "Square" +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:90 +#: src/view/com/modals/ServerInput.tsx:62 +msgid "Staging" +msgstr "" + +#: src/view/screens/Settings.tsx:730 +msgid "Status page" +msgstr "" + +#: src/view/screens/Settings.tsx:666 +msgid "Storybook" +msgstr "" + +#: src/view/screens/ProfileList.tsx:497 +msgid "Subscribe" +msgstr "" + +#: src/view/screens/ProfileList.tsx:493 +msgid "Subscribe to this list" +msgstr "" + +#: src/view/screens/Search/Search.tsx:382 +msgid "Suggested Follows" +msgstr "" + +#: src/view/screens/Support.tsx:30 +#: src/view/screens/Support.tsx:33 +msgid "Support" +msgstr "" + +#: src/view/com/modals/SwitchAccount.tsx:111 +msgid "Switch Account" +msgstr "" + +#: src/view/screens/Settings.tsx:398 +#~ msgid "System" +#~ msgstr "" + +#: src/view/screens/Settings.tsx:646 +msgid "System log" +msgstr "" + +#: src/view/com/modals/crop-image/CropImage.web.tsx:112 +msgid "Tall" +msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:84 +msgid "Terms" +msgstr "" + +#: src/view/screens/TermsOfService.tsx:29 +msgid "Terms of Service" +msgstr "" + +#: src/view/com/modals/report/InputIssueDetails.tsx:50 +msgid "Text input field" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:280 +msgid "The account will be able to interact with you after unblocking." +msgstr "" + +#: src/view/screens/CommunityGuidelines.tsx:36 +msgid "The Community Guidelines have been moved to <0/>" +msgstr "" + +#: src/view/screens/CopyrightPolicy.tsx:33 +msgid "The Copyright Policy has been moved to <0/>" +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:421 +msgid "The post may have been deleted." +msgstr "" + +#: src/view/screens/PrivacyPolicy.tsx:33 +msgid "The Privacy Policy has been moved to <0/>" +msgstr "" + +#: src/view/screens/Support.tsx:36 +msgid "The support form has been moved. If you need help, please<0/> or visit {HELP_DESK_URL} to get in touch with us." +msgstr "" + +#: src/view/screens/TermsOfService.tsx:33 +msgid "The Terms of Service have been moved to" +msgstr "" + +#: src/view/com/util/ErrorBoundary.tsx:35 +msgid "There was an unexpected issue in the application. Please let us know if this happened to you!" +msgstr "" + +#: src/view/com/util/moderation/ScreenHider.tsx:72 +msgid "This {screenDescription} has been flagged:" +msgstr "" + +#: src/view/com/modals/BirthDateSettings.tsx:61 +msgid "This information is not shared with other users." +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:105 +msgid "This is important in case you ever need to change your email or reset your password." +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:55 +msgid "This is the service that keeps you online." +msgstr "" + +#: src/view/com/modals/LinkWarning.tsx:56 +msgid "This link is taking you to the following website:" +msgstr "" + +#: src/view/com/post-thread/PostThreadItem.tsx:114 +msgid "This post has been deleted." +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:137 +msgid "This warning is only available for posts with media attached." +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:53 +#: src/view/screens/Settings.tsx:503 +msgid "Thread Preferences" +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:113 +msgid "Threaded Mode" +msgstr "" + +#: src/view/com/util/forms/DropdownButton.tsx:230 +msgid "Toggle dropdown" +msgstr "" + +#: src/view/com/modals/EditImage.tsx:271 +msgid "Transformations" +msgstr "" + +#: src/view/com/post-thread/PostThreadItem.tsx:646 +#: src/view/com/post-thread/PostThreadItem.tsx:648 +#: src/view/com/util/forms/PostDropdownBtn.tsx:98 +msgid "Translate" +msgstr "" + +#: src/view/com/util/error/ErrorScreen.tsx:73 +msgid "Try again" +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:64 +#: src/view/com/auth/login/Login.tsx:60 +#: src/view/com/auth/login/LoginForm.tsx:117 +msgid "Unable to contact your service. Please check your Internet connection." +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:438 +#: src/view/com/profile/ProfileHeader.tsx:441 +msgid "Unblock" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:278 +#: src/view/com/profile/ProfileHeader.tsx:362 +msgid "Unblock Account" +msgstr "" + +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:48 +msgid "Undo repost" +msgstr "" + +#: src/view/com/auth/create/state.ts:210 +msgid "Unfortunately, you do not meet the requirements to create an account." +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:343 +msgid "Unmute Account" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:144 +msgid "Unmute thread" +msgstr "" + +#: src/view/com/modals/UserAddRemoveLists.tsx:52 +msgid "Update {displayName} in Lists" +msgstr "" + +#: src/lib/hooks/useOTAUpdate.ts:15 +msgid "Update Available" +msgstr "" + +#: src/view/com/auth/login/SetNewPasswordForm.tsx:172 +msgid "Updating..." +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:453 +msgid "Upload a text file to:" +msgstr "" + +#: src/view/screens/AppPasswords.tsx:194 +msgid "Use app passwords to login to other Bluesky clients without giving full access to your account or password." +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:513 +msgid "Use default provider" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:150 +msgid "Use this to sign into the other app along with your handle." +msgstr "" + +#: src/view/com/modals/InviteCodes.tsx:196 +msgid "Used by:" +msgstr "" + +#: src/view/com/auth/create/Step3.tsx:38 +msgid "User handle" +msgstr "" + +#: src/view/screens/Lists.tsx:58 +msgid "User Lists" +msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:172 +#: src/view/com/auth/login/LoginForm.tsx:189 +msgid "Username or email address" +msgstr "" + +#: src/view/screens/ProfileList.tsx:659 +msgid "Users" +msgstr "" + +#: src/view/screens/Settings.tsx:750 +msgid "Verify email" +msgstr "" + +#: src/view/screens/Settings.tsx:775 +msgid "Verify my email" +msgstr "" + +#: src/view/screens/Settings.tsx:784 +msgid "Verify My Email" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:205 +#: src/view/com/modals/ChangeEmail.tsx:207 +msgid "Verify New Email" +msgstr "" + +#: src/view/screens/Log.tsx:52 +msgid "View debug entry" +msgstr "" + +#: src/view/com/profile/ProfileSubpageHeader.tsx:128 +msgid "View the avatar" +msgstr "" + +#: src/view/com/modals/LinkWarning.tsx:73 +msgid "Visit Site" +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:125 +msgid "We're so excited to have you join us!" +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:98 +msgid "We're sorry, but this content is not viewable without a Bluesky account." +msgstr "" + +#: src/view/screens/Search/Search.tsx:236 +msgid "We're sorry, but your search could not be completed. Please try again in a few minutes." +msgstr "" + +#: src/view/screens/NotFound.tsx:48 +msgid "We're sorry! We can't find the page you were looking for." +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:46 +msgid "Welcome to <0>Bluesky</0>" +msgstr "" + +#: src/view/com/modals/report/Modal.tsx:169 +msgid "What is the issue with this {collectionName}?" +msgstr "" + +#: src/view/com/modals/lang-settings/PostLanguagesSettings.tsx:78 +msgid "Which languages are used in this post?" +msgstr "" + +#: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:77 +msgid "Which languages would you like to see in your algorithmic feeds?" +msgstr "" + +#: src/view/com/modals/crop-image/CropImage.web.tsx:102 +msgid "Wide" +msgstr "" + +#: src/view/com/composer/Composer.tsx:389 +msgid "Write post" +msgstr "" + +#: src/view/com/composer/Prompt.tsx:33 +msgid "Write your reply" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:192 +#: src/view/screens/PreferencesHomeFeed.tsx:227 +#: src/view/screens/PreferencesHomeFeed.tsx:262 +msgid "Yes" +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:106 +msgid "You can change hosting providers at any time." +msgstr "" + +#: src/view/com/auth/login/Login.tsx:142 +#: src/view/com/auth/login/PasswordUpdatedForm.tsx:31 +msgid "You can now sign in with your new password." +msgstr "" + +#: src/view/com/modals/InviteCodes.tsx:64 +msgid "You don't have any invite codes yet! We'll send you some when you've been on Bluesky for a little longer." +msgstr "" + +#: src/view/screens/SavedFeeds.tsx:102 +msgid "You don't have any pinned feeds." +msgstr "" + +#: src/view/screens/Feeds.tsx:383 +msgid "You don't have any saved feeds!" +msgstr "" + +#: src/view/screens/SavedFeeds.tsx:135 +msgid "You don't have any saved feeds." +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:369 +msgid "You have blocked the author or you have been blocked by the author." +msgstr "" + +#: src/view/com/feeds/ProfileFeedgens.tsx:150 +msgid "You have no feeds." +msgstr "" + +#: src/view/com/lists/MyLists.tsx:88 +#: src/view/com/lists/ProfileLists.tsx:154 +msgid "You have no lists." +msgstr "" + +#: src/view/screens/ModerationBlockedAccounts.tsx:131 +msgid "You have not blocked any accounts yet. To block an account, go to their profile and selected \"Block account\" from the menu on their account." +msgstr "" + +#: src/view/screens/AppPasswords.tsx:86 +msgid "You have not created any app passwords yet. You can create one by pressing the button below." +msgstr "" + +#: src/view/screens/ModerationMutedAccounts.tsx:130 +msgid "You have not muted any accounts yet. To mute an account, go to their profile and selected \"Mute account\" from the menu on their account." +msgstr "" + +#: src/view/com/auth/login/SetNewPasswordForm.tsx:81 +msgid "You will receive an email with a \"reset code.\" Enter that code here, then enter your new password." +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:43 +msgid "Your account" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:122 +msgid "Your birth date" +msgstr "" + +#: src/view/com/auth/create/state.ts:102 +msgid "Your email appears to be invalid." +msgstr "" + +#: src/view/com/modals/Waitlist.tsx:107 +msgid "Your email has been saved! We'll be in touch soon." +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:125 +msgid "Your email has been updated but not verified. As a next step, please verify your new email." +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:100 +msgid "Your email has not yet been verified. This is an important security step which we recommend." +msgstr "" + +#: src/view/com/auth/create/Step3.tsx:42 +#: src/view/com/modals/ChangeHandle.tsx:270 +msgid "Your full handle will be" +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:53 +msgid "Your hosting provider" +msgstr "" + +#: src/view/screens/Settings.tsx:402 +#: src/view/shell/desktop/RightNav.tsx:127 +#: src/view/shell/Drawer.tsx:517 +msgid "Your invite codes are hidden when logged in using an App Password" +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:59 +msgid "Your posts, likes, and blocks are public. Mutes are private." +msgstr "" + +#: src/view/com/modals/SwitchAccount.tsx:78 +msgid "Your profile" +msgstr "" + +#: src/view/com/auth/create/Step3.tsx:28 +msgid "Your user handle" +msgstr "" diff --git a/src/locale/locales/fr/messages.po b/src/locale/locales/fr/messages.po new file mode 100644 index 000000000..95b10a094 --- /dev/null +++ b/src/locale/locales/fr/messages.po @@ -0,0 +1,2297 @@ +msgid "" +msgstr "" +"POT-Creation-Date: 2023-11-05 16:01-0800\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: @lingui/cli\n" +"Language: fr\n" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Plural-Forms: \n" + +#: src/view/screens/Profile.tsx:214 +#~ msgid "- end of feed -" +#~ msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:138 +#~ msgid ". This warning is only available for posts with media attached." +#~ msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:158 +msgid "{0, plural, one {# invite code available} other {# invite codes available}}" +msgstr "" + +#: src/view/com/modals/Repost.tsx:44 +msgid "{0}" +msgstr "" + +#: src/view/com/modals/CreateOrEditList.tsx:176 +msgid "{0} {purposeLabel} List" +msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:141 +msgid "{invitesAvailable, plural, one {Invite codes: # available} other {Invite codes: # available}}" +msgstr "" + +#: src/view/screens/Settings.tsx:407 +#: src/view/shell/Drawer.tsx:521 +msgid "{invitesAvailable} invite code available" +msgstr "" + +#: src/view/screens/Settings.tsx:409 +#: src/view/shell/Drawer.tsx:523 +msgid "{invitesAvailable} invite codes available" +msgstr "" + +#: src/view/screens/Search/Search.tsx:86 +msgid "{message}" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:30 +msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:37 +msgid "<0>Follow some</0><1>Recommended</1><2>Users</2>" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:132 +#~ msgid "<0>Here is your app password.</0> Use this to sign into the other app along with your handle." +#~ msgstr "" + +#: src/lib/hooks/useOTAUpdate.ts:16 +msgid "A new version of the app is available. Please update to continue using the app." +msgstr "" + +#: src/view/com/modals/EditImage.tsx:299 +#: src/view/screens/Settings.tsx:417 +msgid "Accessibility" +msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:161 +#: src/view/screens/Settings.tsx:286 +msgid "Account" +msgstr "" + +#: src/view/com/util/AccountDropdownBtn.tsx:41 +msgid "Account options" +msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:264 +#: src/view/com/modals/UserAddRemoveLists.tsx:187 +#: src/view/screens/ProfileList.tsx:675 +msgid "Add" +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:56 +msgid "Add a content warning" +msgstr "" + +#: src/view/screens/ProfileList.tsx:665 +msgid "Add a user to this list" +msgstr "" + +#: src/view/screens/Settings.tsx:355 +#: src/view/screens/Settings.tsx:364 +msgid "Add account" +msgstr "" + +#: src/view/com/composer/photos/Gallery.tsx:119 +#: src/view/com/composer/photos/Gallery.tsx:180 +msgid "Add alt text" +msgstr "" + +#: src/view/com/modals/report/InputIssueDetails.tsx:41 +#: src/view/com/modals/report/Modal.tsx:191 +msgid "Add details" +msgstr "" + +#: src/view/com/modals/report/Modal.tsx:194 +msgid "Add details to report" +msgstr "" + +#: src/view/com/composer/Composer.tsx:418 +msgid "Add link card" +msgstr "" + +#: src/view/com/composer/Composer.tsx:421 +msgid "Add link card:" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:415 +msgid "Add the following DNS record to your domain:" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:327 +msgid "Add to Lists" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:279 +msgid "Add to my feeds" +msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:191 +#: src/view/com/modals/UserAddRemoveLists.tsx:122 +msgid "Added to list" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:164 +msgid "Adjust the number of likes a reply must have to be shown in your feed." +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:75 +msgid "Adult Content" +msgstr "" + +#: src/view/screens/Settings.tsx:569 +msgid "Advanced" +msgstr "" + +#: src/view/com/composer/photos/Gallery.tsx:130 +msgid "ALT" +msgstr "" + +#: src/view/com/modals/EditImage.tsx:315 +msgid "Alt text" +msgstr "" + +#: src/view/com/composer/photos/Gallery.tsx:209 +msgid "Alt text describes images for blind and low-vision users, and helps give context to everyone." +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:110 +msgid "An email has been sent to {0}. It includes a confirmation code which you can enter below." +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:119 +msgid "An email has been sent to your previous address, {0}. It includes a confirmation code which you can enter below." +msgstr "" + +#: src/view/com/notifications/FeedItem.tsx:236 +msgid "and" +msgstr "" + +#: src/view/screens/LanguageSettings.tsx:92 +msgid "App Language" +msgstr "" + +#: src/view/screens/Settings.tsx:589 +msgid "App passwords" +msgstr "" + +#: src/view/screens/AppPasswords.tsx:186 +msgid "App Passwords" +msgstr "" + +#: src/view/screens/Settings.tsx:432 +msgid "Appearance" +msgstr "" + +#: src/view/screens/AppPasswords.tsx:223 +msgid "Are you sure you want to delete the app password \"{name}\"?" +msgstr "" + +#: src/view/com/composer/Composer.tsx:137 +msgid "Are you sure you'd like to discard this draft?" +msgstr "" + +#: src/view/screens/ProfileList.tsx:345 +msgid "Are you sure?" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:188 +msgid "Are you sure? This cannot be undone." +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:123 +msgid "Artistic or non-erotic nudity." +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:145 +#: src/view/com/auth/login/ChooseAccountForm.tsx:151 +#: src/view/com/auth/login/ForgotPasswordForm.tsx:166 +#: src/view/com/auth/login/LoginForm.tsx:251 +#: src/view/com/auth/login/SetNewPasswordForm.tsx:148 +#: src/view/com/modals/report/InputIssueDetails.tsx:45 +#: src/view/com/post-thread/PostThread.tsx:376 +#: src/view/com/post-thread/PostThread.tsx:426 +#: src/view/com/post-thread/PostThread.tsx:434 +#: src/view/com/profile/ProfileHeader.tsx:633 +msgid "Back" +msgstr "" + +#: src/view/screens/Settings.tsx:461 +msgid "Basics" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:131 +#: src/view/com/modals/BirthDateSettings.tsx:72 +msgid "Birthday" +msgstr "" + +#: src/view/screens/Settings.tsx:312 +msgid "Birthday:" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:256 +#: src/view/com/profile/ProfileHeader.tsx:363 +msgid "Block Account" +msgstr "" + +#: src/view/screens/ProfileList.tsx:446 +msgid "Block accounts" +msgstr "" + +#: src/view/screens/ProfileList.tsx:302 +msgid "Block these accounts?" +msgstr "" + +#: src/view/screens/Moderation.tsx:109 +msgid "Blocked accounts" +msgstr "" + +#: src/view/screens/ModerationBlockedAccounts.tsx:106 +msgid "Blocked Accounts" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:258 +msgid "Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you." +msgstr "" + +#: src/view/screens/ModerationBlockedAccounts.tsx:114 +msgid "Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you. You will not see their content and they will be prevented from seeing yours." +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:237 +msgid "Blocked post." +msgstr "" + +#: src/view/screens/ProfileList.tsx:304 +msgid "Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you." +msgstr "" + +#: src/view/com/auth/SplashScreen.tsx:26 +msgid "Bluesky" +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:80 +msgid "Bluesky is flexible." +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:69 +msgid "Bluesky is open." +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:56 +msgid "Bluesky is public." +msgstr "" + +#: src/view/com/modals/Waitlist.tsx:70 +msgid "Bluesky uses invites to build a healthier community. If you don't know anybody with an invite, you can sign up for the waitlist and we'll send one soon." +msgstr "" + +#: src/view/com/modals/ServerInput.tsx:78 +msgid "Bluesky.Social" +msgstr "" + +#: src/view/screens/Settings.tsx:718 +msgid "Build version {0} {1}" +msgstr "" + +#: src/view/com/composer/photos/OpenCameraBtn.tsx:60 +#: src/view/com/util/UserAvatar.tsx:217 +#: src/view/com/util/UserBanner.tsx:38 +msgid "Camera" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:214 +msgid "Can only contain letters, numbers, spaces, dashes, and underscores. Must be at least 4 characters long, but no more than 32 characters long." +msgstr "" + +#: src/view/com/composer/Composer.tsx:271 +#: src/view/com/composer/Composer.tsx:274 +#: src/view/com/modals/AltImage.tsx:127 +#: src/view/com/modals/ChangeEmail.tsx:218 +#: src/view/com/modals/ChangeEmail.tsx:220 +#: src/view/com/modals/Confirm.tsx:88 +#: src/view/com/modals/CreateOrEditList.tsx:267 +#: src/view/com/modals/CreateOrEditList.tsx:272 +#: src/view/com/modals/DeleteAccount.tsx:150 +#: src/view/com/modals/DeleteAccount.tsx:223 +#: src/view/com/modals/EditImage.tsx:323 +#: src/view/com/modals/EditProfile.tsx:248 +#: src/view/com/modals/LinkWarning.tsx:85 +#: src/view/com/modals/Repost.tsx:73 +#: src/view/com/modals/Waitlist.tsx:136 +#: src/view/screens/Search/Search.tsx:586 +#: src/view/shell/desktop/Search.tsx:181 +msgid "Cancel" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:146 +#: src/view/com/modals/DeleteAccount.tsx:219 +msgid "Cancel account deletion" +msgstr "" + +#: src/view/com/modals/AltImage.tsx:122 +msgid "Cancel add image alt text" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:149 +msgid "Cancel change handle" +msgstr "" + +#: src/view/com/modals/crop-image/CropImage.web.tsx:134 +msgid "Cancel image crop" +msgstr "" + +#: src/view/com/modals/EditProfile.tsx:243 +msgid "Cancel profile editing" +msgstr "" + +#: src/view/com/modals/Repost.tsx:64 +msgid "Cancel quote post" +msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:87 +#: src/view/shell/desktop/Search.tsx:177 +msgid "Cancel search" +msgstr "" + +#: src/view/com/modals/Waitlist.tsx:132 +msgid "Cancel waitlist signup" +msgstr "" + +#: src/view/screens/Settings.tsx:306 +msgid "Change" +msgstr "" + +#: src/view/screens/Settings.tsx:601 +#: src/view/screens/Settings.tsx:610 +msgid "Change handle" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:161 +msgid "Change Handle" +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:133 +msgid "Change my email" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:109 +msgid "Change Your Email" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:121 +msgid "Check out some recommended feeds. Tap + to add them to your list of pinned feeds." +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:185 +msgid "Check out some recommended users. Follow them to see similar users." +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:163 +msgid "Check your inbox for an email with the confirmation code to enter below:" +msgstr "" + +#: src/view/com/modals/ServerInput.tsx:38 +msgid "Choose Service" +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:83 +msgid "Choose the algorithms that power your experience with custom feeds." +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:65 +#~ msgid "Choose your" +#~ msgstr "" + +#: src/view/com/auth/create/Step2.tsx:106 +msgid "Choose your password" +msgstr "" + +#: src/view/screens/Settings.tsx:694 +msgid "Clear all legacy storage data" +msgstr "" + +#: src/view/screens/Settings.tsx:696 +msgid "Clear all legacy storage data (restart after this)" +msgstr "" + +#: src/view/screens/Settings.tsx:706 +msgid "Clear all storage data" +msgstr "" + +#: src/view/screens/Settings.tsx:708 +msgid "Clear all storage data (restart after this)" +msgstr "" + +#: src/view/com/util/forms/SearchInput.tsx:73 +#: src/view/screens/Search/Search.tsx:571 +msgid "Clear search query" +msgstr "" + +#: src/view/com/auth/login/PasswordUpdatedForm.tsx:38 +msgid "Close alert" +msgstr "" + +#: src/view/com/util/BottomSheetCustomBackdrop.tsx:33 +msgid "Close bottom drawer" +msgstr "" + +#: src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx:26 +msgid "Close image" +msgstr "" + +#: src/view/com/lightbox/Lightbox.web.tsx:112 +msgid "Close image viewer" +msgstr "" + +#: src/view/shell/index.web.tsx:49 +msgid "Close navigation footer" +msgstr "" + +#: src/view/screens/CommunityGuidelines.tsx:32 +msgid "Community Guidelines" +msgstr "" + +#: src/view/com/composer/Prompt.tsx:24 +msgid "Compose reply" +msgstr "" + +#: src/view/com/modals/Confirm.tsx:75 +#: src/view/com/modals/SelfLabel.tsx:154 +#: src/view/com/modals/VerifyEmail.tsx:217 +#: src/view/screens/PreferencesHomeFeed.tsx:299 +#: src/view/screens/PreferencesThreads.tsx:153 +msgid "Confirm" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:193 +#: src/view/com/modals/ChangeEmail.tsx:195 +msgid "Confirm Change" +msgstr "" + +#: src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx:34 +msgid "Confirm content language settings" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:209 +msgid "Confirm delete account" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:157 +#: src/view/com/modals/DeleteAccount.tsx:176 +#: src/view/com/modals/VerifyEmail.tsx:151 +msgid "Confirmation code" +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:178 +#: src/view/com/auth/login/LoginForm.tsx:270 +msgid "Connecting..." +msgstr "" + +#: src/view/screens/Moderation.tsx:67 +msgid "Content filtering" +msgstr "" + +#: src/view/com/modals/ContentFilteringSettings.tsx:44 +msgid "Content Filtering" +msgstr "" + +#: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:74 +#: src/view/screens/LanguageSettings.tsx:273 +msgid "Content Languages" +msgstr "" + +#: src/view/com/util/moderation/ScreenHider.tsx:69 +msgid "Content Warning" +msgstr "" + +#: src/view/com/composer/labels/LabelsBtn.tsx:31 +msgid "Content warnings" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:148 +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:209 +msgid "Continue" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:193 +#: src/view/com/modals/InviteCodes.tsx:178 +msgid "Copied" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:186 +msgid "Copy" +msgstr "" + +#: src/view/screens/ProfileList.tsx:375 +msgid "Copy link to list" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:126 +msgid "Copy link to post" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:312 +msgid "Copy link to profile" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:112 +msgid "Copy post text" +msgstr "" + +#: src/view/screens/CopyrightPolicy.tsx:29 +msgid "Copyright Policy" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:102 +msgid "Could not load feed" +msgstr "" + +#: src/view/screens/ProfileList.tsx:752 +msgid "Could not load list" +msgstr "" + +#: src/view/com/auth/SplashScreen.tsx:41 +msgid "Create a new account" +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:124 +msgid "Create Account" +msgstr "" + +#: src/view/com/auth/SplashScreen.tsx:38 +msgid "Create new account" +msgstr "" + +#: src/view/screens/AppPasswords.tsx:248 +msgid "Created {0}" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:387 +#: src/view/com/modals/ServerInput.tsx:102 +msgid "Custom domain" +msgstr "" + +#: src/view/screens/Settings.tsx:615 +msgid "Danger Zone" +msgstr "" + +#: src/view/screens/Settings.tsx:411 +#~ msgid "Dark" +#~ msgstr "" + +#: src/view/screens/Settings.tsx:622 +msgid "Delete account" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:83 +msgid "Delete Account" +msgstr "" + +#: src/view/screens/AppPasswords.tsx:221 +#: src/view/screens/AppPasswords.tsx:241 +msgid "Delete app password" +msgstr "" + +#: src/view/screens/ProfileList.tsx:344 +#: src/view/screens/ProfileList.tsx:402 +msgid "Delete List" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:212 +msgid "Delete my account" +msgstr "" + +#: src/view/screens/Settings.tsx:632 +msgid "Delete my account…" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:183 +msgid "Delete post" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:187 +msgid "Delete this post?" +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:229 +msgid "Deleted post." +msgstr "" + +#: src/view/com/modals/CreateOrEditList.tsx:218 +#: src/view/com/modals/CreateOrEditList.tsx:234 +#: src/view/com/modals/EditProfile.tsx:197 +#: src/view/com/modals/EditProfile.tsx:209 +msgid "Description" +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:96 +msgid "Dev Server" +msgstr "" + +#: src/view/screens/Settings.tsx:637 +msgid "Developer Tools" +msgstr "" + +#: src/view/com/composer/Composer.tsx:138 +msgid "Discard" +msgstr "" + +#: src/view/com/composer/Composer.tsx:132 +msgid "Discard draft" +msgstr "" + +#: src/view/screens/Feeds.tsx:405 +msgid "Discover new feeds" +msgstr "" + +#: src/view/com/modals/EditProfile.tsx:191 +msgid "Display name" +msgstr "" + +#: src/view/com/modals/EditProfile.tsx:179 +msgid "Display Name" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:485 +msgid "Domain verified!" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:86 +#: src/view/com/modals/ContentFilteringSettings.tsx:88 +#: src/view/com/modals/ContentFilteringSettings.tsx:96 +#: src/view/com/modals/crop-image/CropImage.web.tsx:152 +#: src/view/com/modals/EditImage.tsx:333 +#: src/view/com/modals/ListAddRemoveUsers.tsx:142 +#: src/view/com/modals/SelfLabel.tsx:157 +#: src/view/com/modals/UserAddRemoveLists.tsx:75 +#: src/view/screens/PreferencesHomeFeed.tsx:302 +#: src/view/screens/PreferencesThreads.tsx:156 +msgid "Done" +msgstr "" + +#: src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx:42 +msgid "Done{extraText}" +msgstr "" + +#: src/view/com/modals/InviteCodes.tsx:94 +msgid "Each code works once. You'll receive more invite codes periodically." +msgstr "" + +#: src/view/com/composer/photos/Gallery.tsx:144 +#: src/view/com/modals/EditImage.tsx:207 +msgid "Edit image" +msgstr "" + +#: src/view/screens/ProfileList.tsx:390 +msgid "Edit list details" +msgstr "" + +#: src/view/screens/Feeds.tsx:367 +#: src/view/screens/SavedFeeds.tsx:85 +msgid "Edit My Feeds" +msgstr "" + +#: src/view/com/modals/EditProfile.tsx:151 +msgid "Edit my profile" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:425 +msgid "Edit profile" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:428 +msgid "Edit Profile" +msgstr "" + +#: src/view/screens/Feeds.tsx:330 +msgid "Edit Saved Feeds" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:90 +#: src/view/com/auth/login/ForgotPasswordForm.tsx:148 +#: src/view/com/modals/ChangeEmail.tsx:141 +#: src/view/com/modals/Waitlist.tsx:88 +msgid "Email" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:81 +msgid "Email address" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:111 +msgid "Email Updated" +msgstr "" + +#: src/view/screens/Settings.tsx:290 +msgid "Email:" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:138 +msgid "Enable this setting to only see replies between people you follow." +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:71 +msgid "Enter the address of your provider:" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:369 +msgid "Enter the domain you want to use" +msgstr "" + +#: src/view/com/auth/login/ForgotPasswordForm.tsx:101 +msgid "Enter the email you used to create your account. We'll send you a \"reset code\" so you can set a new password." +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:86 +msgid "Enter your email address" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:117 +msgid "Enter your new email address below." +msgstr "" + +#: src/view/com/auth/login/Login.tsx:83 +msgid "Enter your username and password" +msgstr "" + +#: src/view/screens/Search/Search.tsx:104 +msgid "Error:" +msgstr "" + +#: src/view/com/lightbox/Lightbox.web.tsx:156 +msgid "Expand alt text" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:109 +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:141 +msgid "Failed to load recommended feeds" +msgstr "" + +#: src/view/screens/Feeds.tsx:559 +msgid "Feed offline" +msgstr "" + +#: src/view/com/feeds/FeedPage.tsx:132 +msgid "Feed Preferences" +msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:64 +#: src/view/shell/Drawer.tsx:410 +msgid "Feedback" +msgstr "" + +#: src/view/screens/Feeds.tsx:475 +#: src/view/shell/bottom-bar/BottomBar.tsx:168 +#: src/view/shell/desktop/LeftNav.tsx:341 +#: src/view/shell/Drawer.tsx:327 +#: src/view/shell/Drawer.tsx:328 +msgid "Feeds" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:57 +msgid "Feeds are created by users to curate content. Choose some feeds that you find interesting." +msgstr "" + +#: src/view/screens/SavedFeeds.tsx:156 +msgid "Feeds are custom algorithms that users build with a little coding expertise. <0/> for more information." +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollowsItem.tsx:150 +msgid "Finding similar accounts..." +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:102 +msgid "Fine-tune the content you see on your home screen." +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:60 +msgid "Fine-tune the discussion threads." +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:510 +msgid "Follow" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:42 +#~ msgid "Follow some" +#~ msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:64 +msgid "Follow some users to get started. We can recommend you more users based on who you find interesting." +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:145 +msgid "Followed users only" +msgstr "" + +#: src/view/screens/ProfileFollowers.tsx:25 +msgid "Followers" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:596 +msgid "following" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:494 +#: src/view/screens/ProfileFollows.tsx:25 +msgid "Following" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:543 +msgid "Follows you" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:107 +msgid "For security reasons, we'll need to send a confirmation code to your email address." +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:207 +msgid "For security reasons, you won't be able to view this again. If you lose this password, you'll need to generate a new one." +msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:233 +msgid "Forgot" +msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:230 +msgid "Forgot password" +msgstr "" + +#: src/view/com/auth/login/Login.tsx:111 +#: src/view/com/auth/login/Login.tsx:127 +msgid "Forgot Password" +msgstr "" + +#: src/view/com/composer/photos/SelectPhotoBtn.tsx:43 +msgid "Gallery" +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:175 +msgid "Get Started" +msgstr "" + +#: src/view/com/auth/LoggedOut.tsx:53 +#: src/view/com/auth/LoggedOut.tsx:54 +#: src/view/com/util/moderation/ScreenHider.tsx:105 +#: src/view/shell/desktop/LeftNav.tsx:106 +msgid "Go back" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:111 +#: src/view/screens/ProfileFeed.tsx:116 +#: src/view/screens/ProfileList.tsx:761 +#: src/view/screens/ProfileList.tsx:766 +msgid "Go Back" +msgstr "" + +#: src/view/com/auth/login/ForgotPasswordForm.tsx:181 +#: src/view/com/auth/login/LoginForm.tsx:280 +#: src/view/com/auth/login/SetNewPasswordForm.tsx:163 +msgid "Go to next" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:265 +msgid "Handle" +msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:93 +#: src/view/shell/Drawer.tsx:420 +msgid "Help" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:148 +msgid "Here is your app password." +msgstr "" + +#: src/view/com/notifications/FeedItem.tsx:316 +msgid "Hide" +msgstr "" + +#: src/view/com/notifications/FeedItem.tsx:308 +msgid "Hide user list" +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:101 +msgid "Hmm, some kind of issue occured when contacting the feed server. Please let the feed owner know about this issue." +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:89 +msgid "Hmm, the feed server appears to be misconfigured. Please let the feed owner know about this issue." +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:95 +msgid "Hmm, the feed server appears to be offline. Please let the feed owner know about this issue." +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:92 +msgid "Hmm, the feed server gave a bad response. Please let the feed owner know about this issue." +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:86 +msgid "Hmmm, we're having trouble finding this feed. It may have been deleted." +msgstr "" + +#: src/view/shell/bottom-bar/BottomBar.tsx:124 +#: src/view/shell/desktop/LeftNav.tsx:305 +#: src/view/shell/Drawer.tsx:274 +#: src/view/shell/Drawer.tsx:275 +msgid "Home" +msgstr "" + +#: src/view/com/pager/FeedsTabBarMobile.tsx:99 +#: src/view/screens/PreferencesHomeFeed.tsx:95 +#: src/view/screens/Settings.tsx:481 +msgid "Home Feed Preferences" +msgstr "" + +#: src/view/com/auth/login/ForgotPasswordForm.tsx:114 +msgid "Hosting provider" +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:76 +#: src/view/com/auth/create/Step1.tsx:81 +msgid "Hosting provider address" +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:200 +msgid "I have a code" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:281 +msgid "I have my own domain" +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:127 +msgid "If none are selected, suitable for all ages." +msgstr "" + +#: src/view/com/modals/AltImage.tsx:96 +msgid "Image alt text" +msgstr "" + +#: src/view/com/util/UserAvatar.tsx:304 +#: src/view/com/util/UserBanner.tsx:116 +msgid "Image options" +msgstr "" + +#: src/view/com/search/Suggestions.tsx:104 +#: src/view/com/search/Suggestions.tsx:115 +#~ msgid "In Your Network" +#~ msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:113 +msgid "Invalid username or password" +msgstr "" + +#: src/view/screens/Settings.tsx:383 +msgid "Invite" +msgstr "" + +#: src/view/com/modals/InviteCodes.tsx:91 +#: src/view/screens/Settings.tsx:371 +msgid "Invite a Friend" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:57 +msgid "Invite code" +msgstr "" + +#: src/view/com/auth/create/state.ts:136 +msgid "Invite code not accepted. Check that you input it correctly and try again." +msgstr "" + +#: src/view/shell/Drawer.tsx:502 +msgid "Invite codes: {invitesAvailable} available" +msgstr "" + +#: src/view/com/modals/Waitlist.tsx:67 +msgid "Join the waitlist" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:68 +#: src/view/com/auth/create/Step2.tsx:72 +msgid "Join the waitlist." +msgstr "" + +#: src/view/com/modals/Waitlist.tsx:124 +msgid "Join Waitlist" +msgstr "" + +#: src/view/com/composer/select-language/SelectLangBtn.tsx:104 +msgid "Language selection" +msgstr "" + +#: src/view/screens/LanguageSettings.tsx:86 +msgid "Language Settings" +msgstr "" + +#: src/view/screens/Settings.tsx:541 +msgid "Languages" +msgstr "" + +#: src/view/com/util/moderation/PostAlerts.tsx:47 +#: src/view/com/util/moderation/ProfileHeaderAlerts.tsx:55 +#: src/view/com/util/moderation/ScreenHider.tsx:88 +msgid "Learn More" +msgstr "" + +#: src/view/com/util/moderation/ContentHider.tsx:75 +#: src/view/com/util/moderation/PostAlerts.tsx:40 +#: src/view/com/util/moderation/PostHider.tsx:76 +#: src/view/com/util/moderation/ProfileHeaderAlerts.tsx:47 +#: src/view/com/util/moderation/ScreenHider.tsx:85 +msgid "Learn more about this warning" +msgstr "" + +#: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:82 +msgid "Leave them all unchecked to see any language." +msgstr "" + +#: src/view/com/modals/LinkWarning.tsx:49 +msgid "Leaving Bluesky" +msgstr "" + +#: src/view/com/auth/login/Login.tsx:112 +#: src/view/com/auth/login/Login.tsx:128 +msgid "Let's get your password reset!" +msgstr "" + +#: src/view/com/util/UserAvatar.tsx:241 +#: src/view/com/util/UserBanner.tsx:60 +msgid "Library" +msgstr "" + +#: src/view/screens/Settings.tsx:405 +#~ msgid "Light" +#~ msgstr "" + +#: src/view/screens/ProfileFeed.tsx:627 +msgid "Like this feed" +msgstr "" + +#: src/view/screens/PostLikedBy.tsx:27 +#: src/view/screens/ProfileFeedLikedBy.tsx:27 +msgid "Liked by" +msgstr "" + +#: src/view/com/modals/CreateOrEditList.tsx:186 +msgid "List Avatar" +msgstr "" + +#: src/view/com/modals/CreateOrEditList.tsx:199 +msgid "List Name" +msgstr "" + +#: src/view/shell/desktop/LeftNav.tsx:381 +#: src/view/shell/Drawer.tsx:338 +#: src/view/shell/Drawer.tsx:339 +msgid "Lists" +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:246 +#: src/view/com/post-thread/PostThread.tsx:254 +msgid "Load more posts" +msgstr "" + +#: src/view/screens/Notifications.tsx:129 +msgid "Load new notifications" +msgstr "" + +#: src/view/com/feeds/FeedPage.tsx:177 +msgid "Load new posts" +msgstr "" + +#: src/view/com/composer/text-input/mobile/Autocomplete.tsx:95 +msgid "Loading..." +msgstr "" + +#: src/view/com/modals/ServerInput.tsx:50 +msgid "Local dev server" +msgstr "" + +#: src/view/com/auth/login/ChooseAccountForm.tsx:133 +msgid "Login to account that is not listed" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:479 +msgid "Looks like this feed is only available to users with a Bluesky account. Please sign up or sign in to view this feed!" +msgstr "" + +#: src/view/com/modals/LinkWarning.tsx:63 +msgid "Make sure this is where you intend to go!" +msgstr "" + +#: src/view/screens/Search/Search.tsx:531 +msgid "Menu" +msgstr "" + +#: src/view/screens/Moderation.tsx:51 +#: src/view/screens/Settings.tsx:563 +#: src/view/shell/desktop/LeftNav.tsx:399 +#: src/view/shell/Drawer.tsx:345 +#: src/view/shell/Drawer.tsx:346 +msgid "Moderation" +msgstr "" + +#: src/view/screens/Moderation.tsx:81 +msgid "Moderation lists" +msgstr "" + +#: src/view/shell/desktop/Feeds.tsx:53 +msgid "More feeds" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:520 +#: src/view/screens/ProfileFeed.tsx:369 +#: src/view/screens/ProfileList.tsx:506 +msgid "More options" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:158 +#~ msgid "More post options" +#~ msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:344 +msgid "Mute Account" +msgstr "" + +#: src/view/screens/ProfileList.tsx:434 +msgid "Mute accounts" +msgstr "" + +#: src/view/screens/ProfileList.tsx:267 +msgid "Mute these accounts?" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:144 +msgid "Mute thread" +msgstr "" + +#: src/view/screens/Moderation.tsx:95 +msgid "Muted accounts" +msgstr "" + +#: src/view/screens/ModerationMutedAccounts.tsx:106 +msgid "Muted Accounts" +msgstr "" + +#: src/view/screens/ModerationMutedAccounts.tsx:114 +msgid "Muted accounts have their posts removed from your feed and from your notifications. Mutes are completely private." +msgstr "" + +#: src/view/screens/ProfileList.tsx:269 +msgid "Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them." +msgstr "" + +#: src/view/com/modals/BirthDateSettings.tsx:56 +msgid "My Birthday" +msgstr "" + +#: src/view/screens/Feeds.tsx:363 +msgid "My Feeds" +msgstr "" + +#: src/view/shell/desktop/LeftNav.tsx:67 +msgid "My Profile" +msgstr "" + +#: src/view/screens/Settings.tsx:520 +msgid "My Saved Feeds" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:177 +#: src/view/com/modals/CreateOrEditList.tsx:211 +msgid "Name" +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:72 +msgid "Never lose access to your followers and data." +msgstr "" + +#: src/view/screens/Lists.tsx:76 +msgid "New" +msgstr "" + +#: src/view/com/feeds/FeedPage.tsx:188 +#: src/view/screens/Feeds.tsx:510 +#: src/view/screens/Profile.tsx:382 +#: src/view/screens/ProfileFeed.tsx:449 +#: src/view/screens/ProfileList.tsx:199 +#: src/view/screens/ProfileList.tsx:231 +#: src/view/shell/desktop/LeftNav.tsx:254 +msgid "New post" +msgstr "" + +#: src/view/shell/desktop/LeftNav.tsx:264 +msgid "New Post" +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:158 +#: src/view/com/auth/login/ForgotPasswordForm.tsx:174 +#: src/view/com/auth/login/ForgotPasswordForm.tsx:184 +#: src/view/com/auth/login/LoginForm.tsx:283 +#: src/view/com/auth/login/SetNewPasswordForm.tsx:156 +#: src/view/com/auth/login/SetNewPasswordForm.tsx:166 +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:79 +msgid "Next" +msgstr "" + +#: src/view/com/lightbox/Lightbox.web.tsx:142 +msgid "Next image" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:191 +#: src/view/screens/PreferencesHomeFeed.tsx:226 +#: src/view/screens/PreferencesHomeFeed.tsx:263 +msgid "No" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:620 +#: src/view/screens/ProfileList.tsx:632 +msgid "No description" +msgstr "" + +#: src/view/com/composer/text-input/mobile/Autocomplete.tsx:97 +msgid "No result" +msgstr "" + +#: src/view/screens/Feeds.tsx:452 +msgid "No results found for \"{query}\"" +msgstr "" + +#: src/view/com/modals/ListAddUser.tsx:142 +#: src/view/shell/desktop/Search.tsx:112 +#~ msgid "No results found for {0}" +#~ msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:127 +#: src/view/screens/Search/Search.tsx:269 +#: src/view/screens/Search/Search.tsx:326 +#: src/view/screens/Search/Search.tsx:609 +#: src/view/shell/desktop/Search.tsx:209 +msgid "No results found for {query}" +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:136 +#~ msgid "Not Applicable" +#~ msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:135 +msgid "Not Applicable." +msgstr "" + +#: src/view/screens/Notifications.tsx:96 +#: src/view/screens/Notifications.tsx:120 +#: src/view/shell/bottom-bar/BottomBar.tsx:195 +#: src/view/shell/desktop/LeftNav.tsx:363 +#: src/view/shell/Drawer.tsx:298 +#: src/view/shell/Drawer.tsx:299 +msgid "Notifications" +msgstr "" + +#: src/view/com/util/ErrorBoundary.tsx:34 +msgid "Oh no!" +msgstr "" + +#: src/view/com/auth/login/PasswordUpdatedForm.tsx:41 +msgid "Okay" +msgstr "" + +#: src/view/com/composer/Composer.tsx:334 +msgid "One or more images is missing alt text." +msgstr "" + +#: src/view/com/pager/FeedsTabBarMobile.tsx:79 +msgid "Open navigation" +msgstr "" + +#: src/view/screens/Settings.tsx:533 +msgid "Opens configurable language settings" +msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:146 +#: src/view/shell/Drawer.tsx:503 +msgid "Opens list of invite codes" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:279 +msgid "Opens modal for using custom domain" +msgstr "" + +#: src/view/screens/Settings.tsx:558 +msgid "Opens moderation settings" +msgstr "" + +#: src/view/screens/Settings.tsx:514 +msgid "Opens screen with all saved feeds" +msgstr "" + +#: src/view/screens/Settings.tsx:581 +msgid "Opens the app password settings page" +msgstr "" + +#: src/view/screens/Settings.tsx:473 +msgid "Opens the home feed preferences" +msgstr "" + +#: src/view/screens/Settings.tsx:664 +msgid "Opens the storybook page" +msgstr "" + +#: src/view/screens/Settings.tsx:644 +msgid "Opens the system log page" +msgstr "" + +#: src/view/screens/Settings.tsx:494 +msgid "Opens the threads preferences" +msgstr "" + +#: src/view/com/auth/login/ChooseAccountForm.tsx:138 +msgid "Other account" +msgstr "" + +#: src/view/com/modals/ServerInput.tsx:88 +msgid "Other service" +msgstr "" + +#: src/view/com/composer/select-language/SelectLangBtn.tsx:91 +msgid "Other..." +msgstr "" + +#: src/view/screens/NotFound.tsx:42 +#: src/view/screens/NotFound.tsx:45 +msgid "Page not found" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:101 +#: src/view/com/auth/create/Step2.tsx:111 +#: src/view/com/auth/login/LoginForm.tsx:218 +#: src/view/com/auth/login/SetNewPasswordForm.tsx:130 +#: src/view/com/modals/DeleteAccount.tsx:191 +msgid "Password" +msgstr "" + +#: src/view/com/auth/login/Login.tsx:141 +msgid "Password updated" +msgstr "" + +#: src/view/com/auth/login/PasswordUpdatedForm.tsx:28 +msgid "Password updated!" +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:121 +msgid "Pictures meant for adults." +msgstr "" + +#: src/view/screens/SavedFeeds.tsx:89 +msgid "Pinned Feeds" +msgstr "" + +#: src/view/com/auth/create/state.ts:116 +msgid "Please choose your handle." +msgstr "" + +#: src/view/com/auth/create/state.ts:109 +msgid "Please choose your password." +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:67 +msgid "Please confirm your email before changing it. This is a temporary requirement while email-updating tools are added, and it will soon be removed." +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:140 +msgid "Please enter a unique name for this App Password or use our randomly generated one." +msgstr "" + +#: src/view/com/auth/create/state.ts:95 +msgid "Please enter your email." +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:180 +msgid "Please enter your password as well:" +msgstr "" + +#: src/view/com/composer/Composer.tsx:317 +#: src/view/com/post-thread/PostThread.tsx:212 +#: src/view/screens/PostThread.tsx:77 +msgid "Post" +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:366 +msgid "Post hidden" +msgstr "" + +#: src/view/com/composer/select-language/SelectLangBtn.tsx:87 +msgid "Post language" +msgstr "" + +#: src/view/com/modals/lang-settings/PostLanguagesSettings.tsx:75 +msgid "Post Languages" +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:418 +msgid "Post not found" +msgstr "" + +#: src/view/com/modals/LinkWarning.tsx:44 +msgid "Potentially Misleading Link" +msgstr "" + +#: src/view/com/lightbox/Lightbox.web.tsx:128 +msgid "Previous image" +msgstr "" + +#: src/view/screens/LanguageSettings.tsx:183 +msgid "Primary Language" +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:91 +msgid "Prioritize Your Follows" +msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:75 +msgid "Privacy" +msgstr "" + +#: src/view/screens/PrivacyPolicy.tsx:29 +msgid "Privacy Policy" +msgstr "" + +#: src/view/com/auth/login/ForgotPasswordForm.tsx:190 +msgid "Processing..." +msgstr "" + +#: src/view/shell/bottom-bar/BottomBar.tsx:237 +#: src/view/shell/Drawer.tsx:72 +#: src/view/shell/Drawer.tsx:366 +#: src/view/shell/Drawer.tsx:367 +msgid "Profile" +msgstr "" + +#: src/view/screens/Settings.tsx:789 +msgid "Protect your account by verifying your email." +msgstr "" + +#: src/view/screens/Lists.tsx:61 +msgid "Public, shareable lists which can drive feeds." +msgstr "" + +#: src/view/com/modals/Repost.tsx:52 +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:58 +msgid "Quote post" +msgstr "" + +#: src/view/com/modals/Repost.tsx:56 +msgid "Quote Post" +msgstr "" + +#: src/view/com/modals/EditImage.tsx:236 +msgid "Ratios" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:73 +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:50 +#~ msgid "Recommended" +#~ msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:116 +msgid "Recommended Feeds" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:180 +msgid "Recommended Users" +msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:264 +#: src/view/com/modals/SelfLabel.tsx:83 +#: src/view/com/modals/UserAddRemoveLists.tsx:187 +#: src/view/com/util/UserAvatar.tsx:278 +#: src/view/com/util/UserBanner.tsx:89 +msgid "Remove" +msgstr "" + +#: src/view/com/feeds/FeedSourceCard.tsx:108 +msgid "Remove {0} from my feeds?" +msgstr "" + +#: src/view/com/util/AccountDropdownBtn.tsx:22 +msgid "Remove account" +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:118 +msgid "Remove feed" +msgstr "" + +#: src/view/com/feeds/FeedSourceCard.tsx:107 +#: src/view/screens/ProfileFeed.tsx:279 +msgid "Remove from my feeds" +msgstr "" + +#: src/view/com/composer/photos/Gallery.tsx:167 +msgid "Remove image" +msgstr "" + +#: src/view/com/composer/ExternalEmbed.tsx:70 +msgid "Remove image preview" +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:119 +msgid "Remove this feed from your saved feeds?" +msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:199 +#: src/view/com/modals/UserAddRemoveLists.tsx:130 +msgid "Removed from list" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:135 +msgid "Reply Filters" +msgstr "" + +#: src/view/com/modals/report/Modal.tsx:166 +msgid "Report {collectionName}" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:378 +msgid "Report Account" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:299 +msgid "Report feed" +msgstr "" + +#: src/view/screens/ProfileList.tsx:416 +msgid "Report List" +msgstr "" + +#: src/view/com/modals/report/SendReportButton.tsx:37 +#: src/view/com/util/forms/PostDropdownBtn.tsx:162 +msgid "Report post" +msgstr "" + +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:48 +msgid "Repost" +msgstr "" + +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:94 +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:105 +msgid "Repost or quote post" +msgstr "" + +#: src/view/screens/PostRepostedBy.tsx:27 +msgid "Reposted by" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:181 +#: src/view/com/modals/ChangeEmail.tsx:183 +msgid "Request Change" +msgstr "" + +#: src/view/screens/Settings.tsx:382 +#~ msgid "Require alt text before posting" +#~ msgstr "" + +#: src/view/com/auth/create/Step2.tsx:53 +msgid "Required for this provider" +msgstr "" + +#: src/view/com/auth/login/SetNewPasswordForm.tsx:108 +msgid "Reset code" +msgstr "" + +#: src/view/screens/Settings.tsx:686 +msgid "Reset onboarding state" +msgstr "" + +#: src/view/com/auth/login/ForgotPasswordForm.tsx:98 +msgid "Reset password" +msgstr "" + +#: src/view/screens/Settings.tsx:676 +msgid "Reset preferences state" +msgstr "" + +#: src/view/screens/Settings.tsx:684 +msgid "Resets the onboarding state" +msgstr "" + +#: src/view/screens/Settings.tsx:674 +msgid "Resets the preferences state" +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:167 +#: src/view/com/auth/create/CreateAccount.tsx:171 +#: src/view/com/auth/login/LoginForm.tsx:260 +#: src/view/com/auth/login/LoginForm.tsx:263 +#: src/view/com/util/error/ErrorMessage.tsx:55 +#: src/view/com/util/error/ErrorScreen.tsx:65 +msgid "Retry" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:169 +#~ msgid "Retry change handle" +#~ msgstr "" + +#: src/view/com/modals/AltImage.tsx:114 +#: src/view/com/modals/BirthDateSettings.tsx:93 +#: src/view/com/modals/BirthDateSettings.tsx:96 +#: src/view/com/modals/ChangeHandle.tsx:173 +#: src/view/com/modals/CreateOrEditList.tsx:249 +#: src/view/com/modals/CreateOrEditList.tsx:257 +#: src/view/com/modals/EditProfile.tsx:223 +msgid "Save" +msgstr "" + +#: src/view/com/modals/AltImage.tsx:105 +msgid "Save alt text" +msgstr "" + +#: src/view/com/modals/UserAddRemoveLists.tsx:212 +#~ msgid "Save changes" +#~ msgstr "" + +#: src/view/com/modals/EditProfile.tsx:231 +msgid "Save Changes" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:170 +msgid "Save handle change" +msgstr "" + +#: src/view/com/modals/crop-image/CropImage.web.tsx:144 +msgid "Save image crop" +msgstr "" + +#: src/view/screens/SavedFeeds.tsx:122 +msgid "Saved Feeds" +msgstr "" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:75 +#: src/view/com/util/forms/SearchInput.tsx:64 +#: src/view/screens/Search/Search.tsx:409 +#: src/view/screens/Search/Search.tsx:561 +#: src/view/shell/bottom-bar/BottomBar.tsx:146 +#: src/view/shell/desktop/LeftNav.tsx:323 +#: src/view/shell/desktop/Search.tsx:160 +#: src/view/shell/desktop/Search.tsx:169 +#: src/view/shell/Drawer.tsx:252 +#: src/view/shell/Drawer.tsx:253 +msgid "Search" +msgstr "" + +#: src/view/screens/Search/Search.tsx:418 +msgid "Search for posts and users." +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:110 +msgid "Security Step Required" +msgstr "" + +#: src/view/com/auth/SplashScreen.tsx:29 +msgid "See what's next" +msgstr "" + +#: src/view/com/modals/ServerInput.tsx:75 +msgid "Select Bluesky Social" +msgstr "" + +#: src/view/com/auth/login/Login.tsx:101 +msgid "Select from an existing account" +msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:145 +msgid "Select service" +msgstr "" + +#: src/view/screens/LanguageSettings.tsx:276 +msgid "Select which languages you want your subscribed feeds to include. If none are selected, all languages will be shown." +msgstr "" + +#: src/view/screens/LanguageSettings.tsx:95 +msgid "Select your app language for the default text to display in the app" +msgstr "" + +#: src/view/screens/LanguageSettings.tsx:186 +msgid "Select your preferred language for translations in your feed." +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:188 +msgid "Send Confirmation Email" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:127 +msgid "Send email" +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:138 +msgid "Send Email" +msgstr "" + +#: src/view/shell/Drawer.tsx:394 +#: src/view/shell/Drawer.tsx:415 +msgid "Send feedback" +msgstr "" + +#: src/view/com/modals/report/SendReportButton.tsx:45 +msgid "Send Report" +msgstr "" + +#: src/view/com/auth/login/SetNewPasswordForm.tsx:78 +msgid "Set new password" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:216 +msgid "Set this setting to \"No\" to hide all quote posts from your feed. Reposts will still be visible." +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:113 +msgid "Set this setting to \"No\" to hide all replies from your feed." +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:182 +msgid "Set this setting to \"No\" to hide all reposts from your feed." +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:116 +msgid "Set this setting to \"Yes\" to show replies in a threaded view. This is an experimental feature." +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:252 +msgid "Set this setting to \"Yes\" to show samples of your saved feeds in your following feed. This is an experimental feature." +msgstr "" + +#: src/view/screens/Settings.tsx:277 +#: src/view/shell/desktop/LeftNav.tsx:435 +#: src/view/shell/Drawer.tsx:379 +#: src/view/shell/Drawer.tsx:380 +msgid "Settings" +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:125 +msgid "Sexual activity or erotic nudity." +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:312 +#: src/view/com/util/forms/PostDropdownBtn.tsx:126 +#: src/view/screens/ProfileList.tsx:375 +msgid "Share" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:311 +msgid "Share feed" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:276 +#~ msgid "Share link" +#~ msgstr "" + +#: src/view/screens/Settings.tsx:316 +msgid "Show" +msgstr "" + +#: src/view/com/util/moderation/ScreenHider.tsx:114 +msgid "Show anyway" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:249 +msgid "Show Posts from My Feeds" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:213 +msgid "Show Quote Posts" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:110 +msgid "Show Replies" +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:94 +msgid "Show replies by people you follow before all other replies." +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:179 +msgid "Show Reposts" +msgstr "" + +#: src/view/com/notifications/FeedItem.tsx:337 +msgid "Show users" +msgstr "" + +#: src/view/com/auth/login/Login.tsx:82 +#: src/view/com/auth/SplashScreen.tsx:49 +#: src/view/shell/NavSignupCard.tsx:52 +#: src/view/shell/NavSignupCard.tsx:53 +msgid "Sign in" +msgstr "" + +#: src/view/com/auth/SplashScreen.tsx:52 +#: src/view/com/auth/SplashScreen.web.tsx:84 +msgid "Sign In" +msgstr "" + +#: src/view/com/auth/login/ChooseAccountForm.tsx:44 +msgid "Sign in as {0}" +msgstr "" + +#: src/view/com/auth/login/ChooseAccountForm.tsx:118 +#: src/view/com/auth/login/Login.tsx:100 +msgid "Sign in as..." +msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:132 +msgid "Sign into" +msgstr "" + +#: src/view/com/modals/SwitchAccount.tsx:60 +#: src/view/com/modals/SwitchAccount.tsx:63 +msgid "Sign out" +msgstr "" + +#: src/view/shell/NavSignupCard.tsx:43 +#: src/view/shell/NavSignupCard.tsx:44 +#: src/view/shell/NavSignupCard.tsx:46 +msgid "Sign up" +msgstr "" + +#: src/view/shell/NavSignupCard.tsx:36 +msgid "Sign up or sign in to join the conversation" +msgstr "" + +#: src/view/screens/Settings.tsx:327 +msgid "Signed in as" +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:33 +msgid "Skip" +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:69 +msgid "Sort Replies" +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:72 +msgid "Sort replies to the same post by:" +msgstr "" + +#: src/view/com/modals/crop-image/CropImage.web.tsx:122 +msgid "Square" +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:90 +#: src/view/com/modals/ServerInput.tsx:62 +msgid "Staging" +msgstr "" + +#: src/view/screens/Settings.tsx:730 +msgid "Status page" +msgstr "" + +#: src/view/screens/Settings.tsx:666 +msgid "Storybook" +msgstr "" + +#: src/view/screens/ProfileList.tsx:497 +msgid "Subscribe" +msgstr "" + +#: src/view/screens/ProfileList.tsx:493 +msgid "Subscribe to this list" +msgstr "" + +#: src/view/screens/Search/Search.tsx:382 +msgid "Suggested Follows" +msgstr "" + +#: src/view/screens/Support.tsx:30 +#: src/view/screens/Support.tsx:33 +msgid "Support" +msgstr "" + +#: src/view/com/modals/SwitchAccount.tsx:111 +msgid "Switch Account" +msgstr "" + +#: src/view/screens/Settings.tsx:398 +#~ msgid "System" +#~ msgstr "" + +#: src/view/screens/Settings.tsx:646 +msgid "System log" +msgstr "" + +#: src/view/com/modals/crop-image/CropImage.web.tsx:112 +msgid "Tall" +msgstr "" + +#: src/view/shell/desktop/RightNav.tsx:84 +msgid "Terms" +msgstr "" + +#: src/view/screens/TermsOfService.tsx:29 +msgid "Terms of Service" +msgstr "" + +#: src/view/com/modals/report/InputIssueDetails.tsx:50 +msgid "Text input field" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:280 +msgid "The account will be able to interact with you after unblocking." +msgstr "" + +#: src/view/screens/CommunityGuidelines.tsx:36 +msgid "The Community Guidelines have been moved to <0/>" +msgstr "" + +#: src/view/screens/CopyrightPolicy.tsx:33 +msgid "The Copyright Policy has been moved to <0/>" +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:421 +msgid "The post may have been deleted." +msgstr "" + +#: src/view/screens/PrivacyPolicy.tsx:33 +msgid "The Privacy Policy has been moved to <0/>" +msgstr "" + +#: src/view/screens/Support.tsx:36 +msgid "The support form has been moved. If you need help, please<0/> or visit {HELP_DESK_URL} to get in touch with us." +msgstr "" + +#: src/view/screens/TermsOfService.tsx:33 +msgid "The Terms of Service have been moved to" +msgstr "" + +#: src/view/com/util/ErrorBoundary.tsx:35 +msgid "There was an unexpected issue in the application. Please let us know if this happened to you!" +msgstr "" + +#: src/view/com/util/moderation/ScreenHider.tsx:72 +msgid "This {screenDescription} has been flagged:" +msgstr "" + +#: src/view/com/modals/BirthDateSettings.tsx:61 +msgid "This information is not shared with other users." +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:105 +msgid "This is important in case you ever need to change your email or reset your password." +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:55 +msgid "This is the service that keeps you online." +msgstr "" + +#: src/view/com/modals/LinkWarning.tsx:56 +msgid "This link is taking you to the following website:" +msgstr "" + +#: src/view/com/post-thread/PostThreadItem.tsx:114 +msgid "This post has been deleted." +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:137 +msgid "This warning is only available for posts with media attached." +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:53 +#: src/view/screens/Settings.tsx:503 +msgid "Thread Preferences" +msgstr "" + +#: src/view/screens/PreferencesThreads.tsx:113 +msgid "Threaded Mode" +msgstr "" + +#: src/view/com/util/forms/DropdownButton.tsx:230 +msgid "Toggle dropdown" +msgstr "" + +#: src/view/com/modals/EditImage.tsx:271 +msgid "Transformations" +msgstr "" + +#: src/view/com/post-thread/PostThreadItem.tsx:646 +#: src/view/com/post-thread/PostThreadItem.tsx:648 +#: src/view/com/util/forms/PostDropdownBtn.tsx:98 +msgid "Translate" +msgstr "" + +#: src/view/com/util/error/ErrorScreen.tsx:73 +msgid "Try again" +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:64 +#: src/view/com/auth/login/Login.tsx:60 +#: src/view/com/auth/login/LoginForm.tsx:117 +msgid "Unable to contact your service. Please check your Internet connection." +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:438 +#: src/view/com/profile/ProfileHeader.tsx:441 +msgid "Unblock" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:278 +#: src/view/com/profile/ProfileHeader.tsx:362 +msgid "Unblock Account" +msgstr "" + +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:48 +msgid "Undo repost" +msgstr "" + +#: src/view/com/auth/create/state.ts:210 +msgid "Unfortunately, you do not meet the requirements to create an account." +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:343 +msgid "Unmute Account" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:144 +msgid "Unmute thread" +msgstr "" + +#: src/view/com/modals/UserAddRemoveLists.tsx:52 +msgid "Update {displayName} in Lists" +msgstr "" + +#: src/lib/hooks/useOTAUpdate.ts:15 +msgid "Update Available" +msgstr "" + +#: src/view/com/auth/login/SetNewPasswordForm.tsx:172 +msgid "Updating..." +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:453 +msgid "Upload a text file to:" +msgstr "" + +#: src/view/screens/AppPasswords.tsx:194 +msgid "Use app passwords to login to other Bluesky clients without giving full access to your account or password." +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:513 +msgid "Use default provider" +msgstr "" + +#: src/view/com/modals/AddAppPasswords.tsx:150 +msgid "Use this to sign into the other app along with your handle." +msgstr "" + +#: src/view/com/modals/InviteCodes.tsx:196 +msgid "Used by:" +msgstr "" + +#: src/view/com/auth/create/Step3.tsx:38 +msgid "User handle" +msgstr "" + +#: src/view/screens/Lists.tsx:58 +msgid "User Lists" +msgstr "" + +#: src/view/com/auth/login/LoginForm.tsx:172 +#: src/view/com/auth/login/LoginForm.tsx:189 +msgid "Username or email address" +msgstr "" + +#: src/view/screens/ProfileList.tsx:659 +msgid "Users" +msgstr "" + +#: src/view/screens/Settings.tsx:750 +msgid "Verify email" +msgstr "" + +#: src/view/screens/Settings.tsx:775 +msgid "Verify my email" +msgstr "" + +#: src/view/screens/Settings.tsx:784 +msgid "Verify My Email" +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:205 +#: src/view/com/modals/ChangeEmail.tsx:207 +msgid "Verify New Email" +msgstr "" + +#: src/view/screens/Log.tsx:52 +msgid "View debug entry" +msgstr "" + +#: src/view/com/profile/ProfileSubpageHeader.tsx:128 +msgid "View the avatar" +msgstr "" + +#: src/view/com/modals/LinkWarning.tsx:73 +msgid "Visit Site" +msgstr "" + +#: src/view/com/auth/create/CreateAccount.tsx:125 +msgid "We're so excited to have you join us!" +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:98 +msgid "We're sorry, but this content is not viewable without a Bluesky account." +msgstr "" + +#: src/view/screens/Search/Search.tsx:236 +msgid "We're sorry, but your search could not be completed. Please try again in a few minutes." +msgstr "" + +#: src/view/screens/NotFound.tsx:48 +msgid "We're sorry! We can't find the page you were looking for." +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:46 +msgid "Welcome to <0>Bluesky</0>" +msgstr "" + +#: src/view/com/modals/report/Modal.tsx:169 +msgid "What is the issue with this {collectionName}?" +msgstr "" + +#: src/view/com/modals/lang-settings/PostLanguagesSettings.tsx:78 +msgid "Which languages are used in this post?" +msgstr "" + +#: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:77 +msgid "Which languages would you like to see in your algorithmic feeds?" +msgstr "" + +#: src/view/com/modals/crop-image/CropImage.web.tsx:102 +msgid "Wide" +msgstr "" + +#: src/view/com/composer/Composer.tsx:389 +msgid "Write post" +msgstr "" + +#: src/view/com/composer/Prompt.tsx:33 +msgid "Write your reply" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:192 +#: src/view/screens/PreferencesHomeFeed.tsx:227 +#: src/view/screens/PreferencesHomeFeed.tsx:262 +msgid "Yes" +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:106 +msgid "You can change hosting providers at any time." +msgstr "" + +#: src/view/com/auth/login/Login.tsx:142 +#: src/view/com/auth/login/PasswordUpdatedForm.tsx:31 +msgid "You can now sign in with your new password." +msgstr "" + +#: src/view/com/modals/InviteCodes.tsx:64 +msgid "You don't have any invite codes yet! We'll send you some when you've been on Bluesky for a little longer." +msgstr "" + +#: src/view/screens/SavedFeeds.tsx:102 +msgid "You don't have any pinned feeds." +msgstr "" + +#: src/view/screens/Feeds.tsx:383 +msgid "You don't have any saved feeds!" +msgstr "" + +#: src/view/screens/SavedFeeds.tsx:135 +msgid "You don't have any saved feeds." +msgstr "" + +#: src/view/com/post-thread/PostThread.tsx:369 +msgid "You have blocked the author or you have been blocked by the author." +msgstr "" + +#: src/view/com/feeds/ProfileFeedgens.tsx:150 +msgid "You have no feeds." +msgstr "" + +#: src/view/com/lists/MyLists.tsx:88 +#: src/view/com/lists/ProfileLists.tsx:154 +msgid "You have no lists." +msgstr "" + +#: src/view/screens/ModerationBlockedAccounts.tsx:131 +msgid "You have not blocked any accounts yet. To block an account, go to their profile and selected \"Block account\" from the menu on their account." +msgstr "" + +#: src/view/screens/AppPasswords.tsx:86 +msgid "You have not created any app passwords yet. You can create one by pressing the button below." +msgstr "" + +#: src/view/screens/ModerationMutedAccounts.tsx:130 +msgid "You have not muted any accounts yet. To mute an account, go to their profile and selected \"Mute account\" from the menu on their account." +msgstr "" + +#: src/view/com/auth/login/SetNewPasswordForm.tsx:81 +msgid "You will receive an email with a \"reset code.\" Enter that code here, then enter your new password." +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:43 +msgid "Your account" +msgstr "" + +#: src/view/com/auth/create/Step2.tsx:122 +msgid "Your birth date" +msgstr "" + +#: src/view/com/auth/create/state.ts:102 +msgid "Your email appears to be invalid." +msgstr "" + +#: src/view/com/modals/Waitlist.tsx:107 +msgid "Your email has been saved! We'll be in touch soon." +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:125 +msgid "Your email has been updated but not verified. As a next step, please verify your new email." +msgstr "" + +#: src/view/com/modals/VerifyEmail.tsx:100 +msgid "Your email has not yet been verified. This is an important security step which we recommend." +msgstr "" + +#: src/view/com/auth/create/Step3.tsx:42 +#: src/view/com/modals/ChangeHandle.tsx:270 +msgid "Your full handle will be" +msgstr "" + +#: src/view/com/auth/create/Step1.tsx:53 +msgid "Your hosting provider" +msgstr "" + +#: src/view/screens/Settings.tsx:402 +#: src/view/shell/desktop/RightNav.tsx:127 +#: src/view/shell/Drawer.tsx:517 +msgid "Your invite codes are hidden when logged in using an App Password" +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:59 +msgid "Your posts, likes, and blocks are public. Mutes are private." +msgstr "" + +#: src/view/com/modals/SwitchAccount.tsx:78 +msgid "Your profile" +msgstr "" + +#: src/view/com/auth/create/Step3.tsx:28 +msgid "Your user handle" +msgstr "" diff --git a/src/locale/locales/hi/messages.po b/src/locale/locales/hi/messages.po new file mode 100644 index 000000000..e442483db --- /dev/null +++ b/src/locale/locales/hi/messages.po @@ -0,0 +1,2289 @@ +msgid "" +msgstr "" +"POT-Creation-Date: 2023-11-06 12:28-0800\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: @lingui/cli\n" +"Language: hi\n" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Plural-Forms: \n" + +#: src/view/screens/Profile.tsx:214 +#~ msgid "- end of feed -" +#~ msgstr "- फ़ीड का अंत -" + +#: src/view/com/modals/SelfLabel.tsx:138 +#~ msgid ". This warning is only available for posts with media attached." +#~ msgstr "यह चेतावनी केवल मीडिया वाले पोस्ट के लिए उपलब्ध है।" + +#: src/view/shell/desktop/RightNav.tsx:158 +msgid "{0, plural, one {# invite code available} other {# invite codes available}}" +msgstr "" + +#: src/view/com/modals/Repost.tsx:44 +msgid "{0}" +msgstr "{0}" + +#: src/view/com/modals/CreateOrEditList.tsx:176 +msgid "{0} {purposeLabel} List" +msgstr "{0} {purposeLabel} सूची" + +#: src/view/shell/desktop/RightNav.tsx:141 +msgid "{invitesAvailable, plural, one {Invite codes: # available} other {Invite codes: # available}}" +msgstr "" + +#: src/view/screens/Settings.tsx:407 +#: src/view/shell/Drawer.tsx:521 +msgid "{invitesAvailable} invite code available" +msgstr "" + +#: src/view/screens/Settings.tsx:409 +#: src/view/shell/Drawer.tsx:523 +msgid "{invitesAvailable} invite codes available" +msgstr "" + +#: src/view/screens/Search/Search.tsx:86 +msgid "{message}" +msgstr "" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:30 +msgid "<0>Choose your</0><1>Recommended</1><2>Feeds</2>" +msgstr "<0>अपना</0><1>पसंदीदा</1><2>फ़ीड चुनें</2>" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:37 +msgid "<0>Follow some</0><1>Recommended</1><2>Users</2>" +msgstr "<0>कुछ</0><1>पसंदीदा उपयोगकर्ताओं</1><2>का अनुसरण करें</2>" + +#: src/view/com/modals/AddAppPasswords.tsx:132 +#~ msgid "<0>Here is your app password.</0> Use this to sign into the other app along with your handle." +#~ msgstr "<0>इधर आपका ऐप पासवर्ड है।</0> इसे अपने हैंडल के साथ दूसरे ऐप में साइन करने के लिए उपयोग करें।।" + +#: src/lib/hooks/useOTAUpdate.ts:16 +msgid "A new version of the app is available. Please update to continue using the app." +msgstr "ऐप का एक नया संस्करण उपलब्ध है. कृपया ऐप का उपयोग जारी रखने के लिए अपडेट करें।" + +#: src/view/com/modals/EditImage.tsx:299 +#: src/view/screens/Settings.tsx:417 +msgid "Accessibility" +msgstr "प्रवेर्शयोग्यता" + +#: src/view/com/auth/login/LoginForm.tsx:161 +#: src/view/screens/Settings.tsx:286 +msgid "Account" +msgstr "अकाउंट" + +#: src/view/com/util/AccountDropdownBtn.tsx:41 +msgid "Account options" +msgstr "अकाउंट के विकल्प" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:264 +#: src/view/com/modals/UserAddRemoveLists.tsx:187 +#: src/view/screens/ProfileList.tsx:675 +msgid "Add" +msgstr "ऐड करो" + +#: src/view/com/modals/SelfLabel.tsx:56 +msgid "Add a content warning" +msgstr "सामग्री चेतावनी जोड़ें" + +#: src/view/screens/ProfileList.tsx:665 +msgid "Add a user to this list" +msgstr "इस सूची में किसी को जोड़ें" + +#: src/view/screens/Settings.tsx:355 +#: src/view/screens/Settings.tsx:364 +msgid "Add account" +msgstr "अकाउंट जोड़ें" + +#: src/view/com/composer/photos/Gallery.tsx:119 +#: src/view/com/composer/photos/Gallery.tsx:180 +msgid "Add alt text" +msgstr "इस फ़ोटो में विवरण जोड़ें" + +#: src/view/com/modals/report/InputIssueDetails.tsx:41 +#: src/view/com/modals/report/Modal.tsx:191 +msgid "Add details" +msgstr "विवरण जोड़ें" + +#: src/view/com/modals/report/Modal.tsx:194 +msgid "Add details to report" +msgstr "रिपोर्ट करने के लिए विवरण जोड़ें" + +#: src/view/com/composer/Composer.tsx:418 +msgid "Add link card" +msgstr "लिंक कार्ड जोड़ें" + +#: src/view/com/composer/Composer.tsx:421 +msgid "Add link card:" +msgstr "लिंक कार्ड जोड़ें:" + +#: src/view/com/modals/ChangeHandle.tsx:415 +msgid "Add the following DNS record to your domain:" +msgstr "अपने डोमेन में निम्नलिखित DNS रिकॉर्ड जोड़ें:" + +#: src/view/com/profile/ProfileHeader.tsx:327 +msgid "Add to Lists" +msgstr "सूचियों में जोड़ें" + +#: src/view/screens/ProfileFeed.tsx:279 +msgid "Add to my feeds" +msgstr "इस फ़ीड को सहेजें" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:191 +#: src/view/com/modals/UserAddRemoveLists.tsx:122 +msgid "Added to list" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:164 +msgid "Adjust the number of likes a reply must have to be shown in your feed." +msgstr "पसंद की संख्या को समायोजित करें उत्तर को आपके फ़ीड में दिखाया जाना चाहिए।।" + +#: src/view/com/modals/SelfLabel.tsx:75 +msgid "Adult Content" +msgstr "वयस्क सामग्री" + +#: src/view/screens/Settings.tsx:569 +msgid "Advanced" +msgstr "विकसित" + +#: src/view/com/composer/photos/Gallery.tsx:130 +msgid "ALT" +msgstr "ALT" + +#: src/view/com/modals/EditImage.tsx:315 +msgid "Alt text" +msgstr "वैकल्पिक पाठ" + +#: src/view/com/composer/photos/Gallery.tsx:209 +msgid "Alt text describes images for blind and low-vision users, and helps give context to everyone." +msgstr "ऑल्ट टेक्स्ट अंधा और कम दृश्य लोगों के लिए छवियों का वर्णन करता है, और हर किसी को संदर्भ देने में मदद करता है।।" + +#: src/view/com/modals/VerifyEmail.tsx:110 +msgid "An email has been sent to {0}. It includes a confirmation code which you can enter below." +msgstr "{0} को ईमेल भेजा गया है। इसमें एक OTP कोड शामिल है जिसे आप नीचे दर्ज कर सकते हैं।।" + +#: src/view/com/modals/ChangeEmail.tsx:119 +msgid "An email has been sent to your previous address, {0}. It includes a confirmation code which you can enter below." +msgstr "{0} को ईमेल भेजा गया है। इसमें एक OTP कोड शामिल है जिसे आप नीचे दर्ज कर सकते हैं।।" + +#: src/view/com/notifications/FeedItem.tsx:236 +msgid "and" +msgstr "और" + +#: src/view/screens/LanguageSettings.tsx:92 +msgid "App Language" +msgstr "ऐप भाषा" + +#: src/view/screens/Settings.tsx:589 +msgid "App passwords" +msgstr "ऐप पासवर्ड" + +#: src/view/screens/AppPasswords.tsx:186 +msgid "App Passwords" +msgstr "ऐप पासवर्ड" + +#: src/view/screens/Settings.tsx:432 +msgid "Appearance" +msgstr "दिखावट" + +#: src/view/screens/AppPasswords.tsx:223 +msgid "Are you sure you want to delete the app password \"{name}\"?" +msgstr "क्या आप वाकई ऐप पासवर्ड \"{name}\" हटाना चाहते हैं?" + +#: src/view/com/composer/Composer.tsx:137 +msgid "Are you sure you'd like to discard this draft?" +msgstr "क्या आप वाकई इस ड्राफ्ट को हटाना करना चाहेंगे?" + +#: src/view/screens/ProfileList.tsx:345 +msgid "Are you sure?" +msgstr "क्या आप वास्तव में इसे करना चाहते हैं?" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:188 +msgid "Are you sure? This cannot be undone." +msgstr "क्या आप वास्तव में इसे करना चाहते हैं? इसे असंपादित नहीं किया जा सकता है।" + +#: src/view/com/modals/SelfLabel.tsx:123 +msgid "Artistic or non-erotic nudity." +msgstr "कलात्मक या गैर-कामुक नग्नता।।" + +#: src/view/com/auth/create/CreateAccount.tsx:145 +#: src/view/com/auth/login/ChooseAccountForm.tsx:151 +#: src/view/com/auth/login/ForgotPasswordForm.tsx:166 +#: src/view/com/auth/login/LoginForm.tsx:251 +#: src/view/com/auth/login/SetNewPasswordForm.tsx:148 +#: src/view/com/modals/report/InputIssueDetails.tsx:45 +#: src/view/com/post-thread/PostThread.tsx:376 +#: src/view/com/post-thread/PostThread.tsx:426 +#: src/view/com/post-thread/PostThread.tsx:434 +#: src/view/com/profile/ProfileHeader.tsx:633 +msgid "Back" +msgstr "वापस" + +#: src/view/screens/Settings.tsx:461 +msgid "Basics" +msgstr "मूल बातें" + +#: src/view/com/auth/create/Step2.tsx:131 +#: src/view/com/modals/BirthDateSettings.tsx:72 +msgid "Birthday" +msgstr "जन्मदिन" + +#: src/view/screens/Settings.tsx:312 +msgid "Birthday:" +msgstr "जन्मदिन:" + +#: src/view/com/profile/ProfileHeader.tsx:256 +#: src/view/com/profile/ProfileHeader.tsx:363 +msgid "Block Account" +msgstr "खाता ब्लॉक करें" + +#: src/view/screens/ProfileList.tsx:446 +msgid "Block accounts" +msgstr "खाता ब्लॉक करें" + +#: src/view/screens/ProfileList.tsx:302 +msgid "Block these accounts?" +msgstr "खाता ब्लॉक करें?" + +#: src/view/screens/Moderation.tsx:109 +msgid "Blocked accounts" +msgstr "ब्लॉक किए गए खाते" + +#: src/view/screens/ModerationBlockedAccounts.tsx:106 +msgid "Blocked Accounts" +msgstr "ब्लॉक किए गए खाते" + +#: src/view/com/profile/ProfileHeader.tsx:258 +msgid "Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you." +msgstr "अवरुद्ध खाते आपके थ्रेड्स में उत्तर नहीं दे सकते, आपका उल्लेख नहीं कर सकते, या अन्यथा आपके साथ बातचीत नहीं कर सकते।" + +#: src/view/screens/ModerationBlockedAccounts.tsx:114 +msgid "Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you. You will not see their content and they will be prevented from seeing yours." +msgstr "अवरुद्ध खाते आपके थ्रेड्स में उत्तर नहीं दे सकते, आपका उल्लेख नहीं कर सकते, या अन्यथा आपके साथ बातचीत नहीं कर सकते। आप उनकी सामग्री नहीं देख पाएंगे और उन्हें आपकी सामग्री देखने से रोका जाएगा।" + +#: src/view/com/post-thread/PostThread.tsx:237 +msgid "Blocked post." +msgstr "ब्लॉक पोस्ट।" + +#: src/view/screens/ProfileList.tsx:304 +msgid "Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you." +msgstr "अवरोधन सार्वजनिक है. अवरुद्ध खाते आपके थ्रेड्स में उत्तर नहीं दे सकते, आपका उल्लेख नहीं कर सकते, या अन्यथा आपके साथ बातचीत नहीं कर सकते।" + +#: src/view/com/auth/SplashScreen.tsx:26 +msgid "Bluesky" +msgstr "Bluesky" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:80 +msgid "Bluesky is flexible." +msgstr "Bluesky लचीला है।।" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:69 +msgid "Bluesky is open." +msgstr "Bluesky खुला है।।" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:56 +msgid "Bluesky is public." +msgstr "Bluesky सार्वजनिक है।।" + +#: src/view/com/modals/Waitlist.tsx:70 +msgid "Bluesky uses invites to build a healthier community. If you don't know anybody with an invite, you can sign up for the waitlist and we'll send one soon." +msgstr "ब्लूस्की एक स्वस्थ समुदाय बनाने के लिए आमंत्रित करता है। यदि आप किसी को आमंत्रित नहीं करते हैं, तो आप प्रतीक्षा सूची के लिए साइन अप कर सकते हैं और हम जल्द ही एक भेज देंगे।।" + +#: src/view/com/modals/ServerInput.tsx:78 +msgid "Bluesky.Social" +msgstr "Bluesky.Social" + +#: src/view/screens/Settings.tsx:718 +msgid "Build version {0} {1}" +msgstr "Build version {0} {1}" + +#: src/view/com/composer/photos/OpenCameraBtn.tsx:60 +#: src/view/com/util/UserAvatar.tsx:217 +#: src/view/com/util/UserBanner.tsx:38 +msgid "Camera" +msgstr "कैमरा" + +#: src/view/com/modals/AddAppPasswords.tsx:214 +msgid "Can only contain letters, numbers, spaces, dashes, and underscores. Must be at least 4 characters long, but no more than 32 characters long." +msgstr "केवल अक्षर, संख्या, रिक्त स्थान, डैश और अंडरस्कोर हो सकते हैं। कम से कम 4 अक्षर लंबा होना चाहिए, लेकिन 32 अक्षरों से अधिक लंबा नहीं होना चाहिए।।" + +#: src/view/com/composer/Composer.tsx:271 +#: src/view/com/composer/Composer.tsx:274 +#: src/view/com/modals/AltImage.tsx:127 +#: src/view/com/modals/ChangeEmail.tsx:218 +#: src/view/com/modals/ChangeEmail.tsx:220 +#: src/view/com/modals/Confirm.tsx:88 +#: src/view/com/modals/CreateOrEditList.tsx:267 +#: src/view/com/modals/CreateOrEditList.tsx:272 +#: src/view/com/modals/DeleteAccount.tsx:150 +#: src/view/com/modals/DeleteAccount.tsx:223 +#: src/view/com/modals/EditImage.tsx:323 +#: src/view/com/modals/EditProfile.tsx:248 +#: src/view/com/modals/LinkWarning.tsx:85 +#: src/view/com/modals/Repost.tsx:73 +#: src/view/com/modals/Waitlist.tsx:136 +#: src/view/screens/Search/Search.tsx:586 +#: src/view/shell/desktop/Search.tsx:181 +msgid "Cancel" +msgstr "कैंसिल" + +#: src/view/com/modals/DeleteAccount.tsx:146 +#: src/view/com/modals/DeleteAccount.tsx:219 +msgid "Cancel account deletion" +msgstr "अकाउंट बंद मत करो" + +#: src/view/com/modals/AltImage.tsx:122 +msgid "Cancel add image alt text" +msgstr "ऑल्ट टेक्स्ट मत जोड़ें" + +#: src/view/com/modals/ChangeHandle.tsx:149 +msgid "Cancel change handle" +msgstr "नाम मत बदलो" + +#: src/view/com/modals/crop-image/CropImage.web.tsx:134 +msgid "Cancel image crop" +msgstr "तस्वीर को क्रॉप मत करो" + +#: src/view/com/modals/EditProfile.tsx:243 +msgid "Cancel profile editing" +msgstr "प्रोफ़ाइल संपादन मत करो" + +#: src/view/com/modals/Repost.tsx:64 +msgid "Cancel quote post" +msgstr "कोटे पोस्ट मत करो" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:87 +#: src/view/shell/desktop/Search.tsx:177 +msgid "Cancel search" +msgstr "खोज मत करो" + +#: src/view/com/modals/Waitlist.tsx:132 +msgid "Cancel waitlist signup" +msgstr "प्रतीक्षा सूची पंजीकरण मत करो" + +#: src/view/screens/Settings.tsx:306 +msgid "Change" +msgstr "परिवर्तन" + +#: src/view/screens/Settings.tsx:601 +#: src/view/screens/Settings.tsx:610 +msgid "Change handle" +msgstr "हैंडल बदलें" + +#: src/view/com/modals/ChangeHandle.tsx:161 +msgid "Change Handle" +msgstr "हैंडल बदलें" + +#: src/view/com/modals/VerifyEmail.tsx:133 +msgid "Change my email" +msgstr "मेरा ईमेल बदलें" + +#: src/view/com/modals/ChangeEmail.tsx:109 +msgid "Change Your Email" +msgstr "मेरा ईमेल बदलें" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:121 +msgid "Check out some recommended feeds. Tap + to add them to your list of pinned feeds." +msgstr "कुछ अनुशंसित फ़ीड देखें. उन्हें अपनी पिन की गई फ़ीड की सूची में जोड़ने के लिए + टैप करें।" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:185 +msgid "Check out some recommended users. Follow them to see similar users." +msgstr "कुछ अनुशंसित उपयोगकर्ताओं की जाँच करें। ऐसे ही उपयोगकर्ता देखने के लिए उनका अनुसरण करें।" + +#: src/view/com/modals/DeleteAccount.tsx:163 +msgid "Check your inbox for an email with the confirmation code to enter below:" +msgstr "नीचे प्रवेश करने के लिए OTP कोड के साथ एक ईमेल के लिए अपने इनबॉक्स की जाँच करें:" + +#: src/view/com/modals/ServerInput.tsx:38 +msgid "Choose Service" +msgstr "सेवा चुनें" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:83 +msgid "Choose the algorithms that power your experience with custom feeds." +msgstr "उन एल्गोरिदम का चयन करें जो कस्टम फीड्स के साथ अपने अनुभव को शक्ति देते हैं।।" + +#: src/view/com/auth/create/Step2.tsx:106 +msgid "Choose your password" +msgstr "अपना पासवर्ड चुनें" + +#: src/view/screens/Settings.tsx:694 +msgid "Clear all legacy storage data" +msgstr "" + +#: src/view/screens/Settings.tsx:696 +msgid "Clear all legacy storage data (restart after this)" +msgstr "" + +#: src/view/screens/Settings.tsx:706 +msgid "Clear all storage data" +msgstr "" + +#: src/view/screens/Settings.tsx:708 +msgid "Clear all storage data (restart after this)" +msgstr "" + +#: src/view/com/util/forms/SearchInput.tsx:73 +#: src/view/screens/Search/Search.tsx:571 +msgid "Clear search query" +msgstr "खोज क्वेरी साफ़ करें" + +#: src/view/com/auth/login/PasswordUpdatedForm.tsx:38 +msgid "Close alert" +msgstr "चेतावनी को बंद करो" + +#: src/view/com/util/BottomSheetCustomBackdrop.tsx:33 +msgid "Close bottom drawer" +msgstr "बंद करो" + +#: src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx:26 +msgid "Close image" +msgstr "छवि बंद करें" + +#: src/view/com/lightbox/Lightbox.web.tsx:112 +msgid "Close image viewer" +msgstr "छवि बंद करें" + +#: src/view/shell/index.web.tsx:49 +msgid "Close navigation footer" +msgstr "नेविगेशन पाद बंद करें" + +#: src/view/screens/CommunityGuidelines.tsx:32 +msgid "Community Guidelines" +msgstr "समुदाय दिशानिर्देश" + +#: src/view/com/composer/Prompt.tsx:24 +msgid "Compose reply" +msgstr "जवाब लिखो" + +#: src/view/com/modals/Confirm.tsx:75 +#: src/view/com/modals/SelfLabel.tsx:154 +#: src/view/com/modals/VerifyEmail.tsx:217 +#: src/view/screens/PreferencesHomeFeed.tsx:299 +#: src/view/screens/PreferencesThreads.tsx:153 +msgid "Confirm" +msgstr "हो गया" + +#: src/view/com/modals/ChangeEmail.tsx:193 +#: src/view/com/modals/ChangeEmail.tsx:195 +msgid "Confirm Change" +msgstr "बदलाव की पुष्टि करें" + +#: src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx:34 +msgid "Confirm content language settings" +msgstr "सामग्री भाषा सेटिंग्स की पुष्टि करें" + +#: src/view/com/modals/DeleteAccount.tsx:209 +msgid "Confirm delete account" +msgstr "खाते को हटा दें" + +#: src/view/com/modals/ChangeEmail.tsx:157 +#: src/view/com/modals/DeleteAccount.tsx:176 +#: src/view/com/modals/VerifyEmail.tsx:151 +msgid "Confirmation code" +msgstr "OTP कोड" + +#: src/view/com/auth/create/CreateAccount.tsx:178 +#: src/view/com/auth/login/LoginForm.tsx:270 +msgid "Connecting..." +msgstr "कनेक्टिंग ..।" + +#: src/view/screens/Moderation.tsx:67 +msgid "Content filtering" +msgstr "सामग्री फ़िल्टरिंग" + +#: src/view/com/modals/ContentFilteringSettings.tsx:44 +msgid "Content Filtering" +msgstr "सामग्री फ़िल्टरिंग" + +#: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:74 +#: src/view/screens/LanguageSettings.tsx:273 +msgid "Content Languages" +msgstr "सामग्री भाषा" + +#: src/view/com/util/moderation/ScreenHider.tsx:69 +msgid "Content Warning" +msgstr "सामग्री चेतावनी" + +#: src/view/com/composer/labels/LabelsBtn.tsx:31 +msgid "Content warnings" +msgstr "सामग्री चेतावनी" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:148 +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:209 +msgid "Continue" +msgstr "आगे बढ़ें" + +#: src/view/com/modals/AddAppPasswords.tsx:193 +#: src/view/com/modals/InviteCodes.tsx:178 +msgid "Copied" +msgstr "कॉपी कर ली" + +#: src/view/com/modals/AddAppPasswords.tsx:186 +msgid "Copy" +msgstr "कॉपी" + +#: src/view/screens/ProfileList.tsx:375 +msgid "Copy link to list" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:126 +msgid "Copy link to post" +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:312 +msgid "Copy link to profile" +msgstr "" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:112 +msgid "Copy post text" +msgstr "पोस्ट टेक्स्ट कॉपी करें" + +#: src/view/screens/CopyrightPolicy.tsx:29 +msgid "Copyright Policy" +msgstr "कॉपीराइट नीति" + +#: src/view/screens/ProfileFeed.tsx:102 +msgid "Could not load feed" +msgstr "फ़ीड लोड नहीं कर सकता" + +#: src/view/screens/ProfileList.tsx:752 +msgid "Could not load list" +msgstr "सूची लोड नहीं कर सकता" + +#: src/view/com/auth/SplashScreen.tsx:41 +msgid "Create a new account" +msgstr "नया खाता बनाएं" + +#: src/view/com/auth/create/CreateAccount.tsx:124 +msgid "Create Account" +msgstr "खाता बनाएँ" + +#: src/view/com/auth/SplashScreen.tsx:38 +msgid "Create new account" +msgstr "नया खाता बनाएं" + +#: src/view/screens/AppPasswords.tsx:248 +msgid "Created {0}" +msgstr "बनाया गया {0}" + +#: src/view/com/modals/ChangeHandle.tsx:387 +#: src/view/com/modals/ServerInput.tsx:102 +msgid "Custom domain" +msgstr "कस्टम डोमेन" + +#: src/view/screens/Settings.tsx:615 +msgid "Danger Zone" +msgstr "खतरा क्षेत्र" + +#: src/view/screens/Settings.tsx:411 +#~ msgid "Dark" +#~ msgstr "डार्क मोड" + +#: src/view/screens/Settings.tsx:622 +msgid "Delete account" +msgstr "खाता हटाएं" + +#: src/view/com/modals/DeleteAccount.tsx:83 +msgid "Delete Account" +msgstr "खाता हटाएं" + +#: src/view/screens/AppPasswords.tsx:221 +#: src/view/screens/AppPasswords.tsx:241 +msgid "Delete app password" +msgstr "अप्प पासवर्ड हटाएं" + +#: src/view/screens/ProfileList.tsx:344 +#: src/view/screens/ProfileList.tsx:402 +msgid "Delete List" +msgstr "सूची हटाएँ" + +#: src/view/com/modals/DeleteAccount.tsx:212 +msgid "Delete my account" +msgstr "मेरा खाता हटाएं" + +#: src/view/screens/Settings.tsx:632 +msgid "Delete my account…" +msgstr "मेरा खाता हटाएं…" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:183 +msgid "Delete post" +msgstr "पोस्ट को हटाएं" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:187 +msgid "Delete this post?" +msgstr "इस पोस्ट को डीलीट करें?" + +#: src/view/com/post-thread/PostThread.tsx:229 +msgid "Deleted post." +msgstr "यह पोस्ट मिटाई जा चुकी है" + +#: src/view/com/modals/CreateOrEditList.tsx:218 +#: src/view/com/modals/CreateOrEditList.tsx:234 +#: src/view/com/modals/EditProfile.tsx:197 +#: src/view/com/modals/EditProfile.tsx:209 +msgid "Description" +msgstr "विवरण" + +#: src/view/com/auth/create/Step1.tsx:96 +msgid "Dev Server" +msgstr "देव सर्वर" + +#: src/view/screens/Settings.tsx:637 +msgid "Developer Tools" +msgstr "डेवलपर उपकरण" + +#: src/view/com/composer/Composer.tsx:138 +msgid "Discard" +msgstr "" + +#: src/view/com/composer/Composer.tsx:132 +msgid "Discard draft" +msgstr "ड्राफ्ट हटाएं" + +#: src/view/screens/Feeds.tsx:405 +msgid "Discover new feeds" +msgstr "नए फ़ीड की खोज करें" + +#: src/view/com/modals/EditProfile.tsx:191 +msgid "Display name" +msgstr "नाम" + +#: src/view/com/modals/EditProfile.tsx:179 +msgid "Display Name" +msgstr "प्रदर्शन का नाम" + +#: src/view/com/modals/ChangeHandle.tsx:485 +msgid "Domain verified!" +msgstr "डोमेन सत्यापित!" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:86 +#: src/view/com/modals/ContentFilteringSettings.tsx:88 +#: src/view/com/modals/ContentFilteringSettings.tsx:96 +#: src/view/com/modals/crop-image/CropImage.web.tsx:152 +#: src/view/com/modals/EditImage.tsx:333 +#: src/view/com/modals/ListAddRemoveUsers.tsx:142 +#: src/view/com/modals/SelfLabel.tsx:157 +#: src/view/com/modals/UserAddRemoveLists.tsx:75 +#: src/view/screens/PreferencesHomeFeed.tsx:302 +#: src/view/screens/PreferencesThreads.tsx:156 +msgid "Done" +msgstr "खत्म" + +#: src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx:42 +msgid "Done{extraText}" +msgstr "खत्म {extraText}" + +#: src/view/com/modals/InviteCodes.tsx:94 +msgid "Each code works once. You'll receive more invite codes periodically." +msgstr "प्रत्येक कोड एक बार काम करता है। आपको समय-समय पर अधिक आमंत्रण कोड प्राप्त होंगे।" + +#: src/view/com/composer/photos/Gallery.tsx:144 +#: src/view/com/modals/EditImage.tsx:207 +msgid "Edit image" +msgstr "छवि संपादित करें" + +#: src/view/screens/ProfileList.tsx:390 +msgid "Edit list details" +msgstr "सूची विवरण संपादित करें" + +#: src/view/screens/Feeds.tsx:367 +#: src/view/screens/SavedFeeds.tsx:85 +msgid "Edit My Feeds" +msgstr "मेरी फ़ीड संपादित करें" + +#: src/view/com/modals/EditProfile.tsx:151 +msgid "Edit my profile" +msgstr "मेरी प्रोफ़ाइल संपादित करें" + +#: src/view/com/profile/ProfileHeader.tsx:425 +msgid "Edit profile" +msgstr "मेरी प्रोफ़ाइल संपादित करें" + +#: src/view/com/profile/ProfileHeader.tsx:428 +msgid "Edit Profile" +msgstr "मेरी प्रोफ़ाइल संपादित करें" + +#: src/view/screens/Feeds.tsx:330 +msgid "Edit Saved Feeds" +msgstr "एडिट सेव्ड फीड" + +#: src/view/com/auth/create/Step2.tsx:90 +#: src/view/com/auth/login/ForgotPasswordForm.tsx:148 +#: src/view/com/modals/ChangeEmail.tsx:141 +#: src/view/com/modals/Waitlist.tsx:88 +msgid "Email" +msgstr "ईमेल" + +#: src/view/com/auth/create/Step2.tsx:81 +msgid "Email address" +msgstr "ईमेल" + +#: src/view/com/modals/ChangeEmail.tsx:111 +msgid "Email Updated" +msgstr "ईमेल अपडेट किया गया" + +#: src/view/screens/Settings.tsx:290 +msgid "Email:" +msgstr "ईमेल:" + +#: src/view/screens/PreferencesHomeFeed.tsx:138 +msgid "Enable this setting to only see replies between people you follow." +msgstr "इस सेटिंग को केवल उन लोगों के बीच जवाब देखने में सक्षम करें जिन्हें आप फॉलो करते हैं।।" + +#: src/view/com/auth/create/Step1.tsx:71 +msgid "Enter the address of your provider:" +msgstr "अपने प्रदाता का पता दर्ज करें:" + +#: src/view/com/modals/ChangeHandle.tsx:369 +msgid "Enter the domain you want to use" +msgstr "आप जिस डोमेन का उपयोग करना चाहते हैं उसे दर्ज करें" + +#: src/view/com/auth/login/ForgotPasswordForm.tsx:101 +msgid "Enter the email you used to create your account. We'll send you a \"reset code\" so you can set a new password." +msgstr "वह ईमेल दर्ज करें जिसका उपयोग आपने अपना खाता बनाने के लिए किया था। हम आपको एक \"reset code\" भेजेंगे ताकि आप एक नया पासवर्ड सेट कर सकें।" + +#: src/view/com/auth/create/Step2.tsx:86 +msgid "Enter your email address" +msgstr "अपना ईमेल पता दर्ज करें" + +#: src/view/com/modals/ChangeEmail.tsx:117 +msgid "Enter your new email address below." +msgstr "नीचे अपना नया ईमेल पता दर्ज करें।।" + +#: src/view/com/auth/login/Login.tsx:83 +msgid "Enter your username and password" +msgstr "अपने यूज़रनेम और पासवर्ड दर्ज करें" + +#: src/view/screens/Search/Search.tsx:104 +msgid "Error:" +msgstr "" + +#: src/view/com/lightbox/Lightbox.web.tsx:156 +msgid "Expand alt text" +msgstr "ऑल्ट टेक्स्ट" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:109 +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:141 +msgid "Failed to load recommended feeds" +msgstr "अनुशंसित फ़ीड लोड करने में विफल" + +#: src/view/screens/Feeds.tsx:559 +msgid "Feed offline" +msgstr "फ़ीड ऑफ़लाइन है" + +#: src/view/com/feeds/FeedPage.tsx:132 +msgid "Feed Preferences" +msgstr "फ़ीड प्राथमिकता" + +#: src/view/shell/desktop/RightNav.tsx:64 +#: src/view/shell/Drawer.tsx:410 +msgid "Feedback" +msgstr "प्रतिक्रिया" + +#: src/view/screens/Feeds.tsx:475 +#: src/view/shell/bottom-bar/BottomBar.tsx:168 +#: src/view/shell/desktop/LeftNav.tsx:341 +#: src/view/shell/Drawer.tsx:327 +#: src/view/shell/Drawer.tsx:328 +msgid "Feeds" +msgstr "सभी फ़ीड" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:57 +msgid "Feeds are created by users to curate content. Choose some feeds that you find interesting." +msgstr "सामग्री को व्यवस्थित करने के लिए उपयोगकर्ताओं द्वारा फ़ीड बनाए जाते हैं। कुछ फ़ीड चुनें जो आपको दिलचस्प लगें।" + +#: src/view/screens/SavedFeeds.tsx:156 +msgid "Feeds are custom algorithms that users build with a little coding expertise. <0/> for more information." +msgstr "फ़ीड कस्टम एल्गोरिदम हैं जिन्हें उपयोगकर्ता थोड़ी कोडिंग विशेषज्ञता के साथ बनाते हैं। <0/> अधिक जानकारी के लिए." + +#: src/view/com/auth/onboarding/RecommendedFollowsItem.tsx:150 +msgid "Finding similar accounts..." +msgstr "मिलते-जुलते खाते ढूँढना" + +#: src/view/screens/PreferencesHomeFeed.tsx:102 +msgid "Fine-tune the content you see on your home screen." +msgstr "अपने मुख्य फ़ीड की स्क्रीन पर दिखाई देने वाली सामग्री को ठीक करें।।" + +#: src/view/screens/PreferencesThreads.tsx:60 +msgid "Fine-tune the discussion threads." +msgstr "चर्चा धागे को ठीक-ट्यून करें।।" + +#: src/view/com/profile/ProfileHeader.tsx:510 +msgid "Follow" +msgstr "फॉलो" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:64 +msgid "Follow some users to get started. We can recommend you more users based on who you find interesting." +msgstr "आरंभ करने के लिए कुछ उपयोगकर्ताओं का अनुसरण करें. आपको कौन दिलचस्प लगता है, इसके आधार पर हम आपको और अधिक उपयोगकर्ताओं की अनुशंसा कर सकते हैं।" + +#: src/view/screens/PreferencesHomeFeed.tsx:145 +msgid "Followed users only" +msgstr "केवल वे यूजर को फ़ॉलो किया गया" + +#: src/view/screens/ProfileFollowers.tsx:25 +msgid "Followers" +msgstr "यह यूजर आपका फ़ोलो करता है" + +#: src/view/com/profile/ProfileHeader.tsx:596 +msgid "following" +msgstr "फोल्लोविंग" + +#: src/view/com/profile/ProfileHeader.tsx:494 +#: src/view/screens/ProfileFollows.tsx:25 +msgid "Following" +msgstr "फोल्लोविंग" + +#: src/view/com/profile/ProfileHeader.tsx:543 +msgid "Follows you" +msgstr "यह यूजर आपका फ़ोलो करता है" + +#: src/view/com/modals/DeleteAccount.tsx:107 +msgid "For security reasons, we'll need to send a confirmation code to your email address." +msgstr "सुरक्षा कारणों के लिए, हमें आपके ईमेल पते पर एक OTP कोड भेजने की आवश्यकता होगी।।" + +#: src/view/com/modals/AddAppPasswords.tsx:207 +msgid "For security reasons, you won't be able to view this again. If you lose this password, you'll need to generate a new one." +msgstr "सुरक्षा कारणों के लिए, आप इसे फिर से देखने में सक्षम नहीं होंगे। यदि आप इस पासवर्ड को खो देते हैं, तो आपको एक नया उत्पन्न करना होगा।।" + +#: src/view/com/auth/login/LoginForm.tsx:233 +msgid "Forgot" +msgstr "भूल" + +#: src/view/com/auth/login/LoginForm.tsx:230 +msgid "Forgot password" +msgstr "पासवर्ड भूल गए" + +#: src/view/com/auth/login/Login.tsx:111 +#: src/view/com/auth/login/Login.tsx:127 +msgid "Forgot Password" +msgstr "पासवर्ड भूल गए" + +#: src/view/com/composer/photos/SelectPhotoBtn.tsx:43 +msgid "Gallery" +msgstr "गैलरी" + +#: src/view/com/modals/VerifyEmail.tsx:175 +msgid "Get Started" +msgstr "प्रारंभ करें" + +#: src/view/com/auth/LoggedOut.tsx:53 +#: src/view/com/auth/LoggedOut.tsx:54 +#: src/view/com/util/moderation/ScreenHider.tsx:105 +#: src/view/shell/desktop/LeftNav.tsx:106 +msgid "Go back" +msgstr "वापस जाओ" + +#: src/view/screens/ProfileFeed.tsx:111 +#: src/view/screens/ProfileFeed.tsx:116 +#: src/view/screens/ProfileList.tsx:761 +#: src/view/screens/ProfileList.tsx:766 +msgid "Go Back" +msgstr "वापस जाओ" + +#: src/view/com/auth/login/ForgotPasswordForm.tsx:181 +#: src/view/com/auth/login/LoginForm.tsx:280 +#: src/view/com/auth/login/SetNewPasswordForm.tsx:163 +msgid "Go to next" +msgstr "अगला" + +#: src/view/com/modals/ChangeHandle.tsx:265 +msgid "Handle" +msgstr "हैंडल" + +#: src/view/shell/desktop/RightNav.tsx:93 +#: src/view/shell/Drawer.tsx:420 +msgid "Help" +msgstr "सहायता" + +#: src/view/com/modals/AddAppPasswords.tsx:148 +msgid "Here is your app password." +msgstr "यहां आपका ऐप पासवर्ड है." + +#: src/view/com/notifications/FeedItem.tsx:316 +msgid "Hide" +msgstr "इसे छिपाएं" + +#: src/view/com/notifications/FeedItem.tsx:308 +msgid "Hide user list" +msgstr "उपयोगकर्ता सूची छुपाएँ" + +#: src/view/com/posts/FeedErrorMessage.tsx:101 +msgid "Hmm, some kind of issue occured when contacting the feed server. Please let the feed owner know about this issue." +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:89 +msgid "Hmm, the feed server appears to be misconfigured. Please let the feed owner know about this issue." +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:95 +msgid "Hmm, the feed server appears to be offline. Please let the feed owner know about this issue." +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:92 +msgid "Hmm, the feed server gave a bad response. Please let the feed owner know about this issue." +msgstr "" + +#: src/view/com/posts/FeedErrorMessage.tsx:86 +msgid "Hmmm, we're having trouble finding this feed. It may have been deleted." +msgstr "" + +#: src/view/shell/bottom-bar/BottomBar.tsx:124 +#: src/view/shell/desktop/LeftNav.tsx:305 +#: src/view/shell/Drawer.tsx:274 +#: src/view/shell/Drawer.tsx:275 +msgid "Home" +msgstr "होम फीड" + +#: src/view/com/pager/FeedsTabBarMobile.tsx:99 +#: src/view/screens/PreferencesHomeFeed.tsx:95 +#: src/view/screens/Settings.tsx:481 +msgid "Home Feed Preferences" +msgstr "होम फ़ीड प्राथमिकताएं" + +#: src/view/com/auth/login/ForgotPasswordForm.tsx:114 +msgid "Hosting provider" +msgstr "होस्टिंग प्रदाता" + +#: src/view/com/auth/create/Step1.tsx:76 +#: src/view/com/auth/create/Step1.tsx:81 +msgid "Hosting provider address" +msgstr "होस्टिंग प्रदाता पता" + +#: src/view/com/modals/VerifyEmail.tsx:200 +msgid "I have a code" +msgstr "मेरे पास एक OTP कोड है" + +#: src/view/com/modals/ChangeHandle.tsx:281 +msgid "I have my own domain" +msgstr "मेरे पास अपना डोमेन है" + +#: src/view/com/modals/SelfLabel.tsx:127 +msgid "If none are selected, suitable for all ages." +msgstr "यदि किसी को चुना जाता है, तो सभी उम्र के लिए उपयुक्त है।।" + +#: src/view/com/modals/AltImage.tsx:96 +msgid "Image alt text" +msgstr "छवि alt पाठ" + +#: src/view/com/util/UserAvatar.tsx:304 +#: src/view/com/util/UserBanner.tsx:116 +msgid "Image options" +msgstr "छवि विकल्प" + +#: src/view/com/search/Suggestions.tsx:104 +#: src/view/com/search/Suggestions.tsx:115 +#~ msgid "In Your Network" +#~ msgstr "आपके नेटवर्क में" + +#: src/view/com/auth/login/LoginForm.tsx:113 +msgid "Invalid username or password" +msgstr "अवैध उपयोगकर्ता नाम या पासवर्ड" + +#: src/view/screens/Settings.tsx:383 +msgid "Invite" +msgstr "आमंत्रण भेजो" + +#: src/view/com/modals/InviteCodes.tsx:91 +#: src/view/screens/Settings.tsx:371 +msgid "Invite a Friend" +msgstr "एक दोस्त को आमंत्रित करें" + +#: src/view/com/auth/create/Step2.tsx:57 +msgid "Invite code" +msgstr "आमंत्रण कोड" + +#: src/view/com/auth/create/state.ts:136 +msgid "Invite code not accepted. Check that you input it correctly and try again." +msgstr "" + +#: src/view/shell/Drawer.tsx:502 +msgid "Invite codes: {invitesAvailable} available" +msgstr "" + +#: src/view/com/modals/Waitlist.tsx:67 +msgid "Join the waitlist" +msgstr "प्रतीक्षा सूची में शामिल हों" + +#: src/view/com/auth/create/Step2.tsx:68 +#: src/view/com/auth/create/Step2.tsx:72 +msgid "Join the waitlist." +msgstr "प्रतीक्षा सूची में शामिल हों।।" + +#: src/view/com/modals/Waitlist.tsx:124 +msgid "Join Waitlist" +msgstr "वेटरलिस्ट में शामिल हों" + +#: src/view/com/composer/select-language/SelectLangBtn.tsx:104 +msgid "Language selection" +msgstr "अपनी भाषा चुने" + +#: src/view/screens/LanguageSettings.tsx:86 +msgid "Language Settings" +msgstr "भाषा सेटिंग्स" + +#: src/view/screens/Settings.tsx:541 +msgid "Languages" +msgstr "भाषा" + +#: src/view/com/util/moderation/PostAlerts.tsx:47 +#: src/view/com/util/moderation/ProfileHeaderAlerts.tsx:55 +#: src/view/com/util/moderation/ScreenHider.tsx:88 +msgid "Learn More" +msgstr "अधिक जानें" + +#: src/view/com/util/moderation/ContentHider.tsx:75 +#: src/view/com/util/moderation/PostAlerts.tsx:40 +#: src/view/com/util/moderation/PostHider.tsx:76 +#: src/view/com/util/moderation/ProfileHeaderAlerts.tsx:47 +#: src/view/com/util/moderation/ScreenHider.tsx:85 +msgid "Learn more about this warning" +msgstr "इस चेतावनी के बारे में अधिक जानें" + +#: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:82 +msgid "Leave them all unchecked to see any language." +msgstr "उन्हें किसी भी भाषा को देखने के लिए अनचेक छोड़ दें।।" + +#: src/view/com/modals/LinkWarning.tsx:49 +msgid "Leaving Bluesky" +msgstr "लीविंग Bluesky" + +#: src/view/com/auth/login/Login.tsx:112 +#: src/view/com/auth/login/Login.tsx:128 +msgid "Let's get your password reset!" +msgstr "चलो अपना पासवर्ड रीसेट करें!" + +#: src/view/com/util/UserAvatar.tsx:241 +#: src/view/com/util/UserBanner.tsx:60 +msgid "Library" +msgstr "चित्र पुस्तकालय" + +#: src/view/screens/Settings.tsx:405 +#~ msgid "Light" +#~ msgstr "लाइट मोड" + +#: src/view/screens/ProfileFeed.tsx:627 +msgid "Like this feed" +msgstr "इस फ़ीड को लाइक करो" + +#: src/view/screens/PostLikedBy.tsx:27 +#: src/view/screens/ProfileFeedLikedBy.tsx:27 +msgid "Liked by" +msgstr "इन यूजर ने लाइक किया है" + +#: src/view/com/modals/CreateOrEditList.tsx:186 +msgid "List Avatar" +msgstr "सूची अवतार" + +#: src/view/com/modals/CreateOrEditList.tsx:199 +msgid "List Name" +msgstr "सूची का नाम" + +#: src/view/shell/desktop/LeftNav.tsx:381 +#: src/view/shell/Drawer.tsx:338 +#: src/view/shell/Drawer.tsx:339 +msgid "Lists" +msgstr "सूची" + +#: src/view/com/post-thread/PostThread.tsx:246 +#: src/view/com/post-thread/PostThread.tsx:254 +msgid "Load more posts" +msgstr "अधिक पोस्ट लोड करें" + +#: src/view/screens/Notifications.tsx:129 +msgid "Load new notifications" +msgstr "नई सूचनाएं लोड करें" + +#: src/view/com/feeds/FeedPage.tsx:177 +msgid "Load new posts" +msgstr "नई पोस्ट लोड करें" + +#: src/view/com/composer/text-input/mobile/Autocomplete.tsx:95 +msgid "Loading..." +msgstr "" + +#: src/view/com/modals/ServerInput.tsx:50 +msgid "Local dev server" +msgstr "स्थानीय देव सर्वर" + +#: src/view/com/auth/login/ChooseAccountForm.tsx:133 +msgid "Login to account that is not listed" +msgstr "उस खाते में लॉग इन करें जो सूचीबद्ध नहीं है" + +#: src/view/screens/ProfileFeed.tsx:479 +msgid "Looks like this feed is only available to users with a Bluesky account. Please sign up or sign in to view this feed!" +msgstr "" + +#: src/view/com/modals/LinkWarning.tsx:63 +msgid "Make sure this is where you intend to go!" +msgstr "यह सुनिश्चित करने के लिए कि आप कहाँ जाना चाहते हैं!" + +#: src/view/screens/Search/Search.tsx:531 +msgid "Menu" +msgstr "मेनू" + +#: src/view/screens/Moderation.tsx:51 +#: src/view/screens/Settings.tsx:563 +#: src/view/shell/desktop/LeftNav.tsx:399 +#: src/view/shell/Drawer.tsx:345 +#: src/view/shell/Drawer.tsx:346 +msgid "Moderation" +msgstr "मॉडरेशन" + +#: src/view/screens/Moderation.tsx:81 +msgid "Moderation lists" +msgstr "मॉडरेशन सूचियाँ" + +#: src/view/shell/desktop/Feeds.tsx:53 +msgid "More feeds" +msgstr "अधिक फ़ीड" + +#: src/view/com/profile/ProfileHeader.tsx:520 +#: src/view/screens/ProfileFeed.tsx:369 +#: src/view/screens/ProfileList.tsx:506 +msgid "More options" +msgstr "अधिक विकल्प" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:158 +#~ msgid "More post options" +#~ msgstr "पोस्ट विकल्प" + +#: src/view/com/profile/ProfileHeader.tsx:344 +msgid "Mute Account" +msgstr "खाता म्यूट करें" + +#: src/view/screens/ProfileList.tsx:434 +msgid "Mute accounts" +msgstr "खातों को म्यूट करें" + +#: src/view/screens/ProfileList.tsx:267 +msgid "Mute these accounts?" +msgstr "इन खातों को म्यूट करें?" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:144 +msgid "Mute thread" +msgstr "थ्रेड म्यूट करें" + +#: src/view/screens/Moderation.tsx:95 +msgid "Muted accounts" +msgstr "म्यूट किए गए खाते" + +#: src/view/screens/ModerationMutedAccounts.tsx:106 +msgid "Muted Accounts" +msgstr "म्यूट किए गए खाते" + +#: src/view/screens/ModerationMutedAccounts.tsx:114 +msgid "Muted accounts have their posts removed from your feed and from your notifications. Mutes are completely private." +msgstr "म्यूट किए गए खातों की पोस्ट आपके फ़ीड और आपकी सूचनाओं से हटा दी जाती हैं। म्यूट पूरी तरह से निजी हैं." + +#: src/view/screens/ProfileList.tsx:269 +msgid "Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them." +msgstr "म्यूट करना निजी है. म्यूट किए गए खाते आपके साथ इंटरैक्ट कर सकते हैं, लेकिन आप उनकी पोस्ट नहीं देखेंगे या उनसे सूचनाएं प्राप्त नहीं करेंगे।" + +#: src/view/com/modals/BirthDateSettings.tsx:56 +msgid "My Birthday" +msgstr "जन्मदिन" + +#: src/view/screens/Feeds.tsx:363 +msgid "My Feeds" +msgstr "मेरी फ़ीड" + +#: src/view/shell/desktop/LeftNav.tsx:67 +msgid "My Profile" +msgstr "मेरी प्रोफाइल" + +#: src/view/screens/Settings.tsx:520 +msgid "My Saved Feeds" +msgstr "मेरी फ़ीड" + +#: src/view/com/modals/AddAppPasswords.tsx:177 +#: src/view/com/modals/CreateOrEditList.tsx:211 +msgid "Name" +msgstr "नाम" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:72 +msgid "Never lose access to your followers and data." +msgstr "अपने फ़ॉलोअर्स और डेटा तक पहुंच कभी न खोएं।" + +#: src/view/screens/Lists.tsx:76 +msgid "New" +msgstr "नया" + +#: src/view/com/feeds/FeedPage.tsx:188 +#: src/view/screens/Feeds.tsx:510 +#: src/view/screens/Profile.tsx:382 +#: src/view/screens/ProfileFeed.tsx:449 +#: src/view/screens/ProfileList.tsx:199 +#: src/view/screens/ProfileList.tsx:231 +#: src/view/shell/desktop/LeftNav.tsx:254 +msgid "New post" +msgstr "नई पोस्ट" + +#: src/view/shell/desktop/LeftNav.tsx:264 +msgid "New Post" +msgstr "नई पोस्ट" + +#: src/view/com/auth/create/CreateAccount.tsx:158 +#: src/view/com/auth/login/ForgotPasswordForm.tsx:174 +#: src/view/com/auth/login/ForgotPasswordForm.tsx:184 +#: src/view/com/auth/login/LoginForm.tsx:283 +#: src/view/com/auth/login/SetNewPasswordForm.tsx:156 +#: src/view/com/auth/login/SetNewPasswordForm.tsx:166 +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:79 +msgid "Next" +msgstr "अगला" + +#: src/view/com/lightbox/Lightbox.web.tsx:142 +msgid "Next image" +msgstr "अगली फोटो" + +#: src/view/screens/PreferencesHomeFeed.tsx:191 +#: src/view/screens/PreferencesHomeFeed.tsx:226 +#: src/view/screens/PreferencesHomeFeed.tsx:263 +msgid "No" +msgstr "नहीं" + +#: src/view/screens/ProfileFeed.tsx:620 +#: src/view/screens/ProfileList.tsx:632 +msgid "No description" +msgstr "कोई विवरण नहीं" + +#: src/view/com/composer/text-input/mobile/Autocomplete.tsx:97 +msgid "No result" +msgstr "" + +#: src/view/screens/Feeds.tsx:452 +msgid "No results found for \"{query}\"" +msgstr "\"{query}\" के लिए कोई परिणाम नहीं मिला" + +#: src/view/com/modals/ListAddUser.tsx:142 +#: src/view/shell/desktop/Search.tsx:112 +#~ msgid "No results found for {0}" +#~ msgstr "{0} के लिए कोई परिणाम नहीं मिला" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:127 +#: src/view/screens/Search/Search.tsx:269 +#: src/view/screens/Search/Search.tsx:326 +#: src/view/screens/Search/Search.tsx:609 +#: src/view/shell/desktop/Search.tsx:209 +msgid "No results found for {query}" +msgstr "" + +#: src/view/com/modals/SelfLabel.tsx:136 +#~ msgid "Not Applicable" +#~ msgstr "लागू नहीं" + +#: src/view/com/modals/SelfLabel.tsx:135 +msgid "Not Applicable." +msgstr "लागू नहीं।" + +#: src/view/screens/Notifications.tsx:96 +#: src/view/screens/Notifications.tsx:120 +#: src/view/shell/bottom-bar/BottomBar.tsx:195 +#: src/view/shell/desktop/LeftNav.tsx:363 +#: src/view/shell/Drawer.tsx:298 +#: src/view/shell/Drawer.tsx:299 +msgid "Notifications" +msgstr "सूचनाएं" + +#: src/view/com/util/ErrorBoundary.tsx:34 +msgid "Oh no!" +msgstr "अरे नहीं!" + +#: src/view/com/auth/login/PasswordUpdatedForm.tsx:41 +msgid "Okay" +msgstr "ठीक है" + +#: src/view/com/composer/Composer.tsx:334 +msgid "One or more images is missing alt text." +msgstr "एक या अधिक छवियाँ alt पाठ याद आती हैं।।" + +#: src/view/com/pager/FeedsTabBarMobile.tsx:79 +msgid "Open navigation" +msgstr "ओपन नेविगेशन" + +#: src/view/screens/Settings.tsx:533 +msgid "Opens configurable language settings" +msgstr "भाषा सेटिंग्स खोलें" + +#: src/view/shell/desktop/RightNav.tsx:146 +#: src/view/shell/Drawer.tsx:503 +msgid "Opens list of invite codes" +msgstr "" + +#: src/view/com/modals/ChangeHandle.tsx:279 +msgid "Opens modal for using custom domain" +msgstr "कस्टम डोमेन का उपयोग करने के लिए मोडल खोलें" + +#: src/view/screens/Settings.tsx:558 +msgid "Opens moderation settings" +msgstr "मॉडरेशन सेटिंग्स खोलें" + +#: src/view/screens/Settings.tsx:514 +msgid "Opens screen with all saved feeds" +msgstr "सभी बचाया फ़ीड के साथ स्क्रीन खोलें" + +#: src/view/screens/Settings.tsx:581 +msgid "Opens the app password settings page" +msgstr "ऐप पासवर्ड सेटिंग पेज खोलें" + +#: src/view/screens/Settings.tsx:473 +msgid "Opens the home feed preferences" +msgstr "होम फीड वरीयताओं को खोलता है" + +#: src/view/screens/Settings.tsx:664 +msgid "Opens the storybook page" +msgstr "स्टोरीबुक पेज खोलें" + +#: src/view/screens/Settings.tsx:644 +msgid "Opens the system log page" +msgstr "सिस्टम लॉग पेज खोलें" + +#: src/view/screens/Settings.tsx:494 +msgid "Opens the threads preferences" +msgstr "धागे वरीयताओं को खोलता है" + +#: src/view/com/auth/login/ChooseAccountForm.tsx:138 +msgid "Other account" +msgstr "अन्य खाता" + +#: src/view/com/modals/ServerInput.tsx:88 +msgid "Other service" +msgstr "अन्य सेवा" + +#: src/view/com/composer/select-language/SelectLangBtn.tsx:91 +msgid "Other..." +msgstr "अन्य..।" + +#: src/view/screens/NotFound.tsx:42 +#: src/view/screens/NotFound.tsx:45 +msgid "Page not found" +msgstr "पृष्ठ नहीं मिला" + +#: src/view/com/auth/create/Step2.tsx:101 +#: src/view/com/auth/create/Step2.tsx:111 +#: src/view/com/auth/login/LoginForm.tsx:218 +#: src/view/com/auth/login/SetNewPasswordForm.tsx:130 +#: src/view/com/modals/DeleteAccount.tsx:191 +msgid "Password" +msgstr "पासवर्ड" + +#: src/view/com/auth/login/Login.tsx:141 +msgid "Password updated" +msgstr "" + +#: src/view/com/auth/login/PasswordUpdatedForm.tsx:28 +msgid "Password updated!" +msgstr "पासवर्ड अद्यतन!" + +#: src/view/com/modals/SelfLabel.tsx:121 +msgid "Pictures meant for adults." +msgstr "चित्र वयस्कों के लिए थे।।" + +#: src/view/screens/SavedFeeds.tsx:89 +msgid "Pinned Feeds" +msgstr "पिन किया गया फ़ीड" + +#: src/view/com/auth/create/state.ts:116 +msgid "Please choose your handle." +msgstr "" + +#: src/view/com/auth/create/state.ts:109 +msgid "Please choose your password." +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:67 +msgid "Please confirm your email before changing it. This is a temporary requirement while email-updating tools are added, and it will soon be removed." +msgstr "इसे बदलने से पहले कृपया अपने ईमेल की पुष्टि करें। यह एक अस्थायी आवश्यकता है जबकि ईमेल-अपडेटिंग टूल जोड़ा जाता है, और इसे जल्द ही हटा दिया जाएगा।।" + +#: src/view/com/modals/AddAppPasswords.tsx:140 +msgid "Please enter a unique name for this App Password or use our randomly generated one." +msgstr "कृपया इस ऐप पासवर्ड के लिए एक अद्वितीय नाम दर्ज करें या हमारे यादृच्छिक रूप से उत्पन्न एक का उपयोग करें।।" + +#: src/view/com/auth/create/state.ts:95 +msgid "Please enter your email." +msgstr "" + +#: src/view/com/modals/DeleteAccount.tsx:180 +msgid "Please enter your password as well:" +msgstr "कृपया अपना पासवर्ड भी दर्ज करें:" + +#: src/view/com/composer/Composer.tsx:317 +#: src/view/com/post-thread/PostThread.tsx:212 +#: src/view/screens/PostThread.tsx:77 +msgid "Post" +msgstr "पोस्ट" + +#: src/view/com/post-thread/PostThread.tsx:366 +msgid "Post hidden" +msgstr "छुपा पोस्ट" + +#: src/view/com/composer/select-language/SelectLangBtn.tsx:87 +msgid "Post language" +msgstr "पोस्ट भाषा" + +#: src/view/com/modals/lang-settings/PostLanguagesSettings.tsx:75 +msgid "Post Languages" +msgstr "पोस्ट भाषा" + +#: src/view/com/post-thread/PostThread.tsx:418 +msgid "Post not found" +msgstr "पोस्ट नहीं मिला" + +#: src/view/com/modals/LinkWarning.tsx:44 +msgid "Potentially Misleading Link" +msgstr "शायद एक भ्रामक लिंक" + +#: src/view/com/lightbox/Lightbox.web.tsx:128 +msgid "Previous image" +msgstr "पिछली छवि" + +#: src/view/screens/LanguageSettings.tsx:183 +msgid "Primary Language" +msgstr "प्राथमिक भाषा" + +#: src/view/screens/PreferencesThreads.tsx:91 +msgid "Prioritize Your Follows" +msgstr "अपने फ़ॉलोअर्स को प्राथमिकता दें" + +#: src/view/shell/desktop/RightNav.tsx:75 +msgid "Privacy" +msgstr "गोपनीयता" + +#: src/view/screens/PrivacyPolicy.tsx:29 +msgid "Privacy Policy" +msgstr "गोपनीयता नीति" + +#: src/view/com/auth/login/ForgotPasswordForm.tsx:190 +msgid "Processing..." +msgstr "प्रसंस्करण..." + +#: src/view/shell/bottom-bar/BottomBar.tsx:237 +#: src/view/shell/Drawer.tsx:72 +#: src/view/shell/Drawer.tsx:366 +#: src/view/shell/Drawer.tsx:367 +msgid "Profile" +msgstr "प्रोफ़ाइल" + +#: src/view/screens/Settings.tsx:789 +msgid "Protect your account by verifying your email." +msgstr "अपने ईमेल को सत्यापित करके अपने खाते को सुरक्षित रखें।।" + +#: src/view/screens/Lists.tsx:61 +msgid "Public, shareable lists which can drive feeds." +msgstr "सार्वजनिक, साझा करने योग्य सूचियाँ जो फ़ीड चला सकती हैं।" + +#: src/view/com/modals/Repost.tsx:52 +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:58 +msgid "Quote post" +msgstr "कोटे पोस्ट" + +#: src/view/com/modals/Repost.tsx:56 +msgid "Quote Post" +msgstr "कोटे पोस्ट" + +#: src/view/com/modals/EditImage.tsx:236 +msgid "Ratios" +msgstr "अनुपात" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:73 +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:50 +#~ msgid "Recommended" +#~ msgstr "अनुशंसित" + +#: src/view/com/auth/onboarding/RecommendedFeeds.tsx:116 +msgid "Recommended Feeds" +msgstr "अनुशंसित फ़ीड" + +#: src/view/com/auth/onboarding/RecommendedFollows.tsx:180 +msgid "Recommended Users" +msgstr "अनुशंसित लोग" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:264 +#: src/view/com/modals/SelfLabel.tsx:83 +#: src/view/com/modals/UserAddRemoveLists.tsx:187 +#: src/view/com/util/UserAvatar.tsx:278 +#: src/view/com/util/UserBanner.tsx:89 +msgid "Remove" +msgstr "निकालें" + +#: src/view/com/feeds/FeedSourceCard.tsx:108 +msgid "Remove {0} from my feeds?" +msgstr "मेरे फ़ीड से {0} हटाएं?" + +#: src/view/com/util/AccountDropdownBtn.tsx:22 +msgid "Remove account" +msgstr "खाता हटाएं" + +#: src/view/com/posts/FeedErrorMessage.tsx:118 +msgid "Remove feed" +msgstr "फ़ीड हटाएँ" + +#: src/view/com/feeds/FeedSourceCard.tsx:107 +#: src/view/screens/ProfileFeed.tsx:279 +msgid "Remove from my feeds" +msgstr "मेरे फ़ीड से हटाएँ" + +#: src/view/com/composer/photos/Gallery.tsx:167 +msgid "Remove image" +msgstr "छवि निकालें" + +#: src/view/com/composer/ExternalEmbed.tsx:70 +msgid "Remove image preview" +msgstr "छवि पूर्वावलोकन निकालें" + +#: src/view/com/posts/FeedErrorMessage.tsx:119 +msgid "Remove this feed from your saved feeds?" +msgstr "इस फ़ीड को सहेजे गए फ़ीड से हटा दें?" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:199 +#: src/view/com/modals/UserAddRemoveLists.tsx:130 +msgid "Removed from list" +msgstr "" + +#: src/view/screens/PreferencesHomeFeed.tsx:135 +msgid "Reply Filters" +msgstr "फिल्टर" + +#: src/view/com/modals/report/Modal.tsx:166 +msgid "Report {collectionName}" +msgstr "रिपोर्ट {collectionName}" + +#: src/view/com/profile/ProfileHeader.tsx:378 +msgid "Report Account" +msgstr "रिपोर्ट" + +#: src/view/screens/ProfileFeed.tsx:299 +msgid "Report feed" +msgstr "रिपोर्ट फ़ीड" + +#: src/view/screens/ProfileList.tsx:416 +msgid "Report List" +msgstr "रिपोर्ट सूची" + +#: src/view/com/modals/report/SendReportButton.tsx:37 +#: src/view/com/util/forms/PostDropdownBtn.tsx:162 +msgid "Report post" +msgstr "रिपोर्ट पोस्ट" + +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:48 +msgid "Repost" +msgstr "पुन: पोस्ट" + +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:94 +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:105 +msgid "Repost or quote post" +msgstr "पोस्ट दोबारा पोस्ट करें या उद्धृत करे" + +#: src/view/screens/PostRepostedBy.tsx:27 +msgid "Reposted by" +msgstr "द्वारा दोबारा पोस्ट किया गया" + +#: src/view/com/modals/ChangeEmail.tsx:181 +#: src/view/com/modals/ChangeEmail.tsx:183 +msgid "Request Change" +msgstr "अनुरोध बदलें" + +#: src/view/screens/Settings.tsx:382 +#~ msgid "Require alt text before posting" +#~ msgstr "पोस्ट करने से पहले वैकल्पिक टेक्स्ट की आवश्यकता है" + +#: src/view/com/auth/create/Step2.tsx:53 +msgid "Required for this provider" +msgstr "इस प्रदाता के लिए आवश्यक" + +#: src/view/com/auth/login/SetNewPasswordForm.tsx:108 +msgid "Reset code" +msgstr "कोड रीसेट करें" + +#: src/view/screens/Settings.tsx:686 +msgid "Reset onboarding state" +msgstr "ऑनबोर्डिंग स्टेट को रीसेट करें" + +#: src/view/com/auth/login/ForgotPasswordForm.tsx:98 +msgid "Reset password" +msgstr "पासवर्ड रीसेट" + +#: src/view/screens/Settings.tsx:676 +msgid "Reset preferences state" +msgstr "प्राथमिकताओं को रीसेट करें" + +#: src/view/screens/Settings.tsx:684 +msgid "Resets the onboarding state" +msgstr "ऑनबोर्डिंग स्टेट को रीसेट करें" + +#: src/view/screens/Settings.tsx:674 +msgid "Resets the preferences state" +msgstr "प्राथमिकताओं की स्थिति को रीसेट करें" + +#: src/view/com/auth/create/CreateAccount.tsx:167 +#: src/view/com/auth/create/CreateAccount.tsx:171 +#: src/view/com/auth/login/LoginForm.tsx:260 +#: src/view/com/auth/login/LoginForm.tsx:263 +#: src/view/com/util/error/ErrorMessage.tsx:55 +#: src/view/com/util/error/ErrorScreen.tsx:65 +msgid "Retry" +msgstr "फिर से कोशिश करो" + +#: src/view/com/modals/ChangeHandle.tsx:169 +#~ msgid "Retry change handle" +#~ msgstr "हैंडल बदलना फिर से कोशिश करो" + +#: src/view/com/modals/AltImage.tsx:114 +#: src/view/com/modals/BirthDateSettings.tsx:93 +#: src/view/com/modals/BirthDateSettings.tsx:96 +#: src/view/com/modals/ChangeHandle.tsx:173 +#: src/view/com/modals/CreateOrEditList.tsx:249 +#: src/view/com/modals/CreateOrEditList.tsx:257 +#: src/view/com/modals/EditProfile.tsx:223 +msgid "Save" +msgstr "सेव करो" + +#: src/view/com/modals/AltImage.tsx:105 +msgid "Save alt text" +msgstr "सेव ऑल्ट टेक्स्ट" + +#: src/view/com/modals/UserAddRemoveLists.tsx:212 +#~ msgid "Save changes" +#~ msgstr "बदलाव सेव करो" + +#: src/view/com/modals/EditProfile.tsx:231 +msgid "Save Changes" +msgstr "बदलाव सेव करो" + +#: src/view/com/modals/ChangeHandle.tsx:170 +msgid "Save handle change" +msgstr "बदलाव सेव करो" + +#: src/view/com/modals/crop-image/CropImage.web.tsx:144 +msgid "Save image crop" +msgstr "फोटो बदलाव सेव करो" + +#: src/view/screens/SavedFeeds.tsx:122 +msgid "Saved Feeds" +msgstr "सहेजे गए फ़ीड" + +#: src/view/com/modals/ListAddRemoveUsers.tsx:75 +#: src/view/com/util/forms/SearchInput.tsx:64 +#: src/view/screens/Search/Search.tsx:409 +#: src/view/screens/Search/Search.tsx:561 +#: src/view/shell/bottom-bar/BottomBar.tsx:146 +#: src/view/shell/desktop/LeftNav.tsx:323 +#: src/view/shell/desktop/Search.tsx:160 +#: src/view/shell/desktop/Search.tsx:169 +#: src/view/shell/Drawer.tsx:252 +#: src/view/shell/Drawer.tsx:253 +msgid "Search" +msgstr "खोज" + +#: src/view/screens/Search/Search.tsx:418 +msgid "Search for posts and users." +msgstr "" + +#: src/view/com/modals/ChangeEmail.tsx:110 +msgid "Security Step Required" +msgstr "सुरक्षा चरण आवश्यक" + +#: src/view/com/auth/SplashScreen.tsx:29 +msgid "See what's next" +msgstr "आगे क्या है" + +#: src/view/com/modals/ServerInput.tsx:75 +msgid "Select Bluesky Social" +msgstr "Bluesky Social का चयन करें" + +#: src/view/com/auth/login/Login.tsx:101 +msgid "Select from an existing account" +msgstr "मौजूदा खाते से चुनें" + +#: src/view/com/auth/login/LoginForm.tsx:145 +msgid "Select service" +msgstr "सेवा चुनें" + +#: src/view/screens/LanguageSettings.tsx:276 +msgid "Select which languages you want your subscribed feeds to include. If none are selected, all languages will be shown." +msgstr "चुनें कि आप अपनी सदस्यता वाली फ़ीड में कौन सी भाषाएँ शामिल करना चाहते हैं। यदि कोई भी चयनित नहीं है, तो सभी भाषाएँ दिखाई जाएंगी।" + +#: src/view/screens/LanguageSettings.tsx:95 +msgid "Select your app language for the default text to display in the app" +msgstr "ऐप में प्रदर्शित होने वाले डिफ़ॉल्ट टेक्स्ट के लिए अपनी ऐप भाषा चुनें" + +#: src/view/screens/LanguageSettings.tsx:186 +msgid "Select your preferred language for translations in your feed." +msgstr "अपने फ़ीड में अनुवाद के लिए अपनी पसंदीदा भाषा चुनें।" + +#: src/view/com/modals/VerifyEmail.tsx:188 +msgid "Send Confirmation Email" +msgstr "पुष्टिकरण ईमेल भेजें" + +#: src/view/com/modals/DeleteAccount.tsx:127 +msgid "Send email" +msgstr "ईमेल भेजें" + +#: src/view/com/modals/DeleteAccount.tsx:138 +msgid "Send Email" +msgstr "ईमेल भेजें" + +#: src/view/shell/Drawer.tsx:394 +#: src/view/shell/Drawer.tsx:415 +msgid "Send feedback" +msgstr "प्रतिक्रिया भेजें" + +#: src/view/com/modals/report/SendReportButton.tsx:45 +msgid "Send Report" +msgstr "रिपोर्ट भेजें" + +#: src/view/com/auth/login/SetNewPasswordForm.tsx:78 +msgid "Set new password" +msgstr "नया पासवर्ड सेट करें" + +#: src/view/screens/PreferencesHomeFeed.tsx:216 +msgid "Set this setting to \"No\" to hide all quote posts from your feed. Reposts will still be visible." +msgstr "अपने फ़ीड से सभी उद्धरण पदों को छिपाने के लिए इस सेटिंग को \"नहीं\" में सेट करें। Reposts अभी भी दिखाई देगा।।" + +#: src/view/screens/PreferencesHomeFeed.tsx:113 +msgid "Set this setting to \"No\" to hide all replies from your feed." +msgstr "इस सेटिंग को अपने फ़ीड से सभी उत्तरों को छिपाने के लिए \"नहीं\" पर सेट करें।।" + +#: src/view/screens/PreferencesHomeFeed.tsx:182 +msgid "Set this setting to \"No\" to hide all reposts from your feed." +msgstr "इस सेटिंग को अपने फ़ीड से सभी पोस्ट छिपाने के लिए \"नहीं\" करने के लिए सेट करें।।" + +#: src/view/screens/PreferencesThreads.tsx:116 +msgid "Set this setting to \"Yes\" to show replies in a threaded view. This is an experimental feature." +msgstr "इस सेटिंग को \"हाँ\" में सेट करने के लिए एक थ्रेडेड व्यू में जवाब दिखाने के लिए। यह एक प्रयोगात्मक विशेषता है।।" + +#: src/view/screens/PreferencesHomeFeed.tsx:252 +msgid "Set this setting to \"Yes\" to show samples of your saved feeds in your following feed. This is an experimental feature." +msgstr "इस सेटिंग को अपने निम्नलिखित फ़ीड में अपने सहेजे गए फ़ीड के नमूने दिखाने के लिए \"हाँ\" पर सेट करें। यह एक प्रयोगात्मक विशेषता है।।" + +#: src/view/screens/Settings.tsx:277 +#: src/view/shell/desktop/LeftNav.tsx:435 +#: src/view/shell/Drawer.tsx:379 +#: src/view/shell/Drawer.tsx:380 +msgid "Settings" +msgstr "सेटिंग्स" + +#: src/view/com/modals/SelfLabel.tsx:125 +msgid "Sexual activity or erotic nudity." +msgstr "यौन गतिविधि या कामुक नग्नता।।" + +#: src/view/com/profile/ProfileHeader.tsx:312 +#: src/view/com/util/forms/PostDropdownBtn.tsx:126 +#: src/view/screens/ProfileList.tsx:375 +msgid "Share" +msgstr "शेयर" + +#: src/view/screens/ProfileFeed.tsx:311 +msgid "Share feed" +msgstr "" + +#: src/view/screens/ProfileFeed.tsx:276 +#~ msgid "Share link" +#~ msgstr "लिंक शेयर करें" + +#: src/view/screens/Settings.tsx:316 +msgid "Show" +msgstr "दिखाओ" + +#: src/view/com/util/moderation/ScreenHider.tsx:114 +msgid "Show anyway" +msgstr "दिखाओ" + +#: src/view/screens/PreferencesHomeFeed.tsx:249 +msgid "Show Posts from My Feeds" +msgstr "मेरी फीड से पोस्ट दिखाएं" + +#: src/view/screens/PreferencesHomeFeed.tsx:213 +msgid "Show Quote Posts" +msgstr "उद्धरण पोस्ट दिखाओ" + +#: src/view/screens/PreferencesHomeFeed.tsx:110 +msgid "Show Replies" +msgstr "उत्तर दिखाएँ" + +#: src/view/screens/PreferencesThreads.tsx:94 +msgid "Show replies by people you follow before all other replies." +msgstr "अन्य सभी उत्तरों से पहले उन लोगों के उत्तर दिखाएं जिन्हें आप फ़ॉलो करते हैं।" + +#: src/view/screens/PreferencesHomeFeed.tsx:179 +msgid "Show Reposts" +msgstr "रीपोस्ट दिखाएँ" + +#: src/view/com/notifications/FeedItem.tsx:337 +msgid "Show users" +msgstr "लोग दिखाएँ" + +#: src/view/com/auth/login/Login.tsx:82 +#: src/view/com/auth/SplashScreen.tsx:49 +#: src/view/shell/NavSignupCard.tsx:52 +#: src/view/shell/NavSignupCard.tsx:53 +msgid "Sign in" +msgstr "साइन इन करें" + +#: src/view/com/auth/SplashScreen.tsx:52 +#: src/view/com/auth/SplashScreen.web.tsx:84 +msgid "Sign In" +msgstr "साइन इन करें" + +#: src/view/com/auth/login/ChooseAccountForm.tsx:44 +msgid "Sign in as {0}" +msgstr "{0} के रूप में साइन इन करें" + +#: src/view/com/auth/login/ChooseAccountForm.tsx:118 +#: src/view/com/auth/login/Login.tsx:100 +msgid "Sign in as..." +msgstr "... के रूप में साइन इन करें" + +#: src/view/com/auth/login/LoginForm.tsx:132 +msgid "Sign into" +msgstr "साइन इन करें" + +#: src/view/com/modals/SwitchAccount.tsx:60 +#: src/view/com/modals/SwitchAccount.tsx:63 +msgid "Sign out" +msgstr "साइन आउट" + +#: src/view/shell/NavSignupCard.tsx:43 +#: src/view/shell/NavSignupCard.tsx:44 +#: src/view/shell/NavSignupCard.tsx:46 +msgid "Sign up" +msgstr "" + +#: src/view/shell/NavSignupCard.tsx:36 +msgid "Sign up or sign in to join the conversation" +msgstr "" + +#: src/view/screens/Settings.tsx:327 +msgid "Signed in as" +msgstr "आपने इस रूप में साइन इन करा है:" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:33 +msgid "Skip" +msgstr "स्किप" + +#: src/view/screens/PreferencesThreads.tsx:69 +msgid "Sort Replies" +msgstr "उत्तर क्रमबद्ध करें" + +#: src/view/screens/PreferencesThreads.tsx:72 +msgid "Sort replies to the same post by:" +msgstr "उसी पोस्ट के उत्तरों को इस प्रकार क्रमबद्ध करें:" + +#: src/view/com/modals/crop-image/CropImage.web.tsx:122 +msgid "Square" +msgstr "स्क्वायर" + +#: src/view/com/auth/create/Step1.tsx:90 +#: src/view/com/modals/ServerInput.tsx:62 +msgid "Staging" +msgstr "स्टेजिंग" + +#: src/view/screens/Settings.tsx:730 +msgid "Status page" +msgstr "स्थिति पृष्ठ" + +#: src/view/screens/Settings.tsx:666 +msgid "Storybook" +msgstr "Storybook" + +#: src/view/screens/ProfileList.tsx:497 +msgid "Subscribe" +msgstr "सब्सक्राइब" + +#: src/view/screens/ProfileList.tsx:493 +msgid "Subscribe to this list" +msgstr "इस सूची को सब्सक्राइब करें" + +#: src/view/screens/Search/Search.tsx:382 +msgid "Suggested Follows" +msgstr "अनुशंसित लोग" + +#: src/view/screens/Support.tsx:30 +#: src/view/screens/Support.tsx:33 +msgid "Support" +msgstr "सहायता" + +#: src/view/com/modals/SwitchAccount.tsx:111 +msgid "Switch Account" +msgstr "खाते बदलें" + +#: src/view/screens/Settings.tsx:398 +#~ msgid "System" +#~ msgstr "प्रणाली" + +#: src/view/screens/Settings.tsx:646 +msgid "System log" +msgstr "सिस्टम लॉग" + +#: src/view/com/modals/crop-image/CropImage.web.tsx:112 +msgid "Tall" +msgstr "लंबा" + +#: src/view/shell/desktop/RightNav.tsx:84 +msgid "Terms" +msgstr "शर्तें" + +#: src/view/screens/TermsOfService.tsx:29 +msgid "Terms of Service" +msgstr "सेवा की शर्तें" + +#: src/view/com/modals/report/InputIssueDetails.tsx:50 +msgid "Text input field" +msgstr "पाठ इनपुट फ़ील्ड" + +#: src/view/com/profile/ProfileHeader.tsx:280 +msgid "The account will be able to interact with you after unblocking." +msgstr "अनब्लॉक करने के बाद अकाउंट आपसे इंटरैक्ट कर सकेगा।" + +#: src/view/screens/CommunityGuidelines.tsx:36 +msgid "The Community Guidelines have been moved to <0/>" +msgstr "सामुदायिक दिशानिर्देशों को <0/> पर स्थानांतरित कर दिया गया है" + +#: src/view/screens/CopyrightPolicy.tsx:33 +msgid "The Copyright Policy has been moved to <0/>" +msgstr "कॉपीराइट नीति को <0/> पर स्थानांतरित कर दिया गया है" + +#: src/view/com/post-thread/PostThread.tsx:421 +msgid "The post may have been deleted." +msgstr "हो सकता है कि यह पोस्ट हटा दी गई हो।" + +#: src/view/screens/PrivacyPolicy.tsx:33 +msgid "The Privacy Policy has been moved to <0/>" +msgstr "गोपनीयता नीति को <0/> पर स्थानांतरित किया गया है" + +#: src/view/screens/Support.tsx:36 +msgid "The support form has been moved. If you need help, please<0/> or visit {HELP_DESK_URL} to get in touch with us." +msgstr "समर्थन प्रपत्र स्थानांतरित कर दिया गया है. यदि आपको सहायता की आवश्यकता है, तो कृपया<0/> या हमसे संपर्क करने के लिए {HELP_DESK_URL} पर जाएं।" + +#: src/view/screens/TermsOfService.tsx:33 +msgid "The Terms of Service have been moved to" +msgstr "सेवा की शर्तों को स्थानांतरित कर दिया गया है" + +#: src/view/com/util/ErrorBoundary.tsx:35 +msgid "There was an unexpected issue in the application. Please let us know if this happened to you!" +msgstr "एप्लिकेशन में एक अप्रत्याशित समस्या थी. कृपया हमें बताएं कि क्या आपके साथ ऐसा हुआ है!" + +#: src/view/com/util/moderation/ScreenHider.tsx:72 +msgid "This {screenDescription} has been flagged:" +msgstr "यह {screenDescription} फ्लैग किया गया है:" + +#: src/view/com/modals/BirthDateSettings.tsx:61 +msgid "This information is not shared with other users." +msgstr "यह जानकारी अन्य उपयोगकर्ताओं के साथ साझा नहीं की जाती है।।" + +#: src/view/com/modals/VerifyEmail.tsx:105 +msgid "This is important in case you ever need to change your email or reset your password." +msgstr "अगर आपको कभी अपना ईमेल बदलने या पासवर्ड रीसेट करने की आवश्यकता है तो यह महत्वपूर्ण है।।" + +#: src/view/com/auth/create/Step1.tsx:55 +msgid "This is the service that keeps you online." +msgstr "यह वह सेवा है जो आपको ऑनलाइन रखता है।।" + +#: src/view/com/modals/LinkWarning.tsx:56 +msgid "This link is taking you to the following website:" +msgstr "यह लिंक आपको निम्नलिखित वेबसाइट पर ले जा रहा है:" + +#: src/view/com/post-thread/PostThreadItem.tsx:114 +msgid "This post has been deleted." +msgstr "इस पोस्ट को हटा दिया गया है।।" + +#: src/view/com/modals/SelfLabel.tsx:137 +msgid "This warning is only available for posts with media attached." +msgstr "यह चेतावनी केवल मीडिया संलग्न पोस्ट के लिए उपलब्ध है।" + +#: src/view/screens/PreferencesThreads.tsx:53 +#: src/view/screens/Settings.tsx:503 +msgid "Thread Preferences" +msgstr "थ्रेड प्राथमिकता" + +#: src/view/screens/PreferencesThreads.tsx:113 +msgid "Threaded Mode" +msgstr "थ्रेड मोड" + +#: src/view/com/util/forms/DropdownButton.tsx:230 +msgid "Toggle dropdown" +msgstr "ड्रॉपडाउन टॉगल करें" + +#: src/view/com/modals/EditImage.tsx:271 +msgid "Transformations" +msgstr "परिवर्तन" + +#: src/view/com/post-thread/PostThreadItem.tsx:646 +#: src/view/com/post-thread/PostThreadItem.tsx:648 +#: src/view/com/util/forms/PostDropdownBtn.tsx:98 +msgid "Translate" +msgstr "अनुवाद" + +#: src/view/com/util/error/ErrorScreen.tsx:73 +msgid "Try again" +msgstr "फिर से कोशिश करो" + +#: src/view/com/auth/create/CreateAccount.tsx:64 +#: src/view/com/auth/login/Login.tsx:60 +#: src/view/com/auth/login/LoginForm.tsx:117 +msgid "Unable to contact your service. Please check your Internet connection." +msgstr "आपकी सेवा से संपर्क करने में असमर्थ। कृपया अपने इंटरनेट कनेक्शन की जांच करें।।" + +#: src/view/com/profile/ProfileHeader.tsx:438 +#: src/view/com/profile/ProfileHeader.tsx:441 +msgid "Unblock" +msgstr "अनब्लॉक" + +#: src/view/com/profile/ProfileHeader.tsx:278 +#: src/view/com/profile/ProfileHeader.tsx:362 +msgid "Unblock Account" +msgstr "अनब्लॉक खाता" + +#: src/view/com/util/post-ctrls/RepostButton.web.tsx:48 +msgid "Undo repost" +msgstr "पुनः पोस्ट पूर्ववत करें" + +#: src/view/com/auth/create/state.ts:210 +msgid "Unfortunately, you do not meet the requirements to create an account." +msgstr "" + +#: src/view/com/profile/ProfileHeader.tsx:343 +msgid "Unmute Account" +msgstr "अनम्यूट खाता" + +#: src/view/com/util/forms/PostDropdownBtn.tsx:144 +msgid "Unmute thread" +msgstr "थ्रेड को अनम्यूट करें" + +#: src/view/com/modals/UserAddRemoveLists.tsx:52 +msgid "Update {displayName} in Lists" +msgstr "सूची में {displayName} अद्यतन करें" + +#: src/lib/hooks/useOTAUpdate.ts:15 +msgid "Update Available" +msgstr "उपलब्ध अद्यतन" + +#: src/view/com/auth/login/SetNewPasswordForm.tsx:172 +msgid "Updating..." +msgstr "अद्यतन..।" + +#: src/view/com/modals/ChangeHandle.tsx:453 +msgid "Upload a text file to:" +msgstr "एक पाठ फ़ाइल अपलोड करने के लिए:" + +#: src/view/screens/AppPasswords.tsx:194 +msgid "Use app passwords to login to other Bluesky clients without giving full access to your account or password." +msgstr "अपने खाते या पासवर्ड को पूर्ण एक्सेस देने के बिना अन्य ब्लूस्की ग्राहकों को लॉगिन करने के लिए ऐप पासवर्ड का उपयोग करें।।" + +#: src/view/com/modals/ChangeHandle.tsx:513 +msgid "Use default provider" +msgstr "डिफ़ॉल्ट प्रदाता का उपयोग करें" + +#: src/view/com/modals/AddAppPasswords.tsx:150 +msgid "Use this to sign into the other app along with your handle." +msgstr "अपने हैंडल के साथ दूसरे ऐप में साइन इन करने के लिए इसका उपयोग करें।" + +#: src/view/com/modals/InviteCodes.tsx:196 +msgid "Used by:" +msgstr "के द्वारा उपयोग:" + +#: src/view/com/auth/create/Step3.tsx:38 +msgid "User handle" +msgstr "यूजर हैंडल" + +#: src/view/screens/Lists.tsx:58 +msgid "User Lists" +msgstr "लोग सूचियाँ" + +#: src/view/com/auth/login/LoginForm.tsx:172 +#: src/view/com/auth/login/LoginForm.tsx:189 +msgid "Username or email address" +msgstr "यूजर नाम या ईमेल पता" + +#: src/view/screens/ProfileList.tsx:659 +msgid "Users" +msgstr "यूजर लोग" + +#: src/view/screens/Settings.tsx:750 +msgid "Verify email" +msgstr "ईमेल सत्यापित करें" + +#: src/view/screens/Settings.tsx:775 +msgid "Verify my email" +msgstr "मेरी ईमेल सत्यापित करें" + +#: src/view/screens/Settings.tsx:784 +msgid "Verify My Email" +msgstr "मेरी ईमेल सत्यापित करें" + +#: src/view/com/modals/ChangeEmail.tsx:205 +#: src/view/com/modals/ChangeEmail.tsx:207 +msgid "Verify New Email" +msgstr "नया ईमेल सत्यापित करें" + +#: src/view/screens/Log.tsx:52 +msgid "View debug entry" +msgstr "डीबग प्रविष्टि देखें" + +#: src/view/com/profile/ProfileSubpageHeader.tsx:128 +msgid "View the avatar" +msgstr "अवतार देखें" + +#: src/view/com/modals/LinkWarning.tsx:73 +msgid "Visit Site" +msgstr "साइट पर जाएं" + +#: src/view/com/auth/create/CreateAccount.tsx:125 +msgid "We're so excited to have you join us!" +msgstr "हम आपके हमारी सेवा में शामिल होने को लेकर बहुत उत्साहित हैं!" + +#: src/view/com/posts/FeedErrorMessage.tsx:98 +msgid "We're sorry, but this content is not viewable without a Bluesky account." +msgstr "" + +#: src/view/screens/Search/Search.tsx:236 +msgid "We're sorry, but your search could not be completed. Please try again in a few minutes." +msgstr "" + +#: src/view/screens/NotFound.tsx:48 +msgid "We're sorry! We can't find the page you were looking for." +msgstr "हम क्षमा चाहते हैं! हमें वह पेज नहीं मिल रहा जिसे आप ढूंढ रहे थे।" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:46 +msgid "Welcome to <0>Bluesky</0>" +msgstr "<0>Bluesky</0> में आपका स्वागत है" + +#: src/view/com/modals/report/Modal.tsx:169 +msgid "What is the issue with this {collectionName}?" +msgstr "इस {collectionName} के साथ क्या मुद्दा है?" + +#: src/view/com/modals/lang-settings/PostLanguagesSettings.tsx:78 +msgid "Which languages are used in this post?" +msgstr "इस पोस्ट में किस भाषा का उपयोग किया जाता है?" + +#: src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx:77 +msgid "Which languages would you like to see in your algorithmic feeds?" +msgstr "कौन से भाषाएं आपको अपने एल्गोरिदमिक फ़ीड में देखना पसंद करती हैं?" + +#: src/view/com/modals/crop-image/CropImage.web.tsx:102 +msgid "Wide" +msgstr "चौड़ा" + +#: src/view/com/composer/Composer.tsx:389 +msgid "Write post" +msgstr "पोस्ट लिखो" + +#: src/view/com/composer/Prompt.tsx:33 +msgid "Write your reply" +msgstr "अपना जवाब दें" + +#: src/view/screens/PreferencesHomeFeed.tsx:192 +#: src/view/screens/PreferencesHomeFeed.tsx:227 +#: src/view/screens/PreferencesHomeFeed.tsx:262 +msgid "Yes" +msgstr "हाँ" + +#: src/view/com/auth/create/Step1.tsx:106 +msgid "You can change hosting providers at any time." +msgstr "आप किसी भी समय होस्टिंग प्रदाताओं को बदल सकते हैं।।" + +#: src/view/com/auth/login/Login.tsx:142 +#: src/view/com/auth/login/PasswordUpdatedForm.tsx:31 +msgid "You can now sign in with your new password." +msgstr "अब आप अपने नए पासवर्ड के साथ साइन इन कर सकते हैं।।" + +#: src/view/com/modals/InviteCodes.tsx:64 +msgid "You don't have any invite codes yet! We'll send you some when you've been on Bluesky for a little longer." +msgstr "आपके पास अभी तक कोई आमंत्रण कोड नहीं है! जब आप कुछ अधिक समय के लिए Bluesky पर रहेंगे तो हम आपको कुछ भेजेंगे।" + +#: src/view/screens/SavedFeeds.tsx:102 +msgid "You don't have any pinned feeds." +msgstr "आपके पास कोई पिन किया हुआ फ़ीड नहीं है." + +#: src/view/screens/Feeds.tsx:383 +msgid "You don't have any saved feeds!" +msgstr "" + +#: src/view/screens/SavedFeeds.tsx:135 +msgid "You don't have any saved feeds." +msgstr "आपके पास कोई सहेजी गई फ़ीड नहीं है." + +#: src/view/com/post-thread/PostThread.tsx:369 +msgid "You have blocked the author or you have been blocked by the author." +msgstr "आपने लेखक को अवरुद्ध किया है या आपने लेखक द्वारा अवरुद्ध किया है।।" + +#: src/view/com/feeds/ProfileFeedgens.tsx:150 +msgid "You have no feeds." +msgstr "" + +#: src/view/com/lists/MyLists.tsx:88 +#: src/view/com/lists/ProfileLists.tsx:154 +msgid "You have no lists." +msgstr "आपके पास कोई सूची नहीं है।।" + +#: src/view/screens/ModerationBlockedAccounts.tsx:131 +msgid "You have not blocked any accounts yet. To block an account, go to their profile and selected \"Block account\" from the menu on their account." +msgstr "आपने अभी तक कोई भी अकाउंट ब्लॉक नहीं किया है. किसी खाते को ब्लॉक करने के लिए, उनकी प्रोफ़ाइल पर जाएं और उनके खाते के मेनू से \"खाता ब्लॉक करें\" चुनें।" + +#: src/view/screens/AppPasswords.tsx:86 +msgid "You have not created any app passwords yet. You can create one by pressing the button below." +msgstr "आपने अभी तक कोई ऐप पासवर्ड नहीं बनाया है। आप नीचे बटन दबाकर एक बना सकते हैं।।" + +#: src/view/screens/ModerationMutedAccounts.tsx:130 +msgid "You have not muted any accounts yet. To mute an account, go to their profile and selected \"Mute account\" from the menu on their account." +msgstr "आपने अभी तक कोई खाता म्यूट नहीं किया है. किसी खाते को म्यूट करने के लिए, उनकी प्रोफ़ाइल पर जाएं और उनके खाते के मेनू से \"खाता म्यूट करें\" चुनें।" + +#: src/view/com/auth/login/SetNewPasswordForm.tsx:81 +msgid "You will receive an email with a \"reset code.\" Enter that code here, then enter your new password." +msgstr "आपको \"reset code\" के साथ एक ईमेल प्राप्त होगा। उस कोड को यहाँ दर्ज करें, फिर अपना नया पासवर्ड दर्ज करें।।" + +#: src/view/com/auth/create/Step2.tsx:43 +msgid "Your account" +msgstr "आपका खाता" + +#: src/view/com/auth/create/Step2.tsx:122 +msgid "Your birth date" +msgstr "जन्म तिथि" + +#: src/view/com/auth/create/state.ts:102 +msgid "Your email appears to be invalid." +msgstr "" + +#: src/view/com/modals/Waitlist.tsx:107 +msgid "Your email has been saved! We'll be in touch soon." +msgstr "आपका ईमेल बचाया गया है! हम जल्द ही संपर्क में रहेंगे।।" + +#: src/view/com/modals/ChangeEmail.tsx:125 +msgid "Your email has been updated but not verified. As a next step, please verify your new email." +msgstr "आपका ईमेल अद्यतन किया गया है लेकिन सत्यापित नहीं किया गया है। अगले चरण के रूप में, कृपया अपना नया ईमेल सत्यापित करें।।" + +#: src/view/com/modals/VerifyEmail.tsx:100 +msgid "Your email has not yet been verified. This is an important security step which we recommend." +msgstr "आपका ईमेल अभी तक सत्यापित नहीं हुआ है। यह एक महत्वपूर्ण सुरक्षा कदम है जिसे हम अनुशंसा करते हैं।।" + +#: src/view/com/auth/create/Step3.tsx:42 +#: src/view/com/modals/ChangeHandle.tsx:270 +msgid "Your full handle will be" +msgstr "आपका पूरा हैंडल होगा" + +#: src/view/com/auth/create/Step1.tsx:53 +msgid "Your hosting provider" +msgstr "आपका होस्टिंग प्रदाता" + +#: src/view/screens/Settings.tsx:402 +#: src/view/shell/desktop/RightNav.tsx:127 +#: src/view/shell/Drawer.tsx:517 +msgid "Your invite codes are hidden when logged in using an App Password" +msgstr "" + +#: src/view/com/auth/onboarding/WelcomeMobile.tsx:59 +msgid "Your posts, likes, and blocks are public. Mutes are private." +msgstr "आपकी पोस्ट, पसंद और ब्लॉक सार्वजनिक हैं। म्यूट निजी हैं।।" + +#: src/view/com/modals/SwitchAccount.tsx:78 +msgid "Your profile" +msgstr "आपकी प्रोफ़ाइल" + +#: src/view/com/auth/create/Step3.tsx:28 +msgid "Your user handle" +msgstr "आपका यूजर हैंडल" diff --git a/src/logger/__tests__/logger.test.ts b/src/logger/__tests__/logger.test.ts index 46a5be610..f8b588778 100644 --- a/src/logger/__tests__/logger.test.ts +++ b/src/logger/__tests__/logger.test.ts @@ -222,6 +222,26 @@ describe('general functionality', () => { }) }) + test('sentryTransport serializes errors', () => { + const message = 'message' + const timestamp = Date.now() + const sentryTimestamp = timestamp / 1000 + + sentryTransport( + LogLevel.Debug, + message, + {error: new Error('foo')}, + timestamp, + ) + expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({ + message, + data: {error: 'Error: foo'}, + type: 'default', + level: LogLevel.Debug, + timestamp: sentryTimestamp, + }) + }) + test('add/remove transport', () => { const timestamp = Date.now() const logger = new Logger({enabled: true}) diff --git a/src/logger/index.ts b/src/logger/index.ts index 3de2b9046..59cb84ff4 100644 --- a/src/logger/index.ts +++ b/src/logger/index.ts @@ -90,6 +90,16 @@ const enabledLogLevels: { [LogLevel.Error]: [LogLevel.Error], } +export function prepareMetadata(metadata: Metadata): Metadata { + return Object.keys(metadata).reduce((acc, key) => { + let value = metadata[key] + if (value instanceof Error) { + value = value.toString() + } + return {...acc, [key]: value} + }, {}) +} + /** * Used in dev mode to nicely log to the console */ @@ -100,7 +110,8 @@ export const consoleTransport: Transport = ( timestamp, ) => { const extra = Object.keys(metadata).length - ? ' ' + JSON.stringify(metadata, null, ' ') + ? // don't prepareMetadata here, in dev we want the stack trace + ' ' + JSON.stringify(metadata, null, ' ') : '' const log = { [LogLevel.Debug]: console.debug, @@ -119,6 +130,8 @@ export const sentryTransport: Transport = ( {type, tags, ...metadata}, timestamp, ) => { + const meta = prepareMetadata(metadata) + /** * If a string, report a breadcrumb */ @@ -135,7 +148,7 @@ export const sentryTransport: Transport = ( Sentry.addBreadcrumb({ message, - data: metadata, + data: meta, type: type || 'default', level: severity, timestamp: timestamp / 1000, // Sentry expects seconds @@ -155,7 +168,7 @@ export const sentryTransport: Transport = ( Sentry.captureMessage(message, { level: messageLevel, tags, - extra: metadata, + extra: meta, }) } } else { @@ -164,7 +177,7 @@ export const sentryTransport: Transport = ( */ Sentry.captureException(message, { tags, - extra: metadata, + extra: meta, }) } } @@ -275,16 +288,13 @@ export class Logger { */ export const logger = new Logger() -/** - * Report to console in dev, Sentry in prod, nothing in test. - */ if (env.IS_DEV && !env.IS_TEST) { logger.addTransport(consoleTransport) /** - * Uncomment this to test Sentry in dev + * Comment this out to disable Sentry transport in dev */ - // logger.addTransport(sentryTransport); + logger.addTransport(sentryTransport) } else if (env.IS_PROD) { - // logger.addTransport(sentryTransport) + logger.addTransport(sentryTransport) } diff --git a/src/state/cache/post-shadow.ts b/src/state/cache/post-shadow.ts new file mode 100644 index 000000000..e02d4f1ea --- /dev/null +++ b/src/state/cache/post-shadow.ts @@ -0,0 +1,101 @@ +import {useEffect, useState, useMemo} from 'react' +import EventEmitter from 'eventemitter3' +import {AppBskyFeedDefs} from '@atproto/api' +import {batchedUpdates} from '#/lib/batchedUpdates' +import {Shadow, castAsShadow} from './types' +import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '../queries/notifications/feed' +import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '../queries/post-feed' +import {findAllPostsInQueryData as findAllPostsInThreadQueryData} from '../queries/post-thread' +import {queryClient} from 'lib/react-query' +export type {Shadow} from './types' + +export interface PostShadow { + likeUri: string | undefined + likeCount: number | undefined + repostUri: string | undefined + repostCount: number | undefined + isDeleted: boolean +} + +export const POST_TOMBSTONE = Symbol('PostTombstone') + +const emitter = new EventEmitter() +const shadows: WeakMap< + AppBskyFeedDefs.PostView, + Partial<PostShadow> +> = new WeakMap() + +export function usePostShadow( + post: AppBskyFeedDefs.PostView, +): Shadow<AppBskyFeedDefs.PostView> | typeof POST_TOMBSTONE { + const [shadow, setShadow] = useState(() => shadows.get(post)) + const [prevPost, setPrevPost] = useState(post) + if (post !== prevPost) { + setPrevPost(post) + setShadow(shadows.get(post)) + } + + useEffect(() => { + function onUpdate() { + setShadow(shadows.get(post)) + } + emitter.addListener(post.uri, onUpdate) + return () => { + emitter.removeListener(post.uri, onUpdate) + } + }, [post, setShadow]) + + return useMemo(() => { + if (shadow) { + return mergeShadow(post, shadow) + } else { + return castAsShadow(post) + } + }, [post, shadow]) +} + +function mergeShadow( + post: AppBskyFeedDefs.PostView, + shadow: Partial<PostShadow>, +): Shadow<AppBskyFeedDefs.PostView> | typeof POST_TOMBSTONE { + if (shadow.isDeleted) { + return POST_TOMBSTONE + } + return castAsShadow({ + ...post, + likeCount: 'likeCount' in shadow ? shadow.likeCount : post.likeCount, + repostCount: + 'repostCount' in shadow ? shadow.repostCount : post.repostCount, + viewer: { + ...(post.viewer || {}), + like: 'likeUri' in shadow ? shadow.likeUri : post.viewer?.like, + repost: 'repostUri' in shadow ? shadow.repostUri : post.viewer?.repost, + }, + }) +} + +export function updatePostShadow(uri: string, value: Partial<PostShadow>) { + const cachedPosts = findPostsInCache(uri) + for (let post of cachedPosts) { + shadows.set(post, {...shadows.get(post), ...value}) + } + batchedUpdates(() => { + emitter.emit(uri) + }) +} + +function* findPostsInCache( + uri: string, +): Generator<AppBskyFeedDefs.PostView, void> { + for (let post of findAllPostsInFeedQueryData(queryClient, uri)) { + yield post + } + for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) { + yield post + } + for (let node of findAllPostsInThreadQueryData(queryClient, uri)) { + if (node.type === 'post') { + yield node.post + } + } +} diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts new file mode 100644 index 000000000..f85e1ad8d --- /dev/null +++ b/src/state/cache/profile-shadow.ts @@ -0,0 +1,101 @@ +import {useEffect, useState, useMemo} from 'react' +import EventEmitter from 'eventemitter3' +import {AppBskyActorDefs} from '@atproto/api' +import {batchedUpdates} from '#/lib/batchedUpdates' +import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '../queries/list-members' +import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '../queries/my-blocked-accounts' +import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '../queries/my-muted-accounts' +import {findAllProfilesInQueryData as findAllProfilesInPostLikedByQueryData} from '../queries/post-liked-by' +import {findAllProfilesInQueryData as findAllProfilesInPostRepostedByQueryData} from '../queries/post-reposted-by' +import {findAllProfilesInQueryData as findAllProfilesInProfileQueryData} from '../queries/profile' +import {findAllProfilesInQueryData as findAllProfilesInProfileFollowersQueryData} from '../queries/profile-followers' +import {findAllProfilesInQueryData as findAllProfilesInProfileFollowsQueryData} from '../queries/profile-follows' +import {findAllProfilesInQueryData as findAllProfilesInSuggestedFollowsQueryData} from '../queries/suggested-follows' +import {Shadow, castAsShadow} from './types' +import {queryClient} from 'lib/react-query' +export type {Shadow} from './types' + +export interface ProfileShadow { + followingUri: string | undefined + muted: boolean | undefined + blockingUri: string | undefined +} + +type ProfileView = + | AppBskyActorDefs.ProfileView + | AppBskyActorDefs.ProfileViewBasic + | AppBskyActorDefs.ProfileViewDetailed + +const shadows: WeakMap<ProfileView, Partial<ProfileShadow>> = new WeakMap() +const emitter = new EventEmitter() + +export function useProfileShadow(profile: ProfileView): Shadow<ProfileView> { + const [shadow, setShadow] = useState(() => shadows.get(profile)) + const [prevPost, setPrevPost] = useState(profile) + if (profile !== prevPost) { + setPrevPost(profile) + setShadow(shadows.get(profile)) + } + + useEffect(() => { + function onUpdate() { + setShadow(shadows.get(profile)) + } + emitter.addListener(profile.did, onUpdate) + return () => { + emitter.removeListener(profile.did, onUpdate) + } + }, [profile]) + + return useMemo(() => { + if (shadow) { + return mergeShadow(profile, shadow) + } else { + return castAsShadow(profile) + } + }, [profile, shadow]) +} + +export function updateProfileShadow( + did: string, + value: Partial<ProfileShadow>, +) { + const cachedProfiles = findProfilesInCache(did) + for (let post of cachedProfiles) { + shadows.set(post, {...shadows.get(post), ...value}) + } + batchedUpdates(() => { + emitter.emit(did, value) + }) +} + +function mergeShadow( + profile: ProfileView, + shadow: Partial<ProfileShadow>, +): Shadow<ProfileView> { + return castAsShadow({ + ...profile, + viewer: { + ...(profile.viewer || {}), + following: + 'followingUri' in shadow + ? shadow.followingUri + : profile.viewer?.following, + muted: 'muted' in shadow ? shadow.muted : profile.viewer?.muted, + blocking: + 'blockingUri' in shadow ? shadow.blockingUri : profile.viewer?.blocking, + }, + }) +} + +function* findProfilesInCache(did: string): Generator<ProfileView, void> { + yield* findAllProfilesInListMembersQueryData(queryClient, did) + yield* findAllProfilesInMyBlockedAccountsQueryData(queryClient, did) + yield* findAllProfilesInMyMutedAccountsQueryData(queryClient, did) + yield* findAllProfilesInPostLikedByQueryData(queryClient, did) + yield* findAllProfilesInPostRepostedByQueryData(queryClient, did) + yield* findAllProfilesInProfileQueryData(queryClient, did) + yield* findAllProfilesInProfileFollowersQueryData(queryClient, did) + yield* findAllProfilesInProfileFollowsQueryData(queryClient, did) + yield* findAllProfilesInSuggestedFollowsQueryData(queryClient, did) +} diff --git a/src/state/cache/types.ts b/src/state/cache/types.ts new file mode 100644 index 000000000..055f4167e --- /dev/null +++ b/src/state/cache/types.ts @@ -0,0 +1,7 @@ +// This isn't a real property, but it prevents T being compatible with Shadow<T>. +declare const shadowTag: unique symbol +export type Shadow<T> = T & {[shadowTag]: true} + +export function castAsShadow<T>(value: T): Shadow<T> { + return value as any as Shadow<T> +} diff --git a/src/state/events.ts b/src/state/events.ts new file mode 100644 index 000000000..5441aafef --- /dev/null +++ b/src/state/events.ts @@ -0,0 +1,38 @@ +import EventEmitter from 'eventemitter3' +import {BskyAgent} from '@atproto/api' +import {SessionAccount} from './session' + +type UnlistenFn = () => void + +const emitter = new EventEmitter() + +// a "soft reset" typically means scrolling to top and loading latest +// but it can depend on the screen +export function emitSoftReset() { + emitter.emit('soft-reset') +} +export function listenSoftReset(fn: () => void): UnlistenFn { + emitter.on('soft-reset', fn) + return () => emitter.off('soft-reset', fn) +} + +export function emitSessionLoaded( + sessionAccount: SessionAccount, + agent: BskyAgent, +) { + emitter.emit('session-loaded', sessionAccount, agent) +} +export function listenSessionLoaded( + fn: (sessionAccount: SessionAccount, agent: BskyAgent) => void, +): UnlistenFn { + emitter.on('session-loaded', fn) + return () => emitter.off('session-loaded', fn) +} + +export function emitSessionDropped() { + emitter.emit('session-dropped') +} +export function listenSessionDropped(fn: () => void): UnlistenFn { + emitter.on('session-dropped', fn) + return () => emitter.off('session-dropped', fn) +} diff --git a/src/state/index.ts b/src/state/index.ts deleted file mode 100644 index 55dcae6d6..000000000 --- a/src/state/index.ts +++ /dev/null @@ -1,54 +0,0 @@ -import {autorun} from 'mobx' -import {AppState, Platform} from 'react-native' -import {BskyAgent} from '@atproto/api' -import {RootStoreModel} from './models/root-store' -import * as apiPolyfill from 'lib/api/api-polyfill' -import * as storage from 'lib/storage' -import {logger} from '#/logger' - -export const LOCAL_DEV_SERVICE = - Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583' -export const STAGING_SERVICE = 'https://staging.bsky.dev' -export const PROD_SERVICE = 'https://bsky.social' -export const DEFAULT_SERVICE = PROD_SERVICE -const ROOT_STATE_STORAGE_KEY = 'root' -const STATE_FETCH_INTERVAL = 15e3 - -export async function setupState(serviceUri = DEFAULT_SERVICE) { - let rootStore: RootStoreModel - let data: any - - apiPolyfill.doPolyfill() - - rootStore = new RootStoreModel(new BskyAgent({service: serviceUri})) - try { - data = (await storage.load(ROOT_STATE_STORAGE_KEY)) || {} - logger.debug('Initial hydrate', {hasSession: !!data.session}) - rootStore.hydrate(data) - } catch (e: any) { - logger.error('Failed to load state from storage', {error: e}) - } - rootStore.attemptSessionResumption() - - // track changes & save to storage - autorun(() => { - const snapshot = rootStore.serialize() - storage.save(ROOT_STATE_STORAGE_KEY, snapshot) - }) - - // periodic state fetch - setInterval(() => { - // NOTE - // this must ONLY occur when the app is active, as the bg-fetch handler - // will wake up the thread and cause this interval to fire, which in - // turn schedules a bunch of work at a poor time - // -prf - if (AppState.currentState === 'active') { - rootStore.updateSessionState() - } - }, STATE_FETCH_INTERVAL) - - return rootStore -} - -export {useStores, RootStoreModel, RootStoreProvider} from './models/root-store' diff --git a/src/state/invites.tsx b/src/state/invites.tsx new file mode 100644 index 000000000..6a0d1b590 --- /dev/null +++ b/src/state/invites.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import * as persisted from '#/state/persisted' + +type StateContext = persisted.Schema['invites'] +type ApiContext = { + setInviteCopied: (code: string) => void +} + +const stateContext = React.createContext<StateContext>( + persisted.defaults.invites, +) +const apiContext = React.createContext<ApiContext>({ + setInviteCopied(_: string) {}, +}) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(persisted.get('invites')) + + const api = React.useMemo( + () => ({ + setInviteCopied(code: string) { + setState(state => { + state = { + ...state, + copiedInvites: state.copiedInvites.includes(code) + ? state.copiedInvites + : state.copiedInvites.concat([code]), + } + persisted.write('invites', state) + return state + }) + }, + }), + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate(() => { + setState(persisted.get('invites')) + }) + }, [setState]) + + return ( + <stateContext.Provider value={state}> + <apiContext.Provider value={api}>{children}</apiContext.Provider> + </stateContext.Provider> + ) +} + +export function useInvitesState() { + return React.useContext(stateContext) +} + +export function useInvitesAPI() { + return React.useContext(apiContext) +} diff --git a/src/state/lightbox.tsx b/src/state/lightbox.tsx new file mode 100644 index 000000000..e3bddaee0 --- /dev/null +++ b/src/state/lightbox.tsx @@ -0,0 +1,86 @@ +import React from 'react' +import {AppBskyActorDefs} from '@atproto/api' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' + +interface Lightbox { + name: string +} + +export class ProfileImageLightbox implements Lightbox { + name = 'profile-image' + constructor(public profile: AppBskyActorDefs.ProfileViewDetailed) {} +} + +interface ImagesLightboxItem { + uri: string + alt?: string +} + +export class ImagesLightbox implements Lightbox { + name = 'images' + constructor(public images: ImagesLightboxItem[], public index: number) {} + setIndex(index: number) { + this.index = index + } +} + +const LightboxContext = React.createContext<{ + activeLightbox: Lightbox | null +}>({ + activeLightbox: null, +}) + +const LightboxControlContext = React.createContext<{ + openLightbox: (lightbox: Lightbox) => void + closeLightbox: () => boolean +}>({ + openLightbox: () => {}, + closeLightbox: () => false, +}) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [activeLightbox, setActiveLightbox] = React.useState<Lightbox | null>( + null, + ) + + const openLightbox = useNonReactiveCallback((lightbox: Lightbox) => { + setActiveLightbox(lightbox) + }) + + const closeLightbox = useNonReactiveCallback(() => { + let wasActive = !!activeLightbox + setActiveLightbox(null) + return wasActive + }) + + const state = React.useMemo( + () => ({ + activeLightbox, + }), + [activeLightbox], + ) + + const methods = React.useMemo( + () => ({ + openLightbox, + closeLightbox, + }), + [openLightbox, closeLightbox], + ) + + return ( + <LightboxContext.Provider value={state}> + <LightboxControlContext.Provider value={methods}> + {children} + </LightboxControlContext.Provider> + </LightboxContext.Provider> + ) +} + +export function useLightbox() { + return React.useContext(LightboxContext) +} + +export function useLightboxControls() { + return React.useContext(LightboxControlContext) +} diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx new file mode 100644 index 000000000..84fea3bad --- /dev/null +++ b/src/state/modals/index.tsx @@ -0,0 +1,293 @@ +import React from 'react' +import {AppBskyActorDefs, AppBskyGraphDefs, ModerationUI} from '@atproto/api' +import {StyleProp, ViewStyle} from 'react-native' +import {Image as RNImage} from 'react-native-image-crop-picker' + +import {ImageModel} from '#/state/models/media/image' +import {GalleryModel} from '#/state/models/media/gallery' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' + +export interface ConfirmModal { + name: 'confirm' + title: string + message: string | (() => JSX.Element) + onPressConfirm: () => void | Promise<void> + onPressCancel?: () => void | Promise<void> + confirmBtnText?: string + confirmBtnStyle?: StyleProp<ViewStyle> + cancelBtnText?: string +} + +export interface EditProfileModal { + name: 'edit-profile' + profile: AppBskyActorDefs.ProfileViewDetailed + onUpdate?: () => void +} + +export interface ProfilePreviewModal { + name: 'profile-preview' + did: string +} + +export interface ServerInputModal { + name: 'server-input' + initialService: string + onSelect: (url: string) => void +} + +export interface ModerationDetailsModal { + name: 'moderation-details' + context: 'account' | 'content' + moderation: ModerationUI +} + +export type ReportModal = { + name: 'report' +} & ( + | { + uri: string + cid: string + } + | {did: string} +) + +export interface CreateOrEditListModal { + name: 'create-or-edit-list' + purpose?: string + list?: AppBskyGraphDefs.ListView + onSave?: (uri: string) => void +} + +export interface UserAddRemoveListsModal { + name: 'user-add-remove-lists' + subject: string + displayName: string + onAdd?: (listUri: string) => void + onRemove?: (listUri: string) => void +} + +export interface ListAddRemoveUsersModal { + name: 'list-add-remove-users' + list: AppBskyGraphDefs.ListView + onChange?: ( + type: 'add' | 'remove', + profile: AppBskyActorDefs.ProfileViewBasic, + ) => void +} + +export interface EditImageModal { + name: 'edit-image' + image: ImageModel + gallery: GalleryModel +} + +export interface CropImageModal { + name: 'crop-image' + uri: string + onSelect: (img?: RNImage) => void +} + +export interface AltTextImageModal { + name: 'alt-text-image' + image: ImageModel +} + +export interface DeleteAccountModal { + name: 'delete-account' +} + +export interface RepostModal { + name: 'repost' + onRepost: () => void + onQuote: () => void + isReposted: boolean +} + +export interface SelfLabelModal { + name: 'self-label' + labels: string[] + hasMedia: boolean + onChange: (labels: string[]) => void +} + +export interface ChangeHandleModal { + name: 'change-handle' + onChanged: () => void +} + +export interface WaitlistModal { + name: 'waitlist' +} + +export interface InviteCodesModal { + name: 'invite-codes' +} + +export interface AddAppPasswordModal { + name: 'add-app-password' +} + +export interface ContentFilteringSettingsModal { + name: 'content-filtering-settings' +} + +export interface ContentLanguagesSettingsModal { + name: 'content-languages-settings' +} + +export interface PostLanguagesSettingsModal { + name: 'post-languages-settings' +} + +export interface BirthDateSettingsModal { + name: 'birth-date-settings' +} + +export interface VerifyEmailModal { + name: 'verify-email' + showReminder?: boolean +} + +export interface ChangeEmailModal { + name: 'change-email' +} + +export interface SwitchAccountModal { + name: 'switch-account' +} + +export interface LinkWarningModal { + name: 'link-warning' + text: string + href: string +} + +export type Modal = + // Account + | AddAppPasswordModal + | ChangeHandleModal + | DeleteAccountModal + | EditProfileModal + | ProfilePreviewModal + | BirthDateSettingsModal + | VerifyEmailModal + | ChangeEmailModal + | SwitchAccountModal + + // Curation + | ContentFilteringSettingsModal + | ContentLanguagesSettingsModal + | PostLanguagesSettingsModal + + // Moderation + | ModerationDetailsModal + | ReportModal + + // Lists + | CreateOrEditListModal + | UserAddRemoveListsModal + | ListAddRemoveUsersModal + + // Posts + | AltTextImageModal + | CropImageModal + | EditImageModal + | ServerInputModal + | RepostModal + | SelfLabelModal + + // Bluesky access + | WaitlistModal + | InviteCodesModal + + // Generic + | ConfirmModal + | LinkWarningModal + +const ModalContext = React.createContext<{ + isModalActive: boolean + activeModals: Modal[] +}>({ + isModalActive: false, + activeModals: [], +}) + +const ModalControlContext = React.createContext<{ + openModal: (modal: Modal) => void + closeModal: () => boolean + closeAllModals: () => void +}>({ + openModal: () => {}, + closeModal: () => false, + closeAllModals: () => {}, +}) + +/** + * @deprecated DO NOT USE THIS unless you have no other choice. + */ +export let unstable__openModal: (modal: Modal) => void = () => { + throw new Error(`ModalContext is not initialized`) +} + +/** + * @deprecated DO NOT USE THIS unless you have no other choice. + */ +export let unstable__closeModal: () => boolean = () => { + throw new Error(`ModalContext is not initialized`) +} + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [activeModals, setActiveModals] = React.useState<Modal[]>([]) + + const openModal = useNonReactiveCallback((modal: Modal) => { + setActiveModals(modals => [...modals, modal]) + }) + + const closeModal = useNonReactiveCallback(() => { + let wasActive = activeModals.length > 0 + setActiveModals(modals => { + return modals.slice(0, -1) + }) + return wasActive + }) + + const closeAllModals = useNonReactiveCallback(() => { + setActiveModals([]) + }) + + unstable__openModal = openModal + unstable__closeModal = closeModal + + const state = React.useMemo( + () => ({ + isModalActive: activeModals.length > 0, + activeModals, + }), + [activeModals], + ) + + const methods = React.useMemo( + () => ({ + openModal, + closeModal, + closeAllModals, + }), + [openModal, closeModal, closeAllModals], + ) + + return ( + <ModalContext.Provider value={state}> + <ModalControlContext.Provider value={methods}> + {children} + </ModalControlContext.Provider> + </ModalContext.Provider> + ) +} + +export function useModals() { + return React.useContext(ModalContext) +} + +export function useModalControls() { + return React.useContext(ModalControlContext) +} diff --git a/src/state/models/cache/handle-resolutions.ts b/src/state/models/cache/handle-resolutions.ts deleted file mode 100644 index 2e2b69661..000000000 --- a/src/state/models/cache/handle-resolutions.ts +++ /dev/null @@ -1,5 +0,0 @@ -import {LRUMap} from 'lru_map' - -export class HandleResolutionsCache { - cache: LRUMap<string, string> = new LRUMap(500) -} diff --git a/src/state/models/cache/image-sizes.ts b/src/state/models/cache/image-sizes.ts deleted file mode 100644 index c30a68f4d..000000000 --- a/src/state/models/cache/image-sizes.ts +++ /dev/null @@ -1,38 +0,0 @@ -import {Image} from 'react-native' -import type {Dimensions} from 'lib/media/types' - -export class ImageSizesCache { - sizes: Map<string, Dimensions> = new Map() - activeRequests: Map<string, Promise<Dimensions>> = new Map() - - constructor() {} - - get(uri: string): Dimensions | undefined { - return this.sizes.get(uri) - } - - async fetch(uri: string): Promise<Dimensions> { - const Dimensions = this.sizes.get(uri) - if (Dimensions) { - return Dimensions - } - - const prom = - this.activeRequests.get(uri) || - new Promise<Dimensions>(resolve => { - Image.getSize( - uri, - (width: number, height: number) => resolve({width, height}), - (err: any) => { - console.error('Failed to fetch image dimensions for', uri, err) - resolve({width: 0, height: 0}) - }, - ) - }) - this.activeRequests.set(uri, prom) - const res = await prom - this.activeRequests.delete(uri) - this.sizes.set(uri, res) - return res - } -} diff --git a/src/state/models/cache/link-metas.ts b/src/state/models/cache/link-metas.ts deleted file mode 100644 index 607968c80..000000000 --- a/src/state/models/cache/link-metas.ts +++ /dev/null @@ -1,44 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import {LRUMap} from 'lru_map' -import {RootStoreModel} from '../root-store' -import {LinkMeta, getLinkMeta} from 'lib/link-meta/link-meta' - -type CacheValue = Promise<LinkMeta> | LinkMeta -export class LinkMetasCache { - cache: LRUMap<string, CacheValue> = new LRUMap(100) - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - { - rootStore: false, - cache: false, - }, - {autoBind: true}, - ) - } - - // public api - // = - - async getLinkMeta(url: string) { - const cached = this.cache.get(url) - if (cached) { - try { - return await cached - } catch (e) { - // ignore, we'll try again - } - } - try { - const promise = getLinkMeta(this.rootStore, url) - this.cache.set(url, promise) - const res = await promise - this.cache.set(url, res) - return res - } catch (e) { - this.cache.delete(url) - throw e - } - } -} diff --git a/src/state/models/cache/my-follows.ts b/src/state/models/cache/my-follows.ts deleted file mode 100644 index e1e8af509..000000000 --- a/src/state/models/cache/my-follows.ts +++ /dev/null @@ -1,137 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import { - AppBskyActorDefs, - AppBskyGraphGetFollows as GetFollows, - moderateProfile, -} from '@atproto/api' -import {RootStoreModel} from '../root-store' -import {bundleAsync} from 'lib/async/bundle' - -const MAX_SYNC_PAGES = 10 -const SYNC_TTL = 60e3 * 10 // 10 minutes - -type Profile = AppBskyActorDefs.ProfileViewBasic | AppBskyActorDefs.ProfileView - -export enum FollowState { - Following, - NotFollowing, - Unknown, -} - -export interface FollowInfo { - did: string - followRecordUri: string | undefined - handle: string - displayName: string | undefined - avatar: string | undefined -} - -/** - * This model is used to maintain a synced local cache of the user's - * follows. It should be periodically refreshed and updated any time - * the user makes a change to their follows. - */ -export class MyFollowsCache { - // data - byDid: Record<string, FollowInfo> = {} - lastSync = 0 - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - { - rootStore: false, - }, - {autoBind: true}, - ) - } - - // public api - // = - - clear() { - this.byDid = {} - } - - /** - * Syncs a subset of the user's follows - * for performance reasons, caps out at 1000 follows - */ - syncIfNeeded = bundleAsync(async () => { - if (this.lastSync > Date.now() - SYNC_TTL) { - return - } - - let cursor - for (let i = 0; i < MAX_SYNC_PAGES; i++) { - const res: GetFollows.Response = await this.rootStore.agent.getFollows({ - actor: this.rootStore.me.did, - cursor, - limit: 100, - }) - res.data.follows = res.data.follows.filter( - profile => - !moderateProfile(profile, this.rootStore.preferences.moderationOpts) - .account.filter, - ) - this.hydrateMany(res.data.follows) - if (!res.data.cursor) { - break - } - cursor = res.data.cursor - } - - this.lastSync = Date.now() - }) - - getFollowState(did: string): FollowState { - if (typeof this.byDid[did] === 'undefined') { - return FollowState.Unknown - } - if (typeof this.byDid[did].followRecordUri === 'string') { - return FollowState.Following - } - return FollowState.NotFollowing - } - - async fetchFollowState(did: string): Promise<FollowState> { - // TODO: can we get a more efficient method for this? getProfile fetches more data than we need -prf - const res = await this.rootStore.agent.getProfile({actor: did}) - this.hydrate(did, res.data) - return this.getFollowState(did) - } - - getFollowUri(did: string): string { - const v = this.byDid[did] - if (v && typeof v.followRecordUri === 'string') { - return v.followRecordUri - } - throw new Error('Not a followed user') - } - - addFollow(did: string, info: FollowInfo) { - this.byDid[did] = info - } - - removeFollow(did: string) { - if (this.byDid[did]) { - this.byDid[did].followRecordUri = undefined - } - } - - hydrate(did: string, profile: Profile) { - this.byDid[did] = { - did, - followRecordUri: profile.viewer?.following, - handle: profile.handle, - displayName: profile.displayName, - avatar: profile.avatar, - } - } - - hydrateMany(profiles: Profile[]) { - for (const profile of profiles) { - this.hydrate(profile.did, profile) - } - } -} diff --git a/src/state/models/cache/posts.ts b/src/state/models/cache/posts.ts deleted file mode 100644 index d3632f436..000000000 --- a/src/state/models/cache/posts.ts +++ /dev/null @@ -1,70 +0,0 @@ -import {LRUMap} from 'lru_map' -import {RootStoreModel} from '../root-store' -import { - AppBskyFeedDefs, - AppBskyEmbedRecord, - AppBskyEmbedRecordWithMedia, - AppBskyFeedPost, -} from '@atproto/api' - -type PostView = AppBskyFeedDefs.PostView - -export class PostsCache { - cache: LRUMap<string, PostView> = new LRUMap(500) - - constructor(public rootStore: RootStoreModel) {} - - set(uri: string, postView: PostView) { - this.cache.set(uri, postView) - if (postView.author.handle) { - this.rootStore.handleResolutions.cache.set( - postView.author.handle, - postView.author.did, - ) - } - } - - fromFeedItem(feedItem: AppBskyFeedDefs.FeedViewPost) { - this.set(feedItem.post.uri, feedItem.post) - if ( - feedItem.reply?.parent && - AppBskyFeedDefs.isPostView(feedItem.reply?.parent) - ) { - this.set(feedItem.reply.parent.uri, feedItem.reply.parent) - } - const embed = feedItem.post.embed - if ( - AppBskyEmbedRecord.isView(embed) && - AppBskyEmbedRecord.isViewRecord(embed.record) && - AppBskyFeedPost.isRecord(embed.record.value) && - AppBskyFeedPost.validateRecord(embed.record.value).success - ) { - this.set(embed.record.uri, embedViewToPostView(embed.record)) - } - if ( - AppBskyEmbedRecordWithMedia.isView(embed) && - AppBskyEmbedRecord.isViewRecord(embed.record?.record) && - AppBskyFeedPost.isRecord(embed.record.record.value) && - AppBskyFeedPost.validateRecord(embed.record.record.value).success - ) { - this.set( - embed.record.record.uri, - embedViewToPostView(embed.record.record), - ) - } - } -} - -function embedViewToPostView( - embedView: AppBskyEmbedRecord.ViewRecord, -): PostView { - return { - $type: 'app.bsky.feed.post#view', - uri: embedView.uri, - cid: embedView.cid, - author: embedView.author, - record: embedView.value, - indexedAt: embedView.indexedAt, - labels: embedView.labels, - } -} diff --git a/src/state/models/cache/profiles-view.ts b/src/state/models/cache/profiles-view.ts deleted file mode 100644 index e5a9be587..000000000 --- a/src/state/models/cache/profiles-view.ts +++ /dev/null @@ -1,50 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import {LRUMap} from 'lru_map' -import {RootStoreModel} from '../root-store' -import {AppBskyActorGetProfile as GetProfile} from '@atproto/api' - -type CacheValue = Promise<GetProfile.Response> | GetProfile.Response -export class ProfilesCache { - cache: LRUMap<string, CacheValue> = new LRUMap(100) - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - { - rootStore: false, - cache: false, - }, - {autoBind: true}, - ) - } - - // public api - // = - - async getProfile(did: string) { - const cached = this.cache.get(did) - if (cached) { - try { - return await cached - } catch (e) { - // ignore, we'll try again - } - } - try { - const promise = this.rootStore.agent.getProfile({ - actor: did, - }) - this.cache.set(did, promise) - const res = await promise - this.cache.set(did, res) - return res - } catch (e) { - this.cache.delete(did) - throw e - } - } - - overwrite(did: string, res: GetProfile.Response) { - this.cache.set(did, res) - } -} diff --git a/src/state/models/content/feed-source.ts b/src/state/models/content/feed-source.ts deleted file mode 100644 index 156e3be3b..000000000 --- a/src/state/models/content/feed-source.ts +++ /dev/null @@ -1,231 +0,0 @@ -import {AtUri, RichText, AppBskyFeedDefs, AppBskyGraphDefs} from '@atproto/api' -import {makeAutoObservable, runInAction} from 'mobx' -import {RootStoreModel} from 'state/models/root-store' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {sanitizeHandle} from 'lib/strings/handles' -import {bundleAsync} from 'lib/async/bundle' -import {cleanError} from 'lib/strings/errors' -import {track} from 'lib/analytics/analytics' -import {logger} from '#/logger' - -export class FeedSourceModel { - // state - _reactKey: string - hasLoaded = false - error: string | undefined - - // data - uri: string - cid: string = '' - type: 'feed-generator' | 'list' | 'unsupported' = 'unsupported' - avatar: string | undefined = '' - displayName: string = '' - descriptionRT: RichText | null = null - creatorDid: string = '' - creatorHandle: string = '' - likeCount: number | undefined = 0 - likeUri: string | undefined = '' - - constructor(public rootStore: RootStoreModel, uri: string) { - this._reactKey = uri - this.uri = uri - - try { - const urip = new AtUri(uri) - if (urip.collection === 'app.bsky.feed.generator') { - this.type = 'feed-generator' - } else if (urip.collection === 'app.bsky.graph.list') { - this.type = 'list' - } - } catch {} - this.displayName = uri.split('/').pop() || '' - - makeAutoObservable( - this, - { - rootStore: false, - }, - {autoBind: true}, - ) - } - - get href() { - const urip = new AtUri(this.uri) - const collection = - urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'lists' - return `/profile/${urip.hostname}/${collection}/${urip.rkey}` - } - - get isSaved() { - return this.rootStore.preferences.savedFeeds.includes(this.uri) - } - - get isPinned() { - return this.rootStore.preferences.isPinnedFeed(this.uri) - } - - get isLiked() { - return !!this.likeUri - } - - get isOwner() { - return this.creatorDid === this.rootStore.me.did - } - - setup = bundleAsync(async () => { - try { - if (this.type === 'feed-generator') { - const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({ - feed: this.uri, - }) - this.hydrateFeedGenerator(res.data.view) - } else if (this.type === 'list') { - const res = await this.rootStore.agent.app.bsky.graph.getList({ - list: this.uri, - limit: 1, - }) - this.hydrateList(res.data.list) - } - } catch (e) { - runInAction(() => { - this.error = cleanError(e) - }) - } - }) - - hydrateFeedGenerator(view: AppBskyFeedDefs.GeneratorView) { - this.uri = view.uri - this.cid = view.cid - this.avatar = view.avatar - this.displayName = view.displayName - ? sanitizeDisplayName(view.displayName) - : `Feed by ${sanitizeHandle(view.creator.handle, '@')}` - this.descriptionRT = new RichText({ - text: view.description || '', - facets: (view.descriptionFacets || [])?.slice(), - }) - this.creatorDid = view.creator.did - this.creatorHandle = view.creator.handle - this.likeCount = view.likeCount - this.likeUri = view.viewer?.like - this.hasLoaded = true - } - - hydrateList(view: AppBskyGraphDefs.ListView) { - this.uri = view.uri - this.cid = view.cid - this.avatar = view.avatar - this.displayName = view.name - ? sanitizeDisplayName(view.name) - : `User List by ${sanitizeHandle(view.creator.handle, '@')}` - this.descriptionRT = new RichText({ - text: view.description || '', - facets: (view.descriptionFacets || [])?.slice(), - }) - this.creatorDid = view.creator.did - this.creatorHandle = view.creator.handle - this.likeCount = undefined - this.hasLoaded = true - } - - async save() { - if (this.type !== 'feed-generator') { - return - } - try { - await this.rootStore.preferences.addSavedFeed(this.uri) - } catch (error) { - logger.error('Failed to save feed', {error}) - } finally { - track('CustomFeed:Save') - } - } - - async unsave() { - // TODO TEMPORARY — see PRF's comment in content/list.ts togglePin - if (this.type !== 'feed-generator' && this.type !== 'list') { - return - } - try { - await this.rootStore.preferences.removeSavedFeed(this.uri) - } catch (error) { - logger.error('Failed to unsave feed', {error}) - } finally { - track('CustomFeed:Unsave') - } - } - - async pin() { - try { - await this.rootStore.preferences.addPinnedFeed(this.uri) - } catch (error) { - logger.error('Failed to pin feed', {error}) - } finally { - track('CustomFeed:Pin', { - name: this.displayName, - uri: this.uri, - }) - } - } - - async togglePin() { - if (!this.isPinned) { - track('CustomFeed:Pin', { - name: this.displayName, - uri: this.uri, - }) - return this.rootStore.preferences.addPinnedFeed(this.uri) - } else { - track('CustomFeed:Unpin', { - name: this.displayName, - uri: this.uri, - }) - - if (this.type === 'list') { - // TODO TEMPORARY — see PRF's comment in content/list.ts togglePin - return this.unsave() - } else { - return this.rootStore.preferences.removePinnedFeed(this.uri) - } - } - } - - async like() { - if (this.type !== 'feed-generator') { - return - } - try { - this.likeUri = 'pending' - this.likeCount = (this.likeCount || 0) + 1 - const res = await this.rootStore.agent.like(this.uri, this.cid) - this.likeUri = res.uri - } catch (e: any) { - this.likeUri = undefined - this.likeCount = (this.likeCount || 1) - 1 - logger.error('Failed to like feed', {error: e}) - } finally { - track('CustomFeed:Like') - } - } - - async unlike() { - if (this.type !== 'feed-generator') { - return - } - if (!this.likeUri) { - return - } - const uri = this.likeUri - try { - this.likeUri = undefined - this.likeCount = (this.likeCount || 1) - 1 - await this.rootStore.agent.deleteLike(uri!) - } catch (e: any) { - this.likeUri = uri - this.likeCount = (this.likeCount || 0) + 1 - logger.error('Failed to unlike feed', {error: e}) - } finally { - track('CustomFeed:Unlike') - } - } -} diff --git a/src/state/models/content/list-membership.ts b/src/state/models/content/list-membership.ts deleted file mode 100644 index 135d34dd5..000000000 --- a/src/state/models/content/list-membership.ts +++ /dev/null @@ -1,130 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import {AtUri, AppBskyGraphListitem} from '@atproto/api' -import {runInAction} from 'mobx' -import {RootStoreModel} from '../root-store' - -const PAGE_SIZE = 100 -interface Membership { - uri: string - value: AppBskyGraphListitem.Record -} - -interface ListitemRecord { - uri: string - value: AppBskyGraphListitem.Record -} - -interface ListitemListResponse { - cursor?: string - records: ListitemRecord[] -} - -export class ListMembershipModel { - // data - memberships: Membership[] = [] - - constructor(public rootStore: RootStoreModel, public subject: string) { - makeAutoObservable( - this, - { - rootStore: false, - }, - {autoBind: true}, - ) - } - - // public api - // = - - async fetch() { - // NOTE - // this approach to determining list membership is too inefficient to work at any scale - // it needs to be replaced with server side list membership queries - // -prf - let cursor - let records: ListitemRecord[] = [] - for (let i = 0; i < 100; i++) { - const res: ListitemListResponse = - await this.rootStore.agent.app.bsky.graph.listitem.list({ - repo: this.rootStore.me.did, - cursor, - limit: PAGE_SIZE, - }) - records = records.concat( - res.records.filter(record => record.value.subject === this.subject), - ) - cursor = res.cursor - if (!cursor) { - break - } - } - runInAction(() => { - this.memberships = records - }) - } - - getMembership(listUri: string) { - return this.memberships.find(m => m.value.list === listUri) - } - - isMember(listUri: string) { - return !!this.getMembership(listUri) - } - - async add(listUri: string) { - if (this.isMember(listUri)) { - return - } - const res = await this.rootStore.agent.app.bsky.graph.listitem.create( - { - repo: this.rootStore.me.did, - }, - { - subject: this.subject, - list: listUri, - createdAt: new Date().toISOString(), - }, - ) - const {rkey} = new AtUri(res.uri) - const record = await this.rootStore.agent.app.bsky.graph.listitem.get({ - repo: this.rootStore.me.did, - rkey, - }) - runInAction(() => { - this.memberships = this.memberships.concat([record]) - }) - } - - async remove(listUri: string) { - const membership = this.getMembership(listUri) - if (!membership) { - return - } - const {rkey} = new AtUri(membership.uri) - await this.rootStore.agent.app.bsky.graph.listitem.delete({ - repo: this.rootStore.me.did, - rkey, - }) - runInAction(() => { - this.memberships = this.memberships.filter(m => m.value.list !== listUri) - }) - } - - async updateTo( - uris: string[], - ): Promise<{added: string[]; removed: string[]}> { - const added = [] - const removed = [] - for (const uri of uris) { - await this.add(uri) - added.push(uri) - } - for (const membership of this.memberships) { - if (!uris.includes(membership.value.list)) { - await this.remove(membership.value.list) - removed.push(membership.value.list) - } - } - return {added, removed} - } -} diff --git a/src/state/models/content/list.ts b/src/state/models/content/list.ts deleted file mode 100644 index fc09eeb9f..000000000 --- a/src/state/models/content/list.ts +++ /dev/null @@ -1,508 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import { - AtUri, - AppBskyActorDefs, - AppBskyGraphGetList as GetList, - AppBskyGraphDefs as GraphDefs, - AppBskyGraphList, - AppBskyGraphListitem, - RichText, -} from '@atproto/api' -import {Image as RNImage} from 'react-native-image-crop-picker' -import chunk from 'lodash.chunk' -import {RootStoreModel} from '../root-store' -import * as apilib from 'lib/api/index' -import {cleanError} from 'lib/strings/errors' -import {bundleAsync} from 'lib/async/bundle' -import {track} from 'lib/analytics/analytics' -import {until} from 'lib/async/until' -import {logger} from '#/logger' - -const PAGE_SIZE = 30 - -interface ListitemRecord { - uri: string - value: AppBskyGraphListitem.Record -} - -interface ListitemListResponse { - cursor?: string - records: ListitemRecord[] -} - -export class ListModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - loadMoreError = '' - hasMore = true - loadMoreCursor?: string - - // data - data: GraphDefs.ListView | null = null - items: GraphDefs.ListItemView[] = [] - descriptionRT: RichText | null = null - - static async createList( - rootStore: RootStoreModel, - { - purpose, - name, - description, - avatar, - }: { - purpose: string - name: string - description: string - avatar: RNImage | null | undefined - }, - ) { - if ( - purpose !== 'app.bsky.graph.defs#curatelist' && - purpose !== 'app.bsky.graph.defs#modlist' - ) { - throw new Error('Invalid list purpose: must be curatelist or modlist') - } - const record: AppBskyGraphList.Record = { - purpose, - name, - description, - avatar: undefined, - createdAt: new Date().toISOString(), - } - if (avatar) { - const blobRes = await apilib.uploadBlob( - rootStore, - avatar.path, - avatar.mime, - ) - record.avatar = blobRes.data.blob - } - const res = await rootStore.agent.app.bsky.graph.list.create( - { - repo: rootStore.me.did, - }, - record, - ) - - // wait for the appview to update - await until( - 5, // 5 tries - 1e3, // 1s delay between tries - (v: GetList.Response, _e: any) => { - return typeof v?.data?.list.uri === 'string' - }, - () => - rootStore.agent.app.bsky.graph.getList({ - list: res.uri, - limit: 1, - }), - ) - return res - } - - constructor(public rootStore: RootStoreModel, public uri: string) { - makeAutoObservable( - this, - { - rootStore: false, - }, - {autoBind: true}, - ) - } - - get hasContent() { - return this.items.length > 0 - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - get isCuratelist() { - return this.data?.purpose === 'app.bsky.graph.defs#curatelist' - } - - get isModlist() { - return this.data?.purpose === 'app.bsky.graph.defs#modlist' - } - - get isOwner() { - return this.data?.creator.did === this.rootStore.me.did - } - - get isBlocking() { - return !!this.data?.viewer?.blocked - } - - get isMuting() { - return !!this.data?.viewer?.muted - } - - get isPinned() { - return this.rootStore.preferences.isPinnedFeed(this.uri) - } - - get creatorDid() { - return this.data?.creator.did - } - - getMembership(did: string) { - return this.items.find(item => item.subject.did === did) - } - - isMember(did: string) { - return !!this.getMembership(did) - } - - // public api - // = - - async refresh() { - return this.loadMore(true) - } - - loadMore = bundleAsync(async (replace: boolean = false) => { - if (!replace && !this.hasMore) { - return - } - this._xLoading(replace) - try { - await this._resolveUri() - const res = await this.rootStore.agent.app.bsky.graph.getList({ - list: this.uri, - limit: PAGE_SIZE, - cursor: replace ? undefined : this.loadMoreCursor, - }) - if (replace) { - this._replaceAll(res) - } else { - this._appendAll(res) - } - this._xIdle() - } catch (e: any) { - this._xIdle(replace ? e : undefined, !replace ? e : undefined) - } - }) - - async loadAll() { - for (let i = 0; i < 1000; i++) { - if (!this.hasMore) { - break - } - await this.loadMore() - } - } - - async updateMetadata({ - name, - description, - avatar, - }: { - name: string - description: string - avatar: RNImage | null | undefined - }) { - if (!this.data) { - return - } - if (!this.isOwner) { - throw new Error('Cannot edit this list') - } - await this._resolveUri() - - // get the current record - const {rkey} = new AtUri(this.uri) - const {value: record} = await this.rootStore.agent.app.bsky.graph.list.get({ - repo: this.rootStore.me.did, - rkey, - }) - - // update the fields - record.name = name - record.description = description - if (avatar) { - const blobRes = await apilib.uploadBlob( - this.rootStore, - avatar.path, - avatar.mime, - ) - record.avatar = blobRes.data.blob - } else if (avatar === null) { - record.avatar = undefined - } - return await this.rootStore.agent.com.atproto.repo.putRecord({ - repo: this.rootStore.me.did, - collection: 'app.bsky.graph.list', - rkey, - record, - }) - } - - async delete() { - if (!this.data) { - return - } - await this._resolveUri() - - // fetch all the listitem records that belong to this list - let cursor - let records: ListitemRecord[] = [] - for (let i = 0; i < 100; i++) { - const res: ListitemListResponse = - await this.rootStore.agent.app.bsky.graph.listitem.list({ - repo: this.rootStore.me.did, - cursor, - limit: PAGE_SIZE, - }) - records = records.concat( - res.records.filter(record => record.value.list === this.uri), - ) - cursor = res.cursor - if (!cursor) { - break - } - } - - // batch delete the list and listitem records - const createDel = (uri: string) => { - const urip = new AtUri(uri) - return { - $type: 'com.atproto.repo.applyWrites#delete', - collection: urip.collection, - rkey: urip.rkey, - } - } - const writes = records - .map(record => createDel(record.uri)) - .concat([createDel(this.uri)]) - - // apply in chunks - for (const writesChunk of chunk(writes, 10)) { - await this.rootStore.agent.com.atproto.repo.applyWrites({ - repo: this.rootStore.me.did, - writes: writesChunk, - }) - } - - /* dont await */ this.rootStore.preferences.removeSavedFeed(this.uri) - this.rootStore.emitListDeleted(this.uri) - } - - async addMember(profile: AppBskyActorDefs.ProfileViewBasic) { - if (this.isMember(profile.did)) { - return - } - await this.rootStore.agent.app.bsky.graph.listitem.create( - { - repo: this.rootStore.me.did, - }, - { - subject: profile.did, - list: this.uri, - createdAt: new Date().toISOString(), - }, - ) - runInAction(() => { - this.items = this.items.concat([ - {_reactKey: profile.did, subject: profile}, - ]) - }) - } - - /** - * Just adds to local cache; used to reflect changes affected elsewhere - */ - cacheAddMember(profile: AppBskyActorDefs.ProfileViewBasic) { - if (!this.isMember(profile.did)) { - this.items = this.items.concat([ - {_reactKey: profile.did, subject: profile}, - ]) - } - } - - /** - * Just removes from local cache; used to reflect changes affected elsewhere - */ - cacheRemoveMember(profile: AppBskyActorDefs.ProfileViewBasic) { - if (this.isMember(profile.did)) { - this.items = this.items.filter(item => item.subject.did !== profile.did) - } - } - - async pin() { - try { - await this.rootStore.preferences.addPinnedFeed(this.uri) - } catch (error) { - logger.error('Failed to pin feed', {error}) - } finally { - track('CustomFeed:Pin', { - name: this.data?.name || '', - uri: this.uri, - }) - } - } - - async togglePin() { - if (!this.isPinned) { - track('CustomFeed:Pin', { - name: this.data?.name || '', - uri: this.uri, - }) - return this.rootStore.preferences.addPinnedFeed(this.uri) - } else { - track('CustomFeed:Unpin', { - name: this.data?.name || '', - uri: this.uri, - }) - // TODO TEMPORARY - // lists are temporarily piggybacking on the saved/pinned feeds preferences - // we'll eventually replace saved feeds with the bookmarks API - // until then, we need to unsave lists instead of just unpin them - // -prf - // return this.rootStore.preferences.removePinnedFeed(this.uri) - return this.rootStore.preferences.removeSavedFeed(this.uri) - } - } - - async mute() { - if (!this.data) { - return - } - await this._resolveUri() - await this.rootStore.agent.muteModList(this.data.uri) - track('Lists:Mute') - runInAction(() => { - if (this.data) { - const d = this.data - this.data = {...d, viewer: {...(d.viewer || {}), muted: true}} - } - }) - } - - async unmute() { - if (!this.data) { - return - } - await this._resolveUri() - await this.rootStore.agent.unmuteModList(this.data.uri) - track('Lists:Unmute') - runInAction(() => { - if (this.data) { - const d = this.data - this.data = {...d, viewer: {...(d.viewer || {}), muted: false}} - } - }) - } - - async block() { - if (!this.data) { - return - } - await this._resolveUri() - const res = await this.rootStore.agent.blockModList(this.data.uri) - track('Lists:Block') - runInAction(() => { - if (this.data) { - const d = this.data - this.data = {...d, viewer: {...(d.viewer || {}), blocked: res.uri}} - } - }) - } - - async unblock() { - if (!this.data || !this.data.viewer?.blocked) { - return - } - await this._resolveUri() - await this.rootStore.agent.unblockModList(this.data.uri) - track('Lists:Unblock') - runInAction(() => { - if (this.data) { - const d = this.data - this.data = {...d, viewer: {...(d.viewer || {}), blocked: undefined}} - } - }) - } - - /** - * Attempt to load more again after a failure - */ - async retryLoadMore() { - this.loadMoreError = '' - this.hasMore = true - return this.loadMore() - } - - // state transitions - // = - - _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - _xIdle(err?: any, loadMoreErr?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = cleanError(err) - this.loadMoreError = cleanError(loadMoreErr) - if (err) { - logger.error('Failed to fetch user items', {error: err}) - } - if (loadMoreErr) { - logger.error('Failed to fetch user items', { - error: loadMoreErr, - }) - } - } - - // helper functions - // = - - async _resolveUri() { - const urip = new AtUri(this.uri) - if (!urip.host.startsWith('did:')) { - try { - urip.host = await apilib.resolveName(this.rootStore, urip.host) - } catch (e: any) { - runInAction(() => { - this.error = e.toString() - }) - } - } - runInAction(() => { - this.uri = urip.toString() - }) - } - - _replaceAll(res: GetList.Response) { - this.items = [] - this._appendAll(res) - } - - _appendAll(res: GetList.Response) { - this.loadMoreCursor = res.data.cursor - this.hasMore = !!this.loadMoreCursor - this.data = res.data.list - this.items = this.items.concat( - res.data.items.map(item => ({...item, _reactKey: item.subject.did})), - ) - if (this.data.description) { - this.descriptionRT = new RichText({ - text: this.data.description, - facets: (this.data.descriptionFacets || [])?.slice(), - }) - } else { - this.descriptionRT = null - } - } -} diff --git a/src/state/models/content/post-thread-item.ts b/src/state/models/content/post-thread-item.ts deleted file mode 100644 index 942f3acc8..000000000 --- a/src/state/models/content/post-thread-item.ts +++ /dev/null @@ -1,139 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import { - AppBskyFeedPost as FeedPost, - AppBskyFeedDefs, - RichText, - PostModeration, -} from '@atproto/api' -import {RootStoreModel} from '../root-store' -import {PostsFeedItemModel} from '../feeds/post' - -type PostView = AppBskyFeedDefs.PostView - -// NOTE: this model uses the same data as PostsFeedItemModel, but is used for -// rendering a single post in a thread view, and has additional state -// for rendering the thread view, but calls the same data methods -// as PostsFeedItemModel -// TODO: refactor as an extension or subclass of PostsFeedItemModel -export class PostThreadItemModel { - // ui state - _reactKey: string = '' - _depth = 0 - _isHighlightedPost = false - _showParentReplyLine = false - _showChildReplyLine = false - _hasMore = false - - // data - data: PostsFeedItemModel - post: PostView - postRecord?: FeedPost.Record - richText?: RichText - parent?: - | PostThreadItemModel - | AppBskyFeedDefs.NotFoundPost - | AppBskyFeedDefs.BlockedPost - replies?: (PostThreadItemModel | AppBskyFeedDefs.NotFoundPost)[] - - constructor( - public rootStore: RootStoreModel, - v: AppBskyFeedDefs.ThreadViewPost, - ) { - this._reactKey = `thread-${v.post.uri}` - this.data = new PostsFeedItemModel(rootStore, this._reactKey, v) - this.post = this.data.post - this.postRecord = this.data.postRecord - this.richText = this.data.richText - // replies and parent are handled via assignTreeModels - makeAutoObservable(this, {rootStore: false}) - } - - get uri() { - return this.post.uri - } - - get parentUri() { - return this.postRecord?.reply?.parent.uri - } - - get rootUri(): string { - if (this.postRecord?.reply?.root.uri) { - return this.postRecord.reply.root.uri - } - return this.post.uri - } - - get isThreadMuted() { - return this.data.isThreadMuted - } - - get moderation(): PostModeration { - return this.data.moderation - } - - assignTreeModels( - v: AppBskyFeedDefs.ThreadViewPost, - highlightedPostUri: string, - includeParent = true, - includeChildren = true, - ) { - // parents - if (includeParent && v.parent) { - if (AppBskyFeedDefs.isThreadViewPost(v.parent)) { - const parentModel = new PostThreadItemModel(this.rootStore, v.parent) - parentModel._depth = this._depth - 1 - parentModel._showChildReplyLine = true - if (v.parent.parent) { - parentModel._showParentReplyLine = true - parentModel.assignTreeModels( - v.parent, - highlightedPostUri, - true, - false, - ) - } - this.parent = parentModel - } else if (AppBskyFeedDefs.isNotFoundPost(v.parent)) { - this.parent = v.parent - } else if (AppBskyFeedDefs.isBlockedPost(v.parent)) { - this.parent = v.parent - } - } - // replies - if (includeChildren && v.replies) { - const replies = [] - for (const item of v.replies) { - if (AppBskyFeedDefs.isThreadViewPost(item)) { - const itemModel = new PostThreadItemModel(this.rootStore, item) - itemModel._depth = this._depth + 1 - itemModel._showParentReplyLine = - itemModel.parentUri !== highlightedPostUri - if (item.replies?.length) { - itemModel._showChildReplyLine = true - itemModel.assignTreeModels(item, highlightedPostUri, false, true) - } - replies.push(itemModel) - } else if (AppBskyFeedDefs.isNotFoundPost(item)) { - replies.push(item) - } - } - this.replies = replies - } - } - - async toggleLike() { - this.data.toggleLike() - } - - async toggleRepost() { - this.data.toggleRepost() - } - - async toggleThreadMute() { - this.data.toggleThreadMute() - } - - async delete() { - this.data.delete() - } -} diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts deleted file mode 100644 index fd194056a..000000000 --- a/src/state/models/content/post-thread.ts +++ /dev/null @@ -1,354 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import { - AppBskyFeedGetPostThread as GetPostThread, - AppBskyFeedDefs, - AppBskyFeedPost, - PostModeration, -} from '@atproto/api' -import {AtUri} from '@atproto/api' -import {RootStoreModel} from '../root-store' -import * as apilib from 'lib/api/index' -import {cleanError} from 'lib/strings/errors' -import {ThreadViewPreference} from '../ui/preferences' -import {PostThreadItemModel} from './post-thread-item' -import {logger} from '#/logger' - -export class PostThreadModel { - // state - isLoading = false - isLoadingFromCache = false - isFromCache = false - isRefreshing = false - hasLoaded = false - error = '' - notFound = false - resolvedUri = '' - params: GetPostThread.QueryParams - - // data - thread?: PostThreadItemModel | null = null - isBlocked = false - - constructor( - public rootStore: RootStoreModel, - params: GetPostThread.QueryParams, - ) { - makeAutoObservable( - this, - { - rootStore: false, - params: false, - }, - {autoBind: true}, - ) - this.params = params - } - - static fromPostView( - rootStore: RootStoreModel, - postView: AppBskyFeedDefs.PostView, - ) { - const model = new PostThreadModel(rootStore, {uri: postView.uri}) - model.resolvedUri = postView.uri - model.hasLoaded = true - model.thread = new PostThreadItemModel(rootStore, { - post: postView, - }) - return model - } - - get hasContent() { - return !!this.thread - } - - get hasError() { - return this.error !== '' - } - - get rootUri(): string { - if (this.thread) { - if (this.thread.postRecord?.reply?.root.uri) { - return this.thread.postRecord.reply.root.uri - } - } - return this.resolvedUri - } - - get isThreadMuted() { - return this.rootStore.mutedThreads.uris.has(this.rootUri) - } - - get isCachedPostAReply() { - if (AppBskyFeedPost.isRecord(this.thread?.post.record)) { - return !!this.thread?.post.record.reply - } - return false - } - - // public api - // = - - /** - * Load for first render - */ - async setup() { - if (!this.resolvedUri) { - await this._resolveUri() - } - - if (this.hasContent) { - await this.update() - } else { - const precache = this.rootStore.posts.cache.get(this.resolvedUri) - if (precache) { - await this._loadPrecached(precache) - } else { - await this._load() - } - } - } - - /** - * Register any event listeners. Returns a cleanup function. - */ - registerListeners() { - const sub = this.rootStore.onPostDeleted(this.onPostDeleted.bind(this)) - return () => sub.remove() - } - - /** - * Reset and load - */ - async refresh() { - await this._load(true) - } - - /** - * Update content in-place - */ - async update() { - // NOTE: it currently seems that a full load-and-replace works fine for this - // if the UI loses its place or has jarring re-arrangements, replace this - // with a more in-place update - this._load() - } - - /** - * Refreshes when posts are deleted - */ - onPostDeleted(_uri: string) { - this.refresh() - } - - async toggleThreadMute() { - if (this.isThreadMuted) { - this.rootStore.mutedThreads.uris.delete(this.rootUri) - } else { - this.rootStore.mutedThreads.uris.add(this.rootUri) - } - } - - // state transitions - // = - - _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - this.notFound = false - } - - _xIdle(err?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = cleanError(err) - if (err) { - logger.error('Failed to fetch post thread', {error: err}) - } - this.notFound = err instanceof GetPostThread.NotFoundError - } - - // loader functions - // = - - async _resolveUri() { - const urip = new AtUri(this.params.uri) - if (!urip.host.startsWith('did:')) { - try { - urip.host = await apilib.resolveName(this.rootStore, urip.host) - } catch (e: any) { - runInAction(() => { - this.error = e.toString() - }) - } - } - runInAction(() => { - this.resolvedUri = urip.toString() - }) - } - - async _loadPrecached(precache: AppBskyFeedDefs.PostView) { - // start with the cached version - this.isLoadingFromCache = true - this.isFromCache = true - this._replaceAll({ - success: true, - headers: {}, - data: { - thread: { - post: precache, - }, - }, - }) - this._xIdle() - - // then update in the background - try { - const res = await this.rootStore.agent.getPostThread( - Object.assign({}, this.params, {uri: this.resolvedUri}), - ) - this._replaceAll(res) - } catch (e: any) { - console.log(e) - this._xIdle(e) - } finally { - runInAction(() => { - this.isLoadingFromCache = false - }) - } - } - - async _load(isRefreshing = false) { - if (this.hasLoaded && !isRefreshing) { - return - } - this._xLoading(isRefreshing) - try { - const res = await this.rootStore.agent.getPostThread( - Object.assign({}, this.params, {uri: this.resolvedUri}), - ) - this._replaceAll(res) - this._xIdle() - } catch (e: any) { - console.log(e) - this._xIdle(e) - } - } - - _replaceAll(res: GetPostThread.Response) { - this.isBlocked = AppBskyFeedDefs.isBlockedPost(res.data.thread) - if (this.isBlocked) { - return - } - pruneReplies(res.data.thread) - const thread = new PostThreadItemModel( - this.rootStore, - res.data.thread as AppBskyFeedDefs.ThreadViewPost, - ) - thread._isHighlightedPost = true - thread.assignTreeModels( - res.data.thread as AppBskyFeedDefs.ThreadViewPost, - thread.uri, - ) - sortThread(thread, this.rootStore.preferences.thread) - this.thread = thread - } -} - -type MaybePost = - | AppBskyFeedDefs.ThreadViewPost - | AppBskyFeedDefs.NotFoundPost - | AppBskyFeedDefs.BlockedPost - | {[k: string]: unknown; $type: string} -function pruneReplies(post: MaybePost) { - if (post.replies) { - post.replies = (post.replies as MaybePost[]).filter((reply: MaybePost) => { - if (reply.blocked) { - return false - } - pruneReplies(reply) - return true - }) - } -} - -type MaybeThreadItem = - | PostThreadItemModel - | AppBskyFeedDefs.NotFoundPost - | AppBskyFeedDefs.BlockedPost -function sortThread(item: MaybeThreadItem, opts: ThreadViewPreference) { - if ('notFound' in item) { - return - } - item = item as PostThreadItemModel - if (item.replies) { - item.replies.sort((a: MaybeThreadItem, b: MaybeThreadItem) => { - if ('notFound' in a && a.notFound) { - return 1 - } - if ('notFound' in b && b.notFound) { - return -1 - } - item = item as PostThreadItemModel - a = a as PostThreadItemModel - b = b as PostThreadItemModel - const aIsByOp = a.post.author.did === item.post.author.did - const bIsByOp = b.post.author.did === item.post.author.did - if (aIsByOp && bIsByOp) { - return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest - } else if (aIsByOp) { - return -1 // op's own reply - } else if (bIsByOp) { - return 1 // op's own reply - } - // put moderated content down at the bottom - if (modScore(a.moderation) !== modScore(b.moderation)) { - return modScore(a.moderation) - modScore(b.moderation) - } - if (opts.prioritizeFollowedUsers) { - const af = a.post.author.viewer?.following - const bf = b.post.author.viewer?.following - if (af && !bf) { - return -1 - } else if (!af && bf) { - return 1 - } - } - if (opts.sort === 'oldest') { - return a.post.indexedAt.localeCompare(b.post.indexedAt) - } else if (opts.sort === 'newest') { - return b.post.indexedAt.localeCompare(a.post.indexedAt) - } else if (opts.sort === 'most-likes') { - if (a.post.likeCount === b.post.likeCount) { - return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest - } else { - return (b.post.likeCount || 0) - (a.post.likeCount || 0) // most likes - } - } else if (opts.sort === 'random') { - return 0.5 - Math.random() // this is vaguely criminal but we can get away with it - } - return b.post.indexedAt.localeCompare(a.post.indexedAt) - }) - item.replies.forEach(reply => sortThread(reply, opts)) - } -} - -function modScore(mod: PostModeration): number { - if (mod.content.blur && mod.content.noOverride) { - return 5 - } - if (mod.content.blur) { - return 4 - } - if (mod.content.alert) { - return 3 - } - if (mod.embed.blur && mod.embed.noOverride) { - return 2 - } - if (mod.embed.blur) { - return 1 - } - return 0 -} diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts deleted file mode 100644 index 14362ceec..000000000 --- a/src/state/models/content/profile.ts +++ /dev/null @@ -1,306 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import { - AtUri, - ComAtprotoLabelDefs, - AppBskyGraphDefs, - AppBskyActorGetProfile as GetProfile, - AppBskyActorProfile, - RichText, - moderateProfile, - ProfileModeration, -} from '@atproto/api' -import {RootStoreModel} from '../root-store' -import * as apilib from 'lib/api/index' -import {cleanError} from 'lib/strings/errors' -import {FollowState} from '../cache/my-follows' -import {Image as RNImage} from 'react-native-image-crop-picker' -import {track} from 'lib/analytics/analytics' -import {logger} from '#/logger' - -export class ProfileViewerModel { - muted?: boolean - mutedByList?: AppBskyGraphDefs.ListViewBasic - following?: string - followedBy?: string - blockedBy?: boolean - blocking?: string - blockingByList?: AppBskyGraphDefs.ListViewBasic; - [key: string]: unknown - - constructor() { - makeAutoObservable(this) - } -} - -export class ProfileModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - params: GetProfile.QueryParams - - // data - did: string = '' - handle: string = '' - creator: string = '' - displayName?: string = '' - description?: string = '' - avatar?: string = '' - banner?: string = '' - followersCount: number = 0 - followsCount: number = 0 - postsCount: number = 0 - labels?: ComAtprotoLabelDefs.Label[] = undefined - viewer = new ProfileViewerModel(); - [key: string]: unknown - - // added data - descriptionRichText?: RichText = new RichText({text: ''}) - - constructor( - public rootStore: RootStoreModel, - params: GetProfile.QueryParams, - ) { - makeAutoObservable( - this, - { - rootStore: false, - params: false, - }, - {autoBind: true}, - ) - this.params = params - } - - get hasContent() { - return this.did !== '' - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - get moderation(): ProfileModeration { - return moderateProfile(this, this.rootStore.preferences.moderationOpts) - } - - // public api - // = - - async setup() { - const precache = await this.rootStore.profiles.cache.get(this.params.actor) - if (precache) { - await this._loadWithCache(precache) - } else { - await this._load() - } - } - - async refresh() { - await this._load(true) - } - - async toggleFollowing() { - if (!this.rootStore.me.did) { - throw new Error('Not logged in') - } - - const follows = this.rootStore.me.follows - const followUri = - (await follows.fetchFollowState(this.did)) === FollowState.Following - ? follows.getFollowUri(this.did) - : undefined - - // guard against this view getting out of sync with the follows cache - if (followUri !== this.viewer.following) { - this.viewer.following = followUri - return - } - - if (followUri) { - // unfollow - await this.rootStore.agent.deleteFollow(followUri) - runInAction(() => { - this.followersCount-- - this.viewer.following = undefined - this.rootStore.me.follows.removeFollow(this.did) - }) - track('Profile:Unfollow', { - username: this.handle, - }) - } else { - // follow - const res = await this.rootStore.agent.follow(this.did) - runInAction(() => { - this.followersCount++ - this.viewer.following = res.uri - this.rootStore.me.follows.hydrate(this.did, this) - }) - track('Profile:Follow', { - username: this.handle, - }) - } - } - - async updateProfile( - updates: AppBskyActorProfile.Record, - newUserAvatar: RNImage | undefined | null, - newUserBanner: RNImage | undefined | null, - ) { - await this.rootStore.agent.upsertProfile(async existing => { - existing = existing || {} - existing.displayName = updates.displayName - existing.description = updates.description - if (newUserAvatar) { - const res = await apilib.uploadBlob( - this.rootStore, - newUserAvatar.path, - newUserAvatar.mime, - ) - existing.avatar = res.data.blob - } else if (newUserAvatar === null) { - existing.avatar = undefined - } - if (newUserBanner) { - const res = await apilib.uploadBlob( - this.rootStore, - newUserBanner.path, - newUserBanner.mime, - ) - existing.banner = res.data.blob - } else if (newUserBanner === null) { - existing.banner = undefined - } - return existing - }) - await this.rootStore.me.load() - await this.refresh() - } - - async muteAccount() { - await this.rootStore.agent.mute(this.did) - this.viewer.muted = true - await this.refresh() - } - - async unmuteAccount() { - await this.rootStore.agent.unmute(this.did) - this.viewer.muted = false - await this.refresh() - } - - async blockAccount() { - const res = await this.rootStore.agent.app.bsky.graph.block.create( - { - repo: this.rootStore.me.did, - }, - { - subject: this.did, - createdAt: new Date().toISOString(), - }, - ) - this.viewer.blocking = res.uri - await this.refresh() - } - - async unblockAccount() { - if (!this.viewer.blocking) { - return - } - const {rkey} = new AtUri(this.viewer.blocking) - await this.rootStore.agent.app.bsky.graph.block.delete({ - repo: this.rootStore.me.did, - rkey, - }) - this.viewer.blocking = undefined - await this.refresh() - } - - // state transitions - // = - - _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - _xIdle(err?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = cleanError(err) - if (err) { - logger.error('Failed to fetch profile', {error: err}) - } - } - - // loader functions - // = - - async _load(isRefreshing = false) { - this._xLoading(isRefreshing) - try { - const res = await this.rootStore.agent.getProfile(this.params) - this.rootStore.profiles.overwrite(this.params.actor, res) - if (res.data.handle) { - this.rootStore.handleResolutions.cache.set( - res.data.handle, - res.data.did, - ) - } - this._replaceAll(res) - await this._createRichText() - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - } - - async _loadWithCache(precache: GetProfile.Response) { - // use cached value - this._replaceAll(precache) - await this._createRichText() - this._xIdle() - - // fetch latest - try { - const res = await this.rootStore.agent.getProfile(this.params) - this.rootStore.profiles.overwrite(this.params.actor, res) // cache invalidation - this._replaceAll(res) - await this._createRichText() - } catch (e: any) { - this._xIdle(e) - } - } - - _replaceAll(res: GetProfile.Response) { - this.did = res.data.did - this.handle = res.data.handle - this.displayName = res.data.displayName - this.description = res.data.description - this.avatar = res.data.avatar - this.banner = res.data.banner - this.followersCount = res.data.followersCount || 0 - this.followsCount = res.data.followsCount || 0 - this.postsCount = res.data.postsCount || 0 - this.labels = res.data.labels - if (res.data.viewer) { - Object.assign(this.viewer, res.data.viewer) - } - this.rootStore.me.follows.hydrate(this.did, res.data) - } - - async _createRichText() { - this.descriptionRichText = new RichText( - {text: this.description || ''}, - {cleanNewlines: true}, - ) - await this.descriptionRichText.detectFacets(this.rootStore.agent) - } -} diff --git a/src/state/models/discovery/feeds.ts b/src/state/models/discovery/feeds.ts deleted file mode 100644 index a7c94e40d..000000000 --- a/src/state/models/discovery/feeds.ts +++ /dev/null @@ -1,148 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import {AppBskyUnspeccedGetPopularFeedGenerators} from '@atproto/api' -import {RootStoreModel} from '../root-store' -import {bundleAsync} from 'lib/async/bundle' -import {cleanError} from 'lib/strings/errors' -import {FeedSourceModel} from '../content/feed-source' -import {logger} from '#/logger' - -const DEFAULT_LIMIT = 50 - -export class FeedsDiscoveryModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - loadMoreCursor: string | undefined = undefined - - // data - feeds: FeedSourceModel[] = [] - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - { - rootStore: false, - }, - {autoBind: true}, - ) - } - - get hasMore() { - if (this.loadMoreCursor) { - return true - } - return false - } - - get hasContent() { - return this.feeds.length > 0 - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - // public api - // = - - refresh = bundleAsync(async () => { - this._xLoading() - try { - const res = - await this.rootStore.agent.app.bsky.unspecced.getPopularFeedGenerators({ - limit: DEFAULT_LIMIT, - }) - this._replaceAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - }) - - loadMore = bundleAsync(async () => { - if (!this.hasMore) { - return - } - this._xLoading() - try { - const res = - await this.rootStore.agent.app.bsky.unspecced.getPopularFeedGenerators({ - limit: DEFAULT_LIMIT, - cursor: this.loadMoreCursor, - }) - this._append(res) - } catch (e: any) { - this._xIdle(e) - } - this._xIdle() - }) - - search = async (query: string) => { - this._xLoading(false) - try { - const results = - await this.rootStore.agent.app.bsky.unspecced.getPopularFeedGenerators({ - limit: DEFAULT_LIMIT, - query: query, - }) - this._replaceAll(results) - } catch (e: any) { - this._xIdle(e) - } - this._xIdle() - } - - clear() { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = false - this.error = '' - this.feeds = [] - } - - // state transitions - // = - - _xLoading(isRefreshing = true) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - _xIdle(err?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = cleanError(err) - if (err) { - logger.error('Failed to fetch popular feeds', {error: err}) - } - } - - // helper functions - // = - - _replaceAll(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) { - // 1. set feeds data to empty array - this.feeds = [] - // 2. call this._append() - this._append(res) - } - - _append(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) { - // 1. push data into feeds array - for (const f of res.data.feeds) { - const model = new FeedSourceModel(this.rootStore, f.uri) - model.hydrateFeedGenerator(f) - this.feeds.push(model) - } - // 2. set loadMoreCursor - this.loadMoreCursor = res.data.cursor - } -} diff --git a/src/state/models/discovery/foafs.ts b/src/state/models/discovery/foafs.ts deleted file mode 100644 index 4a647dcfe..000000000 --- a/src/state/models/discovery/foafs.ts +++ /dev/null @@ -1,132 +0,0 @@ -import {AppBskyActorDefs} from '@atproto/api' -import {makeAutoObservable, runInAction} from 'mobx' -import sampleSize from 'lodash.samplesize' -import {bundleAsync} from 'lib/async/bundle' -import {RootStoreModel} from '../root-store' - -export type RefWithInfoAndFollowers = AppBskyActorDefs.ProfileViewBasic & { - followers: AppBskyActorDefs.ProfileView[] -} - -export type ProfileViewFollows = AppBskyActorDefs.ProfileView & { - follows: AppBskyActorDefs.ProfileViewBasic[] -} - -export class FoafsModel { - isLoading = false - hasData = false - sources: string[] = [] - foafs: Map<string, ProfileViewFollows> = new Map() // FOAF stands for Friend of a Friend - popular: RefWithInfoAndFollowers[] = [] - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable(this) - } - - get hasContent() { - if (this.popular.length > 0) { - return true - } - for (const foaf of this.foafs.values()) { - if (foaf.follows.length) { - return true - } - } - return false - } - - fetch = bundleAsync(async () => { - try { - this.isLoading = true - - // fetch some of the user's follows - await this.rootStore.me.follows.syncIfNeeded() - - // grab 10 of the users followed by the user - runInAction(() => { - this.sources = sampleSize( - Object.keys(this.rootStore.me.follows.byDid), - 10, - ) - }) - if (this.sources.length === 0) { - return - } - runInAction(() => { - this.foafs.clear() - this.popular.length = 0 - }) - - // fetch their profiles - const profiles = await this.rootStore.agent.getProfiles({ - actors: this.sources, - }) - - // fetch their follows - const results = await Promise.allSettled( - this.sources.map(source => - this.rootStore.agent.getFollows({actor: source}), - ), - ) - - // store the follows and construct a "most followed" set - const popular: RefWithInfoAndFollowers[] = [] - for (let i = 0; i < results.length; i++) { - const res = results[i] - if (res.status === 'fulfilled') { - this.rootStore.me.follows.hydrateMany(res.value.data.follows) - } - const profile = profiles.data.profiles[i] - const source = this.sources[i] - if (res.status === 'fulfilled' && profile) { - // filter out inappropriate suggestions - res.value.data.follows = res.value.data.follows.filter(follow => { - const viewer = follow.viewer - if (viewer) { - if ( - viewer.following || - viewer.muted || - viewer.mutedByList || - viewer.blockedBy || - viewer.blocking - ) { - return false - } - } - if (follow.did === this.rootStore.me.did) { - return false - } - return true - }) - - runInAction(() => { - this.foafs.set(source, { - ...profile, - follows: res.value.data.follows, - }) - }) - for (const follow of res.value.data.follows) { - let item = popular.find(p => p.did === follow.did) - if (!item) { - item = {...follow, followers: []} - popular.push(item) - } - item.followers.push(profile) - } - } - } - - popular.sort((a, b) => b.followers.length - a.followers.length) - runInAction(() => { - this.popular = popular.filter(p => p.followers.length > 1).slice(0, 20) - }) - this.hasData = true - } catch (e) { - console.error('Failed to fetch FOAFs', e) - } finally { - runInAction(() => { - this.isLoading = false - }) - } - }) -} diff --git a/src/state/models/discovery/onboarding.ts b/src/state/models/discovery/onboarding.ts deleted file mode 100644 index 3638e7f0d..000000000 --- a/src/state/models/discovery/onboarding.ts +++ /dev/null @@ -1,106 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import {RootStoreModel} from '../root-store' -import {hasProp} from 'lib/type-guards' -import {track} from 'lib/analytics/analytics' -import {SuggestedActorsModel} from './suggested-actors' - -export const OnboardingScreenSteps = { - Welcome: 'Welcome', - RecommendedFeeds: 'RecommendedFeeds', - RecommendedFollows: 'RecommendedFollows', - Home: 'Home', -} as const - -type OnboardingStep = - (typeof OnboardingScreenSteps)[keyof typeof OnboardingScreenSteps] -const OnboardingStepsArray = Object.values(OnboardingScreenSteps) -export class OnboardingModel { - // state - step: OnboardingStep = 'Home' // default state to skip onboarding, only enabled for new users by calling start() - - // data - suggestedActors: SuggestedActorsModel - - constructor(public rootStore: RootStoreModel) { - this.suggestedActors = new SuggestedActorsModel(this.rootStore) - makeAutoObservable(this, { - rootStore: false, - hydrate: false, - serialize: false, - }) - } - - serialize(): unknown { - return { - step: this.step, - } - } - - hydrate(v: unknown) { - if (typeof v === 'object' && v !== null) { - if ( - hasProp(v, 'step') && - typeof v.step === 'string' && - OnboardingStepsArray.includes(v.step as OnboardingStep) - ) { - this.step = v.step as OnboardingStep - } - } else { - // if there is no valid state, we'll just reset - this.reset() - } - } - - /** - * Returns the name of the next screen in the onboarding process based on the current step or screen name provided. - * @param {OnboardingStep} [currentScreenName] - * @returns name of next screen in the onboarding process - */ - next(currentScreenName?: OnboardingStep) { - currentScreenName = currentScreenName || this.step - if (currentScreenName === 'Welcome') { - this.step = 'RecommendedFeeds' - return this.step - } else if (this.step === 'RecommendedFeeds') { - this.step = 'RecommendedFollows' - // prefetch recommended follows - this.suggestedActors.loadMore(true) - return this.step - } else if (this.step === 'RecommendedFollows') { - this.finish() - return this.step - } else { - // if we get here, we're in an invalid state, let's just go Home - return 'Home' - } - } - - start() { - this.step = 'Welcome' - track('Onboarding:Begin') - } - - finish() { - this.rootStore.me.mainFeed.refresh() // load the selected content - this.step = 'Home' - track('Onboarding:Complete') - } - - reset() { - this.step = 'Welcome' - track('Onboarding:Reset') - } - - skip() { - this.step = 'Home' - track('Onboarding:Skipped') - } - - get isComplete() { - return this.step === 'Home' - } - - get isActive() { - return !this.isComplete - } -} diff --git a/src/state/models/discovery/suggested-actors.ts b/src/state/models/discovery/suggested-actors.ts deleted file mode 100644 index 450786c2f..000000000 --- a/src/state/models/discovery/suggested-actors.ts +++ /dev/null @@ -1,151 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import {AppBskyActorDefs, moderateProfile} from '@atproto/api' -import {RootStoreModel} from '../root-store' -import {cleanError} from 'lib/strings/errors' -import {bundleAsync} from 'lib/async/bundle' -import {logger} from '#/logger' - -const PAGE_SIZE = 30 - -export type SuggestedActor = - | AppBskyActorDefs.ProfileViewBasic - | AppBskyActorDefs.ProfileView - -export class SuggestedActorsModel { - // state - pageSize = PAGE_SIZE - isLoading = false - isRefreshing = false - hasLoaded = false - loadMoreCursor: string | undefined = undefined - error = '' - hasMore = false - lastInsertedAtIndex = -1 - - // data - suggestions: SuggestedActor[] = [] - - constructor(public rootStore: RootStoreModel, opts?: {pageSize?: number}) { - if (opts?.pageSize) { - this.pageSize = opts.pageSize - } - makeAutoObservable( - this, - { - rootStore: false, - }, - {autoBind: true}, - ) - } - - get hasContent() { - return this.suggestions.length > 0 - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - // public api - // = - - async refresh() { - return this.loadMore(true) - } - - loadMore = bundleAsync(async (replace: boolean = false) => { - if (replace) { - this.hasMore = true - this.loadMoreCursor = undefined - } - if (!this.hasMore) { - return - } - this._xLoading(replace) - try { - const res = await this.rootStore.agent.app.bsky.actor.getSuggestions({ - limit: 25, - cursor: this.loadMoreCursor, - }) - let {actors, cursor} = res.data - actors = actors.filter( - actor => - !moderateProfile(actor, this.rootStore.preferences.moderationOpts) - .account.filter, - ) - this.rootStore.me.follows.hydrateMany(actors) - - runInAction(() => { - if (replace) { - this.suggestions = [] - } - this.loadMoreCursor = cursor - this.hasMore = !!cursor - this.suggestions = this.suggestions.concat( - actors.filter(actor => { - const viewer = actor.viewer - if (viewer) { - if ( - viewer.following || - viewer.muted || - viewer.mutedByList || - viewer.blockedBy || - viewer.blocking - ) { - return false - } - } - if (actor.did === this.rootStore.me.did) { - return false - } - return true - }), - ) - }) - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - }) - - async insertSuggestionsByActor(actor: string, indexToInsertAt: number) { - // fetch suggestions - const res = - await this.rootStore.agent.app.bsky.graph.getSuggestedFollowsByActor({ - actor: actor, - }) - const {suggestions: moreSuggestions} = res.data - this.rootStore.me.follows.hydrateMany(moreSuggestions) - // dedupe - const toInsert = moreSuggestions.filter( - s => !this.suggestions.find(s2 => s2.did === s.did), - ) - // insert - this.suggestions.splice(indexToInsertAt + 1, 0, ...toInsert) - // update index - this.lastInsertedAtIndex = indexToInsertAt - } - - // state transitions - // = - - _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - _xIdle(err?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = cleanError(err) - if (err) { - logger.error('Failed to fetch suggested actors', {error: err}) - } - } -} diff --git a/src/state/models/discovery/user-autocomplete.ts b/src/state/models/discovery/user-autocomplete.ts deleted file mode 100644 index f28869e83..000000000 --- a/src/state/models/discovery/user-autocomplete.ts +++ /dev/null @@ -1,143 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import {AppBskyActorDefs} from '@atproto/api' -import AwaitLock from 'await-lock' -import {RootStoreModel} from '../root-store' -import {isInvalidHandle} from 'lib/strings/handles' - -type ProfileViewBasic = AppBskyActorDefs.ProfileViewBasic - -export class UserAutocompleteModel { - // state - isLoading = false - isActive = false - prefix = '' - lock = new AwaitLock() - - // data - knownHandles: Set<string> = new Set() - _suggestions: ProfileViewBasic[] = [] - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - { - rootStore: false, - knownHandles: false, - }, - {autoBind: true}, - ) - } - - get follows(): ProfileViewBasic[] { - return Object.values(this.rootStore.me.follows.byDid).map(item => ({ - did: item.did, - handle: item.handle, - displayName: item.displayName, - avatar: item.avatar, - })) - } - - get suggestions(): ProfileViewBasic[] { - if (!this.isActive) { - return [] - } - return this._suggestions - } - - // public api - // = - - async setup() { - this.isLoading = true - await this.rootStore.me.follows.syncIfNeeded() - runInAction(() => { - for (const did in this.rootStore.me.follows.byDid) { - const info = this.rootStore.me.follows.byDid[did] - if (!isInvalidHandle(info.handle)) { - this.knownHandles.add(info.handle) - } - } - this.isLoading = false - }) - } - - setActive(v: boolean) { - this.isActive = v - } - - async setPrefix(prefix: string) { - const origPrefix = prefix.trim().toLocaleLowerCase() - this.prefix = origPrefix - await this.lock.acquireAsync() - try { - if (this.prefix) { - if (this.prefix !== origPrefix) { - return // another prefix was set before we got our chance - } - - // reset to follow results - this._computeSuggestions([]) - - // ask backend - const res = await this.rootStore.agent.searchActorsTypeahead({ - term: this.prefix, - limit: 8, - }) - this._computeSuggestions(res.data.actors) - - // update known handles - runInAction(() => { - for (const u of res.data.actors) { - this.knownHandles.add(u.handle) - } - }) - } else { - runInAction(() => { - this._computeSuggestions([]) - }) - } - } finally { - this.lock.release() - } - } - - // internal - // = - - _computeSuggestions(searchRes: AppBskyActorDefs.ProfileViewBasic[] = []) { - if (this.prefix) { - const items: ProfileViewBasic[] = [] - for (const item of this.follows) { - if (prefixMatch(this.prefix, item)) { - items.push(item) - } - if (items.length >= 8) { - break - } - } - for (const item of searchRes) { - if (!items.find(item2 => item2.handle === item.handle)) { - items.push({ - did: item.did, - handle: item.handle, - displayName: item.displayName, - avatar: item.avatar, - }) - } - } - this._suggestions = items - } else { - this._suggestions = this.follows - } - } -} - -function prefixMatch(prefix: string, info: ProfileViewBasic): boolean { - if (info.handle.includes(prefix)) { - return true - } - if (info.displayName?.toLocaleLowerCase().includes(prefix)) { - return true - } - return false -} diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts deleted file mode 100644 index 607e3038b..000000000 --- a/src/state/models/feeds/notifications.ts +++ /dev/null @@ -1,671 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import { - AppBskyNotificationListNotifications as ListNotifications, - AppBskyActorDefs, - AppBskyFeedDefs, - AppBskyFeedPost, - AppBskyFeedRepost, - AppBskyFeedLike, - AppBskyGraphFollow, - ComAtprotoLabelDefs, - moderatePost, - moderateProfile, -} from '@atproto/api' -import AwaitLock from 'await-lock' -import chunk from 'lodash.chunk' -import {bundleAsync} from 'lib/async/bundle' -import {RootStoreModel} from '../root-store' -import {PostThreadModel} from '../content/post-thread' -import {cleanError} from 'lib/strings/errors' -import {logger} from '#/logger' - -const GROUPABLE_REASONS = ['like', 'repost', 'follow'] -const PAGE_SIZE = 30 -const MS_1HR = 1e3 * 60 * 60 -const MS_2DAY = MS_1HR * 48 - -export const MAX_VISIBLE_NOTIFS = 30 - -export interface GroupedNotification extends ListNotifications.Notification { - additional?: ListNotifications.Notification[] -} - -type SupportedRecord = - | AppBskyFeedPost.Record - | AppBskyFeedRepost.Record - | AppBskyFeedLike.Record - | AppBskyGraphFollow.Record - -export class NotificationsFeedItemModel { - // ui state - _reactKey: string = '' - - // data - uri: string = '' - cid: string = '' - author: AppBskyActorDefs.ProfileViewBasic = { - did: '', - handle: '', - avatar: '', - } - reason: string = '' - reasonSubject?: string - record?: SupportedRecord - isRead: boolean = false - indexedAt: string = '' - labels?: ComAtprotoLabelDefs.Label[] - additional?: NotificationsFeedItemModel[] - - // additional data - additionalPost?: PostThreadModel - - constructor( - public rootStore: RootStoreModel, - reactKey: string, - v: GroupedNotification, - ) { - makeAutoObservable(this, {rootStore: false}) - this._reactKey = reactKey - this.copy(v) - } - - copy(v: GroupedNotification, preserve = false) { - this.uri = v.uri - this.cid = v.cid - this.author = v.author - this.reason = v.reason - this.reasonSubject = v.reasonSubject - this.record = this.toSupportedRecord(v.record) - this.isRead = v.isRead - this.indexedAt = v.indexedAt - this.labels = v.labels - if (v.additional?.length) { - this.additional = [] - for (const add of v.additional) { - this.additional.push( - new NotificationsFeedItemModel(this.rootStore, '', add), - ) - } - } else if (!preserve) { - this.additional = undefined - } - } - - get shouldFilter(): boolean { - if (this.additionalPost?.thread) { - const postMod = moderatePost( - this.additionalPost.thread.data.post, - this.rootStore.preferences.moderationOpts, - ) - return postMod.content.filter || false - } - const profileMod = moderateProfile( - this.author, - this.rootStore.preferences.moderationOpts, - ) - return profileMod.account.filter || false - } - - get numUnreadInGroup(): number { - if (this.additional?.length) { - return ( - this.additional.reduce( - (acc, notif) => acc + notif.numUnreadInGroup, - 0, - ) + (this.isRead ? 0 : 1) - ) - } - return this.isRead ? 0 : 1 - } - - markGroupRead() { - if (this.additional?.length) { - for (const notif of this.additional) { - notif.markGroupRead() - } - } - this.isRead = true - } - - get isLike() { - return this.reason === 'like' && !this.isCustomFeedLike // the reason property for custom feed likes is also 'like' - } - - get isRepost() { - return this.reason === 'repost' - } - - get isMention() { - return this.reason === 'mention' - } - - get isReply() { - return this.reason === 'reply' - } - - get isQuote() { - return this.reason === 'quote' - } - - get isFollow() { - return this.reason === 'follow' - } - - get isCustomFeedLike() { - return ( - this.reason === 'like' && this.reasonSubject?.includes('feed.generator') - ) - } - - get needsAdditionalData() { - if ( - this.isLike || - this.isRepost || - this.isReply || - this.isQuote || - this.isMention - ) { - return !this.additionalPost - } - return false - } - - get additionalDataUri(): string | undefined { - if (this.isReply || this.isQuote || this.isMention) { - return this.uri - } else if (this.isLike || this.isRepost) { - return this.subjectUri - } - } - - get subjectUri(): string { - if (this.reasonSubject) { - return this.reasonSubject - } - const record = this.record - if ( - AppBskyFeedRepost.isRecord(record) || - AppBskyFeedLike.isRecord(record) - ) { - return record.subject.uri - } - return '' - } - - get reasonSubjectRootUri(): string | undefined { - if (this.additionalPost) { - return this.additionalPost.rootUri - } - return undefined - } - - toSupportedRecord(v: unknown): SupportedRecord | undefined { - for (const ns of [ - AppBskyFeedPost, - AppBskyFeedRepost, - AppBskyFeedLike, - AppBskyGraphFollow, - ]) { - if (ns.isRecord(v)) { - const valid = ns.validateRecord(v) - if (valid.success) { - return v - } else { - logger.warn('Received an invalid record', { - record: v, - error: valid.error, - }) - return - } - } - } - logger.warn( - 'app.bsky.notifications.list served an unsupported record type', - {record: v}, - ) - } - - setAdditionalData(additionalPost: AppBskyFeedDefs.PostView) { - if (this.additionalPost) { - this.additionalPost._replaceAll({ - success: true, - headers: {}, - data: { - thread: { - post: additionalPost, - }, - }, - }) - } else { - this.additionalPost = PostThreadModel.fromPostView( - this.rootStore, - additionalPost, - ) - } - } -} - -export class NotificationsFeedModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - loadMoreError = '' - hasMore = true - loadMoreCursor?: string - - /** - * The last time notifications were seen. Refers to either the - * user's machine clock or the value of the `indexedAt` property on their - * latest notification, whichever was greater at the time of viewing. - */ - lastSync?: Date - - // used to linearize async modifications to state - lock = new AwaitLock() - - // data - notifications: NotificationsFeedItemModel[] = [] - queuedNotifications: undefined | NotificationsFeedItemModel[] = undefined - unreadCount = 0 - - // this is used to help trigger push notifications - mostRecentNotificationUri: string | undefined - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - { - rootStore: false, - mostRecentNotificationUri: false, - }, - {autoBind: true}, - ) - } - - get hasContent() { - return this.notifications.length !== 0 - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - get hasNewLatest() { - return Boolean( - this.queuedNotifications && this.queuedNotifications?.length > 0, - ) - } - - get unreadCountLabel(): string { - const count = this.unreadCount + this.rootStore.invitedUsers.numNotifs - if (count >= MAX_VISIBLE_NOTIFS) { - return `${MAX_VISIBLE_NOTIFS}+` - } - if (count === 0) { - return '' - } - return String(count) - } - - // public api - // = - - /** - * Nuke all data - */ - clear() { - logger.debug('NotificationsModel:clear') - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = false - this.error = '' - this.hasMore = true - this.loadMoreCursor = undefined - this.notifications = [] - this.unreadCount = 0 - this.rootStore.emitUnreadNotifications(0) - this.mostRecentNotificationUri = undefined - } - - /** - * Load for first render - */ - setup = bundleAsync(async (isRefreshing: boolean = false) => { - logger.debug('NotificationsModel:refresh', {isRefreshing}) - await this.lock.acquireAsync() - try { - this._xLoading(isRefreshing) - try { - const res = await this.rootStore.agent.listNotifications({ - limit: PAGE_SIZE, - }) - await this._replaceAll(res) - this._setQueued(undefined) - this._countUnread() - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - } finally { - this.lock.release() - } - }) - - /** - * Reset and load - */ - async refresh() { - this.isRefreshing = true // set optimistically for UI - return this.setup(true) - } - - /** - * Sync the next set of notifications to show - */ - syncQueue = bundleAsync(async () => { - logger.debug('NotificationsModel:syncQueue') - if (this.unreadCount >= MAX_VISIBLE_NOTIFS) { - return // no need to check - } - await this.lock.acquireAsync() - try { - const res = await this.rootStore.agent.listNotifications({ - limit: PAGE_SIZE, - }) - - const queue = [] - for (const notif of res.data.notifications) { - if (this.notifications.length) { - if (isEq(notif, this.notifications[0])) { - break - } - } else { - if (!notif.isRead) { - break - } - } - queue.push(notif) - } - - // NOTE - // because filtering depends on the added information we have to fetch - // the full models here. this is *not* ideal performance and we need - // to update the notifications route to give all the info we need - // -prf - const queueModels = await this._fetchItemModels(queue) - this._setQueued(this._filterNotifications(queueModels)) - this._countUnread() - } catch (e) { - logger.error('NotificationsModel:syncQueue failed', { - error: e, - }) - } finally { - this.lock.release() - } - - // if there are no notifications, we should refresh the list - // this will only run for new users who have no notifications - // NOTE: needs to be after the lock is released - if (this.isEmpty) { - this.refresh() - } - }) - - /** - * Load more posts to the end of the notifications - */ - loadMore = bundleAsync(async () => { - if (!this.hasMore) { - return - } - await this.lock.acquireAsync() - try { - this._xLoading() - try { - const res = await this.rootStore.agent.listNotifications({ - limit: PAGE_SIZE, - cursor: this.loadMoreCursor, - }) - await this._appendAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle(undefined, e) - runInAction(() => { - this.hasMore = false - }) - } - } finally { - this.lock.release() - } - }) - - /** - * Attempt to load more again after a failure - */ - async retryLoadMore() { - this.loadMoreError = '' - this.hasMore = true - return this.loadMore() - } - - // unread notification in-place - // = - async update() { - const promises = [] - for (const item of this.notifications) { - if (item.additionalPost) { - promises.push(item.additionalPost.update()) - } - } - await Promise.all(promises).catch(e => { - logger.error('Uncaught failure during notifications update()', e) - }) - } - - /** - * Update read/unread state - */ - async markAllRead() { - try { - for (const notif of this.notifications) { - notif.markGroupRead() - } - this._countUnread() - await this.rootStore.agent.updateSeenNotifications( - this.lastSync ? this.lastSync.toISOString() : undefined, - ) - } catch (e: any) { - logger.warn('Failed to update notifications read state', { - error: e, - }) - } - } - - // state transitions - // = - - _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - _xIdle(error?: any, loadMoreError?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = cleanError(error) - this.loadMoreError = cleanError(loadMoreError) - if (error) { - logger.error('Failed to fetch notifications', {error}) - } - if (loadMoreError) { - logger.error('Failed to load more notifications', { - error: loadMoreError, - }) - } - } - - // helper functions - // = - - async _replaceAll(res: ListNotifications.Response) { - const latest = res.data.notifications[0] - - if (latest) { - const now = new Date() - const lastIndexed = new Date(latest.indexedAt) - const nowOrLastIndexed = now > lastIndexed ? now : lastIndexed - - this.mostRecentNotificationUri = latest.uri - this.lastSync = nowOrLastIndexed - } - - return this._appendAll(res, true) - } - - async _appendAll(res: ListNotifications.Response, replace = false) { - this.loadMoreCursor = res.data.cursor - this.hasMore = !!this.loadMoreCursor - const itemModels = await this._processNotifications(res.data.notifications) - runInAction(() => { - if (replace) { - this.notifications = itemModels - } else { - this.notifications = this.notifications.concat(itemModels) - } - }) - } - - _filterNotifications( - items: NotificationsFeedItemModel[], - ): NotificationsFeedItemModel[] { - return items - .filter(item => { - const hideByLabel = item.shouldFilter - let mutedThread = !!( - item.reasonSubjectRootUri && - this.rootStore.mutedThreads.uris.has(item.reasonSubjectRootUri) - ) - return !hideByLabel && !mutedThread - }) - .map(item => { - if (item.additional?.length) { - item.additional = this._filterNotifications(item.additional) - } - return item - }) - } - - async _fetchItemModels( - items: ListNotifications.Notification[], - ): Promise<NotificationsFeedItemModel[]> { - // construct item models and track who needs more data - const itemModels: NotificationsFeedItemModel[] = [] - const addedPostMap = new Map<string, NotificationsFeedItemModel[]>() - for (const item of items) { - const itemModel = new NotificationsFeedItemModel( - this.rootStore, - `notification-${item.uri}`, - item, - ) - const uri = itemModel.additionalDataUri - if (uri) { - const models = addedPostMap.get(uri) || [] - models.push(itemModel) - addedPostMap.set(uri, models) - } - itemModels.push(itemModel) - } - - // fetch additional data - if (addedPostMap.size > 0) { - const uriChunks = chunk(Array.from(addedPostMap.keys()), 25) - const postsChunks = await Promise.all( - uriChunks.map(uris => - this.rootStore.agent.app.bsky.feed - .getPosts({uris}) - .then(res => res.data.posts), - ), - ) - for (const post of postsChunks.flat()) { - this.rootStore.posts.set(post.uri, post) - const models = addedPostMap.get(post.uri) - if (models?.length) { - for (const model of models) { - model.setAdditionalData(post) - } - } - } - } - - return itemModels - } - - async _processNotifications( - items: ListNotifications.Notification[], - ): Promise<NotificationsFeedItemModel[]> { - const itemModels = await this._fetchItemModels(groupNotifications(items)) - return this._filterNotifications(itemModels) - } - - _setQueued(queued: undefined | NotificationsFeedItemModel[]) { - this.queuedNotifications = queued - } - - _countUnread() { - let unread = 0 - for (const notif of this.notifications) { - unread += notif.numUnreadInGroup - } - if (this.queuedNotifications) { - unread += this.queuedNotifications.filter(notif => !notif.isRead).length - } - this.unreadCount = unread - this.rootStore.emitUnreadNotifications(unread) - } -} - -function groupNotifications( - items: ListNotifications.Notification[], -): GroupedNotification[] { - const items2: GroupedNotification[] = [] - for (const item of items) { - const ts = +new Date(item.indexedAt) - let grouped = false - if (GROUPABLE_REASONS.includes(item.reason)) { - for (const item2 of items2) { - const ts2 = +new Date(item2.indexedAt) - if ( - Math.abs(ts2 - ts) < MS_2DAY && - item.reason === item2.reason && - item.reasonSubject === item2.reasonSubject && - item.author.did !== item2.author.did - ) { - item2.additional = item2.additional || [] - item2.additional.push(item) - grouped = true - break - } - } - } - if (!grouped) { - items2.push(item) - } - } - return items2 -} - -type N = ListNotifications.Notification | NotificationsFeedItemModel -function isEq(a: N, b: N) { - // this function has a key subtlety- the indexedAt comparison - // the reason for this is reposts: they set the URI of the original post, not of the repost record - // the indexedAt time will be for the repost however, so we use that to help us - return a.uri === b.uri && a.indexedAt === b.indexedAt -} diff --git a/src/state/models/feeds/post.ts b/src/state/models/feeds/post.ts deleted file mode 100644 index d064edc21..000000000 --- a/src/state/models/feeds/post.ts +++ /dev/null @@ -1,199 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import { - AppBskyFeedPost as FeedPost, - AppBskyFeedDefs, - RichText, - moderatePost, - PostModeration, -} from '@atproto/api' -import {RootStoreModel} from '../root-store' -import {updateDataOptimistically} from 'lib/async/revertible' -import {track} from 'lib/analytics/analytics' -import {hackAddDeletedEmbed} from 'lib/api/hack-add-deleted-embed' -import {logger} from '#/logger' - -type FeedViewPost = AppBskyFeedDefs.FeedViewPost -type ReasonRepost = AppBskyFeedDefs.ReasonRepost -type PostView = AppBskyFeedDefs.PostView - -export class PostsFeedItemModel { - // ui state - _reactKey: string = '' - - // data - post: PostView - postRecord?: FeedPost.Record - reply?: FeedViewPost['reply'] - reason?: FeedViewPost['reason'] - richText?: RichText - - constructor( - public rootStore: RootStoreModel, - _reactKey: string, - v: FeedViewPost, - ) { - this._reactKey = _reactKey - this.post = v.post - if (FeedPost.isRecord(this.post.record)) { - const valid = FeedPost.validateRecord(this.post.record) - if (valid.success) { - hackAddDeletedEmbed(this.post) - this.postRecord = this.post.record - this.richText = new RichText(this.postRecord, {cleanNewlines: true}) - } else { - this.postRecord = undefined - this.richText = undefined - logger.warn('Received an invalid app.bsky.feed.post record', { - error: valid.error, - }) - } - } else { - this.postRecord = undefined - this.richText = undefined - logger.warn( - 'app.bsky.feed.getTimeline or app.bsky.feed.getAuthorFeed served an unexpected record type', - {record: this.post.record}, - ) - } - this.reply = v.reply - this.reason = v.reason - makeAutoObservable(this, {rootStore: false}) - } - - get uri() { - return this.post.uri - } - - get parentUri() { - return this.postRecord?.reply?.parent.uri - } - - get rootUri(): string { - if (typeof this.postRecord?.reply?.root.uri === 'string') { - return this.postRecord?.reply?.root.uri - } - return this.post.uri - } - - get isThreadMuted() { - return this.rootStore.mutedThreads.uris.has(this.rootUri) - } - - get moderation(): PostModeration { - return moderatePost(this.post, this.rootStore.preferences.moderationOpts) - } - - copy(v: FeedViewPost) { - this.post = v.post - this.reply = v.reply - this.reason = v.reason - } - - copyMetrics(v: FeedViewPost) { - this.post.replyCount = v.post.replyCount - this.post.repostCount = v.post.repostCount - this.post.likeCount = v.post.likeCount - this.post.viewer = v.post.viewer - } - - get reasonRepost(): ReasonRepost | undefined { - if (this.reason?.$type === 'app.bsky.feed.defs#reasonRepost') { - return this.reason as ReasonRepost - } - } - - async toggleLike() { - this.post.viewer = this.post.viewer || {} - try { - if (this.post.viewer.like) { - // unlike - const url = this.post.viewer.like - await updateDataOptimistically( - this.post, - () => { - this.post.likeCount = (this.post.likeCount || 0) - 1 - this.post.viewer!.like = undefined - }, - () => this.rootStore.agent.deleteLike(url), - ) - track('Post:Unlike') - } else { - // like - await updateDataOptimistically( - this.post, - () => { - this.post.likeCount = (this.post.likeCount || 0) + 1 - this.post.viewer!.like = 'pending' - }, - () => this.rootStore.agent.like(this.post.uri, this.post.cid), - res => { - this.post.viewer!.like = res.uri - }, - ) - track('Post:Like') - } - } catch (error) { - logger.error('Failed to toggle like', {error}) - } - } - - async toggleRepost() { - this.post.viewer = this.post.viewer || {} - try { - if (this.post.viewer?.repost) { - // unrepost - const url = this.post.viewer.repost - await updateDataOptimistically( - this.post, - () => { - this.post.repostCount = (this.post.repostCount || 0) - 1 - this.post.viewer!.repost = undefined - }, - () => this.rootStore.agent.deleteRepost(url), - ) - track('Post:Unrepost') - } else { - // repost - await updateDataOptimistically( - this.post, - () => { - this.post.repostCount = (this.post.repostCount || 0) + 1 - this.post.viewer!.repost = 'pending' - }, - () => this.rootStore.agent.repost(this.post.uri, this.post.cid), - res => { - this.post.viewer!.repost = res.uri - }, - ) - track('Post:Repost') - } - } catch (error) { - logger.error('Failed to toggle repost', {error}) - } - } - - async toggleThreadMute() { - try { - if (this.isThreadMuted) { - this.rootStore.mutedThreads.uris.delete(this.rootUri) - track('Post:ThreadUnmute') - } else { - this.rootStore.mutedThreads.uris.add(this.rootUri) - track('Post:ThreadMute') - } - } catch (error) { - logger.error('Failed to toggle thread mute', {error}) - } - } - - async delete() { - try { - await this.rootStore.agent.deletePost(this.post.uri) - this.rootStore.emitPostDeleted(this.post.uri) - } catch (error) { - logger.error('Failed to delete post', {error}) - } finally { - track('Post:Delete') - } - } -} diff --git a/src/state/models/feeds/posts-slice.ts b/src/state/models/feeds/posts-slice.ts deleted file mode 100644 index 2501cef6f..000000000 --- a/src/state/models/feeds/posts-slice.ts +++ /dev/null @@ -1,91 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import {RootStoreModel} from '../root-store' -import {FeedViewPostsSlice} from 'lib/api/feed-manip' -import {PostsFeedItemModel} from './post' -import {FeedSourceInfo} from 'lib/api/feed/types' - -export class PostsFeedSliceModel { - // ui state - _reactKey: string = '' - - // data - items: PostsFeedItemModel[] = [] - source: FeedSourceInfo | undefined - - constructor(public rootStore: RootStoreModel, slice: FeedViewPostsSlice) { - this._reactKey = slice._reactKey - this.source = slice.source - for (let i = 0; i < slice.items.length; i++) { - this.items.push( - new PostsFeedItemModel( - rootStore, - `${this._reactKey} - ${i}`, - slice.items[i], - ), - ) - } - makeAutoObservable(this, {rootStore: false}) - } - - get uri() { - if (this.isReply) { - return this.items[1].post.uri - } - return this.items[0].post.uri - } - - get isThread() { - return ( - this.items.length > 1 && - this.items.every( - item => item.post.author.did === this.items[0].post.author.did, - ) - ) - } - - get isReply() { - return this.items.length > 1 && !this.isThread - } - - get rootItem() { - if (this.isReply) { - return this.items[1] - } - return this.items[0] - } - - get moderation() { - // prefer the most stringent item - const topItem = this.items.find(item => item.moderation.content.filter) - if (topItem) { - return topItem.moderation - } - // otherwise just use the first one - return this.items[0].moderation - } - - shouldFilter(ignoreFilterForDid: string | undefined): boolean { - const mods = this.items - .filter(item => item.post.author.did !== ignoreFilterForDid) - .map(item => item.moderation) - return !!mods.find(mod => mod.content.filter) - } - - containsUri(uri: string) { - return !!this.items.find(item => item.post.uri === uri) - } - - isThreadParentAt(i: number) { - if (this.items.length === 1) { - return false - } - return i < this.items.length - 1 - } - - isThreadChildAt(i: number) { - if (this.items.length === 1) { - return false - } - return i > 0 - } -} diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts deleted file mode 100644 index 0a06c581c..000000000 --- a/src/state/models/feeds/posts.ts +++ /dev/null @@ -1,429 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import { - AppBskyFeedGetTimeline as GetTimeline, - AppBskyFeedGetAuthorFeed as GetAuthorFeed, - AppBskyFeedGetFeed as GetCustomFeed, - AppBskyFeedGetActorLikes as GetActorLikes, - AppBskyFeedGetListFeed as GetListFeed, -} from '@atproto/api' -import AwaitLock from 'await-lock' -import {bundleAsync} from 'lib/async/bundle' -import {RootStoreModel} from '../root-store' -import {cleanError} from 'lib/strings/errors' -import {FeedTuner} from 'lib/api/feed-manip' -import {PostsFeedSliceModel} from './posts-slice' -import {track} from 'lib/analytics/analytics' -import {FeedViewPostsSlice} from 'lib/api/feed-manip' - -import {FeedAPI, FeedAPIResponse} from 'lib/api/feed/types' -import {FollowingFeedAPI} from 'lib/api/feed/following' -import {AuthorFeedAPI} from 'lib/api/feed/author' -import {LikesFeedAPI} from 'lib/api/feed/likes' -import {CustomFeedAPI} from 'lib/api/feed/custom' -import {ListFeedAPI} from 'lib/api/feed/list' -import {MergeFeedAPI} from 'lib/api/feed/merge' -import {logger} from '#/logger' - -const PAGE_SIZE = 30 - -type FeedType = 'home' | 'following' | 'author' | 'custom' | 'likes' | 'list' - -export enum KnownError { - FeedgenDoesNotExist, - FeedgenMisconfigured, - FeedgenBadResponse, - FeedgenOffline, - FeedgenUnknown, - Unknown, -} - -type Options = { - /** - * Formats the feed in a flat array with no threading of replies, just - * top-level posts. - */ - isSimpleFeed?: boolean -} - -type QueryParams = - | GetTimeline.QueryParams - | GetAuthorFeed.QueryParams - | GetActorLikes.QueryParams - | GetCustomFeed.QueryParams - | GetListFeed.QueryParams - -export class PostsFeedModel { - // state - isLoading = false - isRefreshing = false - hasNewLatest = false - hasLoaded = false - isBlocking = false - isBlockedBy = false - error = '' - knownError: KnownError | undefined - loadMoreError = '' - params: QueryParams - hasMore = true - pollCursor: string | undefined - api: FeedAPI - tuner = new FeedTuner() - pageSize = PAGE_SIZE - options: Options = {} - - // used to linearize async modifications to state - lock = new AwaitLock() - - // used to track if a feed is coming up empty - emptyFetches = 0 - - // data - slices: PostsFeedSliceModel[] = [] - - constructor( - public rootStore: RootStoreModel, - public feedType: FeedType, - params: QueryParams, - options?: Options, - ) { - makeAutoObservable( - this, - { - rootStore: false, - params: false, - }, - {autoBind: true}, - ) - this.params = params - this.options = options || {} - if (feedType === 'home') { - this.api = new MergeFeedAPI(rootStore) - } else if (feedType === 'following') { - this.api = new FollowingFeedAPI(rootStore) - } else if (feedType === 'author') { - this.api = new AuthorFeedAPI( - rootStore, - params as GetAuthorFeed.QueryParams, - ) - } else if (feedType === 'likes') { - this.api = new LikesFeedAPI( - rootStore, - params as GetActorLikes.QueryParams, - ) - } else if (feedType === 'custom') { - this.api = new CustomFeedAPI( - rootStore, - params as GetCustomFeed.QueryParams, - ) - } else if (feedType === 'list') { - this.api = new ListFeedAPI(rootStore, params as GetListFeed.QueryParams) - } else { - this.api = new FollowingFeedAPI(rootStore) - } - } - - get reactKey() { - if (this.feedType === 'author') { - return (this.params as GetAuthorFeed.QueryParams).actor - } - if (this.feedType === 'custom') { - return (this.params as GetCustomFeed.QueryParams).feed - } - if (this.feedType === 'list') { - return (this.params as GetListFeed.QueryParams).list - } - return this.feedType - } - - get hasContent() { - return this.slices.length !== 0 - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - get isLoadingMore() { - return this.isLoading && !this.isRefreshing && this.hasContent - } - - setHasNewLatest(v: boolean) { - this.hasNewLatest = v - } - - // public api - // = - - /** - * Nuke all data - */ - clear() { - logger.debug('FeedModel:clear') - this.isLoading = false - this.isRefreshing = false - this.hasNewLatest = false - this.hasLoaded = false - this.error = '' - this.hasMore = true - this.pollCursor = undefined - this.slices = [] - this.tuner.reset() - } - - /** - * Load for first render - */ - setup = bundleAsync(async (isRefreshing: boolean = false) => { - logger.debug('FeedModel:setup', {isRefreshing}) - if (isRefreshing) { - this.isRefreshing = true // set optimistically for UI - } - await this.lock.acquireAsync() - try { - this.setHasNewLatest(false) - this.api.reset() - this.tuner.reset() - this._xLoading(isRefreshing) - try { - const res = await this.api.fetchNext({limit: this.pageSize}) - await this._replaceAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - } finally { - this.lock.release() - } - }) - - /** - * Register any event listeners. Returns a cleanup function. - */ - registerListeners() { - const sub = this.rootStore.onPostDeleted(this.onPostDeleted.bind(this)) - return () => sub.remove() - } - - /** - * Reset and load - */ - async refresh() { - await this.setup(true) - } - - /** - * Load more posts to the end of the feed - */ - loadMore = bundleAsync(async () => { - await this.lock.acquireAsync() - try { - if (!this.hasMore || this.hasError) { - return - } - this._xLoading() - try { - const res = await this.api.fetchNext({ - limit: this.pageSize, - }) - await this._appendAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle(undefined, e) - runInAction(() => { - this.hasMore = false - }) - } - } finally { - this.lock.release() - if (this.feedType === 'custom') { - track('CustomFeed:LoadMore') - } - } - }) - - /** - * Attempt to load more again after a failure - */ - async retryLoadMore() { - this.loadMoreError = '' - this.hasMore = true - return this.loadMore() - } - - /** - * Check if new posts are available - */ - async checkForLatest() { - if (!this.hasLoaded || this.hasNewLatest || this.isLoading) { - return - } - const post = await this.api.peekLatest() - if (post) { - const slices = this.tuner.tune( - [post], - this.rootStore.preferences.getFeedTuners(this.feedType), - { - dryRun: true, - maintainOrder: true, - }, - ) - if (slices[0]) { - const sliceModel = new PostsFeedSliceModel(this.rootStore, slices[0]) - if (sliceModel.moderation.content.filter) { - return - } - this.setHasNewLatest(sliceModel.uri !== this.pollCursor) - } - } - } - - /** - * Updates the UI after the user has created a post - */ - onPostCreated() { - if (!this.slices.length) { - return this.refresh() - } else { - this.setHasNewLatest(true) - } - } - - /** - * Removes posts from the feed upon deletion. - */ - onPostDeleted(uri: string) { - let i - do { - i = this.slices.findIndex(slice => slice.containsUri(uri)) - if (i !== -1) { - this.slices.splice(i, 1) - } - } while (i !== -1) - } - - // state transitions - // = - - _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - this.knownError = undefined - } - - _xIdle(error?: any, loadMoreError?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.isBlocking = error instanceof GetAuthorFeed.BlockedActorError - this.isBlockedBy = error instanceof GetAuthorFeed.BlockedByActorError - this.error = cleanError(error) - this.knownError = detectKnownError(this.feedType, error) - this.loadMoreError = cleanError(loadMoreError) - if (error) { - logger.error('Posts feed request failed', {error}) - } - if (loadMoreError) { - logger.error('Posts feed load-more request failed', { - error: loadMoreError, - }) - } - } - - // helper functions - // = - - async _replaceAll(res: FeedAPIResponse) { - this.pollCursor = res.feed[0]?.post.uri - return this._appendAll(res, true) - } - - async _appendAll(res: FeedAPIResponse, replace = false) { - this.hasMore = !!res.cursor && res.feed.length > 0 - if (replace) { - this.emptyFetches = 0 - } - - this.rootStore.me.follows.hydrateMany( - res.feed.map(item => item.post.author), - ) - for (const item of res.feed) { - this.rootStore.posts.fromFeedItem(item) - } - - const slices = this.options.isSimpleFeed - ? res.feed.map(item => new FeedViewPostsSlice([item])) - : this.tuner.tune( - res.feed, - this.rootStore.preferences.getFeedTuners(this.feedType), - ) - - const toAppend: PostsFeedSliceModel[] = [] - for (const slice of slices) { - const sliceModel = new PostsFeedSliceModel(this.rootStore, slice) - const dupTest = (item: PostsFeedSliceModel) => - item._reactKey === sliceModel._reactKey - // sanity check - // if a duplicate _reactKey passes through, the UI breaks hard - if (!replace) { - if (this.slices.find(dupTest) || toAppend.find(dupTest)) { - continue - } - } - toAppend.push(sliceModel) - } - runInAction(() => { - if (replace) { - this.slices = toAppend - } else { - this.slices = this.slices.concat(toAppend) - } - if (toAppend.length === 0) { - this.emptyFetches++ - if (this.emptyFetches >= 10) { - this.hasMore = false - } - } - }) - } -} - -function detectKnownError( - feedType: FeedType, - error: any, -): KnownError | undefined { - if (!error) { - return undefined - } - if (typeof error !== 'string') { - error = error.toString() - } - if (feedType !== 'custom') { - return KnownError.Unknown - } - if (error.includes('could not find feed')) { - return KnownError.FeedgenDoesNotExist - } - if (error.includes('feed unavailable')) { - return KnownError.FeedgenOffline - } - if (error.includes('invalid did document')) { - return KnownError.FeedgenMisconfigured - } - if (error.includes('could not resolve did document')) { - return KnownError.FeedgenMisconfigured - } - if ( - error.includes('invalid feed generator service details in did document') - ) { - return KnownError.FeedgenMisconfigured - } - if (error.includes('feed provided an invalid response')) { - return KnownError.FeedgenBadResponse - } - return KnownError.FeedgenUnknown -} diff --git a/src/state/models/invited-users.ts b/src/state/models/invited-users.ts deleted file mode 100644 index 9ba65e19e..000000000 --- a/src/state/models/invited-users.ts +++ /dev/null @@ -1,88 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import {ComAtprotoServerDefs, AppBskyActorDefs} from '@atproto/api' -import {RootStoreModel} from './root-store' -import {isObj, hasProp, isStrArray} from 'lib/type-guards' -import {logger} from '#/logger' - -export class InvitedUsers { - copiedInvites: string[] = [] - seenDids: string[] = [] - profiles: AppBskyActorDefs.ProfileViewDetailed[] = [] - - get numNotifs() { - return this.profiles.length - } - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - {rootStore: false, serialize: false, hydrate: false}, - {autoBind: true}, - ) - } - - serialize() { - return {seenDids: this.seenDids, copiedInvites: this.copiedInvites} - } - - hydrate(v: unknown) { - if (isObj(v) && hasProp(v, 'seenDids') && isStrArray(v.seenDids)) { - this.seenDids = v.seenDids - } - if ( - isObj(v) && - hasProp(v, 'copiedInvites') && - isStrArray(v.copiedInvites) - ) { - this.copiedInvites = v.copiedInvites - } - } - - async fetch(invites: ComAtprotoServerDefs.InviteCode[]) { - // pull the dids of invited users not marked seen - const dids = [] - for (const invite of invites) { - for (const use of invite.uses) { - if (!this.seenDids.includes(use.usedBy)) { - dids.push(use.usedBy) - } - } - } - - // fetch their profiles - this.profiles = [] - if (dids.length) { - try { - const res = await this.rootStore.agent.app.bsky.actor.getProfiles({ - actors: dids, - }) - runInAction(() => { - // save the ones following -- these are the ones we want to notify the user about - this.profiles = res.data.profiles.filter( - profile => !profile.viewer?.following, - ) - }) - this.rootStore.me.follows.hydrateMany(this.profiles) - } catch (e) { - logger.error('Failed to fetch profiles for invited users', { - error: e, - }) - } - } - } - - isInviteCopied(invite: string) { - return this.copiedInvites.includes(invite) - } - - setInviteCopied(invite: string) { - if (!this.isInviteCopied(invite)) { - this.copiedInvites.push(invite) - } - } - - markSeen(did: string) { - this.seenDids.push(did) - this.profiles = this.profiles.filter(profile => profile.did !== did) - } -} diff --git a/src/state/models/lists/actor-feeds.ts b/src/state/models/lists/actor-feeds.ts deleted file mode 100644 index 29c01e536..000000000 --- a/src/state/models/lists/actor-feeds.ts +++ /dev/null @@ -1,123 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import {AppBskyFeedGetActorFeeds as GetActorFeeds} from '@atproto/api' -import {RootStoreModel} from '../root-store' -import {bundleAsync} from 'lib/async/bundle' -import {cleanError} from 'lib/strings/errors' -import {FeedSourceModel} from '../content/feed-source' -import {logger} from '#/logger' - -const PAGE_SIZE = 30 - -export class ActorFeedsModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - hasMore = true - loadMoreCursor?: string - - // data - feeds: FeedSourceModel[] = [] - - constructor( - public rootStore: RootStoreModel, - public params: GetActorFeeds.QueryParams, - ) { - makeAutoObservable( - this, - { - rootStore: false, - }, - {autoBind: true}, - ) - } - - get hasContent() { - return this.feeds.length > 0 - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - // public api - // = - - async refresh() { - return this.loadMore(true) - } - - clear() { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = false - this.error = '' - this.hasMore = true - this.loadMoreCursor = undefined - this.feeds = [] - } - - loadMore = bundleAsync(async (replace: boolean = false) => { - if (!replace && !this.hasMore) { - return - } - this._xLoading(replace) - try { - const res = await this.rootStore.agent.app.bsky.feed.getActorFeeds({ - actor: this.params.actor, - limit: PAGE_SIZE, - cursor: replace ? undefined : this.loadMoreCursor, - }) - if (replace) { - this._replaceAll(res) - } else { - this._appendAll(res) - } - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - }) - - // state transitions - // = - - _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - _xIdle(err?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = cleanError(err) - if (err) { - logger.error('Failed to fetch user followers', {error: err}) - } - } - - // helper functions - // = - - _replaceAll(res: GetActorFeeds.Response) { - this.feeds = [] - this._appendAll(res) - } - - _appendAll(res: GetActorFeeds.Response) { - this.loadMoreCursor = res.data.cursor - this.hasMore = !!this.loadMoreCursor - for (const f of res.data.feeds) { - const model = new FeedSourceModel(this.rootStore, f.uri) - model.hydrateFeedGenerator(f) - this.feeds.push(model) - } - } -} diff --git a/src/state/models/lists/blocked-accounts.ts b/src/state/models/lists/blocked-accounts.ts deleted file mode 100644 index 5c3dbe7ce..000000000 --- a/src/state/models/lists/blocked-accounts.ts +++ /dev/null @@ -1,107 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import { - AppBskyGraphGetBlocks as GetBlocks, - AppBskyActorDefs as ActorDefs, -} from '@atproto/api' -import {RootStoreModel} from '../root-store' -import {cleanError} from 'lib/strings/errors' -import {bundleAsync} from 'lib/async/bundle' -import {logger} from '#/logger' - -const PAGE_SIZE = 30 - -export class BlockedAccountsModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - hasMore = true - loadMoreCursor?: string - - // data - blocks: ActorDefs.ProfileView[] = [] - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - { - rootStore: false, - }, - {autoBind: true}, - ) - } - - get hasContent() { - return this.blocks.length > 0 - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - // public api - // = - - async refresh() { - return this.loadMore(true) - } - - loadMore = bundleAsync(async (replace: boolean = false) => { - if (!replace && !this.hasMore) { - return - } - this._xLoading(replace) - try { - const res = await this.rootStore.agent.app.bsky.graph.getBlocks({ - limit: PAGE_SIZE, - cursor: replace ? undefined : this.loadMoreCursor, - }) - if (replace) { - this._replaceAll(res) - } else { - this._appendAll(res) - } - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - }) - - // state transitions - // = - - _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - _xIdle(err?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = cleanError(err) - if (err) { - logger.error('Failed to fetch user followers', {error: err}) - } - } - - // helper functions - // = - - _replaceAll(res: GetBlocks.Response) { - this.blocks = [] - this._appendAll(res) - } - - _appendAll(res: GetBlocks.Response) { - this.loadMoreCursor = res.data.cursor - this.hasMore = !!this.loadMoreCursor - this.blocks = this.blocks.concat(res.data.blocks) - } -} diff --git a/src/state/models/lists/likes.ts b/src/state/models/lists/likes.ts deleted file mode 100644 index df20f09db..000000000 --- a/src/state/models/lists/likes.ts +++ /dev/null @@ -1,135 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import {AtUri} from '@atproto/api' -import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api' -import {RootStoreModel} from '../root-store' -import {cleanError} from 'lib/strings/errors' -import {bundleAsync} from 'lib/async/bundle' -import * as apilib from 'lib/api/index' -import {logger} from '#/logger' - -const PAGE_SIZE = 30 - -export type LikeItem = GetLikes.Like - -export class LikesModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - resolvedUri = '' - params: GetLikes.QueryParams - hasMore = true - loadMoreCursor?: string - - // data - uri: string = '' - likes: LikeItem[] = [] - - constructor(public rootStore: RootStoreModel, params: GetLikes.QueryParams) { - makeAutoObservable( - this, - { - rootStore: false, - params: false, - }, - {autoBind: true}, - ) - this.params = params - } - - get hasContent() { - return this.uri !== '' - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - // public api - // = - - async refresh() { - return this.loadMore(true) - } - - loadMore = bundleAsync(async (replace: boolean = false) => { - if (!replace && !this.hasMore) { - return - } - this._xLoading(replace) - try { - if (!this.resolvedUri) { - await this._resolveUri() - } - const params = Object.assign({}, this.params, { - uri: this.resolvedUri, - limit: PAGE_SIZE, - cursor: replace ? undefined : this.loadMoreCursor, - }) - const res = await this.rootStore.agent.getLikes(params) - if (replace) { - this._replaceAll(res) - } else { - this._appendAll(res) - } - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - }) - - // state transitions - // = - - _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - _xIdle(err?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = cleanError(err) - if (err) { - logger.error('Failed to fetch likes', {error: err}) - } - } - - // helper functions - // = - - async _resolveUri() { - const urip = new AtUri(this.params.uri) - if (!urip.host.startsWith('did:')) { - try { - urip.host = await apilib.resolveName(this.rootStore, urip.host) - } catch (e: any) { - this.error = e.toString() - } - } - runInAction(() => { - this.resolvedUri = urip.toString() - }) - } - - _replaceAll(res: GetLikes.Response) { - this.likes = [] - this._appendAll(res) - } - - _appendAll(res: GetLikes.Response) { - this.loadMoreCursor = res.data.cursor - this.hasMore = !!this.loadMoreCursor - this.rootStore.me.follows.hydrateMany( - res.data.likes.map(like => like.actor), - ) - this.likes = this.likes.concat(res.data.likes) - } -} diff --git a/src/state/models/lists/lists-list.ts b/src/state/models/lists/lists-list.ts deleted file mode 100644 index eb6291637..000000000 --- a/src/state/models/lists/lists-list.ts +++ /dev/null @@ -1,244 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' -import {RootStoreModel} from '../root-store' -import {cleanError} from 'lib/strings/errors' -import {bundleAsync} from 'lib/async/bundle' -import {accumulate} from 'lib/async/accumulate' -import {logger} from '#/logger' - -const PAGE_SIZE = 30 - -export class ListsListModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - loadMoreError = '' - hasMore = true - loadMoreCursor?: string - - // data - lists: GraphDefs.ListView[] = [] - - constructor( - public rootStore: RootStoreModel, - public source: 'mine' | 'my-curatelists' | 'my-modlists' | string, - ) { - makeAutoObservable( - this, - { - rootStore: false, - }, - {autoBind: true}, - ) - } - - get hasContent() { - return this.lists.length > 0 - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - get curatelists() { - return this.lists.filter( - list => list.purpose === 'app.bsky.graph.defs#curatelist', - ) - } - - get isCuratelistsEmpty() { - return this.hasLoaded && this.curatelists.length === 0 - } - - get modlists() { - return this.lists.filter( - list => list.purpose === 'app.bsky.graph.defs#modlist', - ) - } - - get isModlistsEmpty() { - return this.hasLoaded && this.modlists.length === 0 - } - - /** - * Removes posts from the feed upon deletion. - */ - onListDeleted(uri: string) { - this.lists = this.lists.filter(l => l.uri !== uri) - } - - // public api - // = - - /** - * Register any event listeners. Returns a cleanup function. - */ - registerListeners() { - const sub = this.rootStore.onListDeleted(this.onListDeleted.bind(this)) - return () => sub.remove() - } - - async refresh() { - return this.loadMore(true) - } - - loadMore = bundleAsync(async (replace: boolean = false) => { - if (!replace && !this.hasMore) { - return - } - this._xLoading(replace) - try { - let cursor: string | undefined - let lists: GraphDefs.ListView[] = [] - if ( - this.source === 'mine' || - this.source === 'my-curatelists' || - this.source === 'my-modlists' - ) { - const promises = [ - accumulate(cursor => - this.rootStore.agent.app.bsky.graph - .getLists({ - actor: this.rootStore.me.did, - cursor, - limit: 50, - }) - .then(res => ({cursor: res.data.cursor, items: res.data.lists})), - ), - ] - if (this.source === 'my-modlists') { - promises.push( - accumulate(cursor => - this.rootStore.agent.app.bsky.graph - .getListMutes({ - cursor, - limit: 50, - }) - .then(res => ({ - cursor: res.data.cursor, - items: res.data.lists, - })), - ), - ) - promises.push( - accumulate(cursor => - this.rootStore.agent.app.bsky.graph - .getListBlocks({ - cursor, - limit: 50, - }) - .then(res => ({ - cursor: res.data.cursor, - items: res.data.lists, - })), - ), - ) - } - const resultset = await Promise.all(promises) - for (const res of resultset) { - for (let list of res) { - if ( - this.source === 'my-curatelists' && - list.purpose !== 'app.bsky.graph.defs#curatelist' - ) { - continue - } - if ( - this.source === 'my-modlists' && - list.purpose !== 'app.bsky.graph.defs#modlist' - ) { - continue - } - if (!lists.find(l => l.uri === list.uri)) { - lists.push(list) - } - } - } - } else { - const res = await this.rootStore.agent.app.bsky.graph.getLists({ - actor: this.source, - limit: PAGE_SIZE, - cursor: replace ? undefined : this.loadMoreCursor, - }) - lists = res.data.lists - cursor = res.data.cursor - } - if (replace) { - this._replaceAll({lists, cursor}) - } else { - this._appendAll({lists, cursor}) - } - this._xIdle() - } catch (e: any) { - this._xIdle(replace ? e : undefined, !replace ? e : undefined) - } - }) - - /** - * Attempt to load more again after a failure - */ - async retryLoadMore() { - this.loadMoreError = '' - this.hasMore = true - return this.loadMore() - } - - // state transitions - // = - - _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - _xIdle(err?: any, loadMoreErr?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = cleanError(err) - this.loadMoreError = cleanError(loadMoreErr) - if (err) { - logger.error('Failed to fetch user lists', {error: err}) - } - if (loadMoreErr) { - logger.error('Failed to fetch user lists', { - error: loadMoreErr, - }) - } - } - - // helper functions - // = - - _replaceAll({ - lists, - cursor, - }: { - lists: GraphDefs.ListView[] - cursor: string | undefined - }) { - this.lists = [] - this._appendAll({lists, cursor}) - } - - _appendAll({ - lists, - cursor, - }: { - lists: GraphDefs.ListView[] - cursor: string | undefined - }) { - this.loadMoreCursor = cursor - this.hasMore = !!this.loadMoreCursor - this.lists = this.lists.concat( - lists.map(list => ({...list, _reactKey: list.uri})), - ) - } -} diff --git a/src/state/models/lists/muted-accounts.ts b/src/state/models/lists/muted-accounts.ts deleted file mode 100644 index 19ade0d9c..000000000 --- a/src/state/models/lists/muted-accounts.ts +++ /dev/null @@ -1,107 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import { - AppBskyGraphGetMutes as GetMutes, - AppBskyActorDefs as ActorDefs, -} from '@atproto/api' -import {RootStoreModel} from '../root-store' -import {cleanError} from 'lib/strings/errors' -import {bundleAsync} from 'lib/async/bundle' -import {logger} from '#/logger' - -const PAGE_SIZE = 30 - -export class MutedAccountsModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - hasMore = true - loadMoreCursor?: string - - // data - mutes: ActorDefs.ProfileView[] = [] - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - { - rootStore: false, - }, - {autoBind: true}, - ) - } - - get hasContent() { - return this.mutes.length > 0 - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - // public api - // = - - async refresh() { - return this.loadMore(true) - } - - loadMore = bundleAsync(async (replace: boolean = false) => { - if (!replace && !this.hasMore) { - return - } - this._xLoading(replace) - try { - const res = await this.rootStore.agent.app.bsky.graph.getMutes({ - limit: PAGE_SIZE, - cursor: replace ? undefined : this.loadMoreCursor, - }) - if (replace) { - this._replaceAll(res) - } else { - this._appendAll(res) - } - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - }) - - // state transitions - // = - - _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - _xIdle(err?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = cleanError(err) - if (err) { - logger.error('Failed to fetch user followers', {error: err}) - } - } - - // helper functions - // = - - _replaceAll(res: GetMutes.Response) { - this.mutes = [] - this._appendAll(res) - } - - _appendAll(res: GetMutes.Response) { - this.loadMoreCursor = res.data.cursor - this.hasMore = !!this.loadMoreCursor - this.mutes = this.mutes.concat(res.data.mutes) - } -} diff --git a/src/state/models/lists/reposted-by.ts b/src/state/models/lists/reposted-by.ts deleted file mode 100644 index c5058558a..000000000 --- a/src/state/models/lists/reposted-by.ts +++ /dev/null @@ -1,136 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import {AtUri} from '@atproto/api' -import { - AppBskyFeedGetRepostedBy as GetRepostedBy, - AppBskyActorDefs, -} from '@atproto/api' -import {RootStoreModel} from '../root-store' -import {bundleAsync} from 'lib/async/bundle' -import {cleanError} from 'lib/strings/errors' -import * as apilib from 'lib/api/index' -import {logger} from '#/logger' - -const PAGE_SIZE = 30 - -export type RepostedByItem = AppBskyActorDefs.ProfileViewBasic - -export class RepostedByModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - resolvedUri = '' - params: GetRepostedBy.QueryParams - hasMore = true - loadMoreCursor?: string - - // data - uri: string = '' - repostedBy: RepostedByItem[] = [] - - constructor( - public rootStore: RootStoreModel, - params: GetRepostedBy.QueryParams, - ) { - makeAutoObservable( - this, - { - rootStore: false, - params: false, - }, - {autoBind: true}, - ) - this.params = params - } - - get hasContent() { - return this.uri !== '' - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - // public api - // = - - async refresh() { - return this.loadMore(true) - } - - loadMore = bundleAsync(async (replace: boolean = false) => { - this._xLoading(replace) - try { - if (!this.resolvedUri) { - await this._resolveUri() - } - const params = Object.assign({}, this.params, { - uri: this.resolvedUri, - limit: PAGE_SIZE, - cursor: replace ? undefined : this.loadMoreCursor, - }) - const res = await this.rootStore.agent.getRepostedBy(params) - if (replace) { - this._replaceAll(res) - } else { - this._appendAll(res) - } - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - }) - - // state transitions - // = - - _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - _xIdle(err?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = cleanError(err) - if (err) { - logger.error('Failed to fetch reposted by view', {error: err}) - } - } - - // helper functions - // = - - async _resolveUri() { - const urip = new AtUri(this.params.uri) - if (!urip.host.startsWith('did:')) { - try { - urip.host = await apilib.resolveName(this.rootStore, urip.host) - } catch (e: any) { - this.error = e.toString() - } - } - runInAction(() => { - this.resolvedUri = urip.toString() - }) - } - - _replaceAll(res: GetRepostedBy.Response) { - this.repostedBy = [] - this._appendAll(res) - } - - _appendAll(res: GetRepostedBy.Response) { - this.loadMoreCursor = res.data.cursor - this.hasMore = !!this.loadMoreCursor - this.repostedBy = this.repostedBy.concat(res.data.repostedBy) - this.rootStore.me.follows.hydrateMany(res.data.repostedBy) - } -} diff --git a/src/state/models/lists/user-followers.ts b/src/state/models/lists/user-followers.ts deleted file mode 100644 index 159308b9b..000000000 --- a/src/state/models/lists/user-followers.ts +++ /dev/null @@ -1,121 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import { - AppBskyGraphGetFollowers as GetFollowers, - AppBskyActorDefs as ActorDefs, -} from '@atproto/api' -import {RootStoreModel} from '../root-store' -import {cleanError} from 'lib/strings/errors' -import {bundleAsync} from 'lib/async/bundle' -import {logger} from '#/logger' - -const PAGE_SIZE = 30 - -export type FollowerItem = ActorDefs.ProfileViewBasic - -export class UserFollowersModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - params: GetFollowers.QueryParams - hasMore = true - loadMoreCursor?: string - - // data - subject: ActorDefs.ProfileViewBasic = { - did: '', - handle: '', - } - followers: FollowerItem[] = [] - - constructor( - public rootStore: RootStoreModel, - params: GetFollowers.QueryParams, - ) { - makeAutoObservable( - this, - { - rootStore: false, - params: false, - }, - {autoBind: true}, - ) - this.params = params - } - - get hasContent() { - return this.subject.did !== '' - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - // public api - // = - - async refresh() { - return this.loadMore(true) - } - - loadMore = bundleAsync(async (replace: boolean = false) => { - if (!replace && !this.hasMore) { - return - } - this._xLoading(replace) - try { - const params = Object.assign({}, this.params, { - limit: PAGE_SIZE, - cursor: replace ? undefined : this.loadMoreCursor, - }) - const res = await this.rootStore.agent.getFollowers(params) - if (replace) { - this._replaceAll(res) - } else { - this._appendAll(res) - } - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - }) - - // state transitions - // = - - _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - _xIdle(err?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = cleanError(err) - if (err) { - logger.error('Failed to fetch user followers', {error: err}) - } - } - - // helper functions - // = - - _replaceAll(res: GetFollowers.Response) { - this.followers = [] - this._appendAll(res) - } - - _appendAll(res: GetFollowers.Response) { - this.loadMoreCursor = res.data.cursor - this.hasMore = !!this.loadMoreCursor - this.followers = this.followers.concat(res.data.followers) - this.rootStore.me.follows.hydrateMany(res.data.followers) - } -} diff --git a/src/state/models/lists/user-follows.ts b/src/state/models/lists/user-follows.ts deleted file mode 100644 index 3abbbaf95..000000000 --- a/src/state/models/lists/user-follows.ts +++ /dev/null @@ -1,121 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import { - AppBskyGraphGetFollows as GetFollows, - AppBskyActorDefs as ActorDefs, -} from '@atproto/api' -import {RootStoreModel} from '../root-store' -import {cleanError} from 'lib/strings/errors' -import {bundleAsync} from 'lib/async/bundle' -import {logger} from '#/logger' - -const PAGE_SIZE = 30 - -export type FollowItem = ActorDefs.ProfileViewBasic - -export class UserFollowsModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - params: GetFollows.QueryParams - hasMore = true - loadMoreCursor?: string - - // data - subject: ActorDefs.ProfileViewBasic = { - did: '', - handle: '', - } - follows: FollowItem[] = [] - - constructor( - public rootStore: RootStoreModel, - params: GetFollows.QueryParams, - ) { - makeAutoObservable( - this, - { - rootStore: false, - params: false, - }, - {autoBind: true}, - ) - this.params = params - } - - get hasContent() { - return this.subject.did !== '' - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - // public api - // = - - async refresh() { - return this.loadMore(true) - } - - loadMore = bundleAsync(async (replace: boolean = false) => { - if (!replace && !this.hasMore) { - return - } - this._xLoading(replace) - try { - const params = Object.assign({}, this.params, { - limit: PAGE_SIZE, - cursor: replace ? undefined : this.loadMoreCursor, - }) - const res = await this.rootStore.agent.getFollows(params) - if (replace) { - this._replaceAll(res) - } else { - this._appendAll(res) - } - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - }) - - // state transitions - // = - - _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - _xIdle(err?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = cleanError(err) - if (err) { - logger.error('Failed to fetch user follows', err) - } - } - - // helper functions - // = - - _replaceAll(res: GetFollows.Response) { - this.follows = [] - this._appendAll(res) - } - - _appendAll(res: GetFollows.Response) { - this.loadMoreCursor = res.data.cursor - this.hasMore = !!this.loadMoreCursor - this.follows = this.follows.concat(res.data.follows) - this.rootStore.me.follows.hydrateMany(res.data.follows) - } -} diff --git a/src/state/models/me.ts b/src/state/models/me.ts deleted file mode 100644 index d12cb68c4..000000000 --- a/src/state/models/me.ts +++ /dev/null @@ -1,255 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import { - ComAtprotoServerDefs, - ComAtprotoServerListAppPasswords, -} from '@atproto/api' -import {RootStoreModel} from './root-store' -import {PostsFeedModel} from './feeds/posts' -import {NotificationsFeedModel} from './feeds/notifications' -import {MyFeedsUIModel} from './ui/my-feeds' -import {MyFollowsCache} from './cache/my-follows' -import {isObj, hasProp} from 'lib/type-guards' -import {logger} from '#/logger' - -const PROFILE_UPDATE_INTERVAL = 10 * 60 * 1e3 // 10min -const NOTIFS_UPDATE_INTERVAL = 30 * 1e3 // 30sec - -export class MeModel { - did: string = '' - handle: string = '' - displayName: string = '' - description: string = '' - avatar: string = '' - followsCount: number | undefined - followersCount: number | undefined - mainFeed: PostsFeedModel - notifications: NotificationsFeedModel - myFeeds: MyFeedsUIModel - follows: MyFollowsCache - invites: ComAtprotoServerDefs.InviteCode[] = [] - appPasswords: ComAtprotoServerListAppPasswords.AppPassword[] = [] - lastProfileStateUpdate = Date.now() - lastNotifsUpdate = Date.now() - - get invitesAvailable() { - return this.invites.filter(isInviteAvailable).length - } - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - {rootStore: false, serialize: false, hydrate: false}, - {autoBind: true}, - ) - this.mainFeed = new PostsFeedModel(this.rootStore, 'home', { - algorithm: 'reverse-chronological', - }) - this.notifications = new NotificationsFeedModel(this.rootStore) - this.myFeeds = new MyFeedsUIModel(this.rootStore) - this.follows = new MyFollowsCache(this.rootStore) - } - - clear() { - this.mainFeed.clear() - this.notifications.clear() - this.myFeeds.clear() - this.follows.clear() - this.rootStore.profiles.cache.clear() - this.rootStore.posts.cache.clear() - this.did = '' - this.handle = '' - this.displayName = '' - this.description = '' - this.avatar = '' - this.invites = [] - this.appPasswords = [] - } - - serialize(): unknown { - return { - did: this.did, - handle: this.handle, - displayName: this.displayName, - description: this.description, - avatar: this.avatar, - } - } - - hydrate(v: unknown) { - if (isObj(v)) { - let did, handle, displayName, description, avatar - if (hasProp(v, 'did') && typeof v.did === 'string') { - did = v.did - } - if (hasProp(v, 'handle') && typeof v.handle === 'string') { - handle = v.handle - } - if (hasProp(v, 'displayName') && typeof v.displayName === 'string') { - displayName = v.displayName - } - if (hasProp(v, 'description') && typeof v.description === 'string') { - description = v.description - } - if (hasProp(v, 'avatar') && typeof v.avatar === 'string') { - avatar = v.avatar - } - if (did && handle) { - this.did = did - this.handle = handle - this.displayName = displayName || '' - this.description = description || '' - this.avatar = avatar || '' - } - } - } - - async load() { - const sess = this.rootStore.session - logger.debug('MeModel:load', {hasSession: sess.hasSession}) - if (sess.hasSession) { - this.did = sess.currentSession?.did || '' - await this.fetchProfile() - this.mainFeed.clear() - /* dont await */ this.mainFeed.setup().catch(e => { - logger.error('Failed to setup main feed model', {error: e}) - }) - /* dont await */ this.notifications.setup().catch(e => { - logger.error('Failed to setup notifications model', { - error: e, - }) - }) - /* dont await */ this.notifications.setup().catch(e => { - logger.error('Failed to setup notifications model', { - error: e, - }) - }) - this.myFeeds.clear() - /* dont await */ this.myFeeds.saved.refresh() - this.rootStore.emitSessionLoaded() - await this.fetchInviteCodes() - await this.fetchAppPasswords() - } else { - this.clear() - } - } - - async updateIfNeeded() { - if (Date.now() - this.lastProfileStateUpdate > PROFILE_UPDATE_INTERVAL) { - logger.debug('Updating me profile information') - this.lastProfileStateUpdate = Date.now() - await this.fetchProfile() - await this.fetchInviteCodes() - await this.fetchAppPasswords() - } - if (Date.now() - this.lastNotifsUpdate > NOTIFS_UPDATE_INTERVAL) { - this.lastNotifsUpdate = Date.now() - await this.notifications.syncQueue() - } - } - - async fetchProfile() { - const profile = await this.rootStore.agent.getProfile({ - actor: this.did, - }) - runInAction(() => { - if (profile?.data) { - this.displayName = profile.data.displayName || '' - this.description = profile.data.description || '' - this.avatar = profile.data.avatar || '' - this.handle = profile.data.handle || '' - this.followsCount = profile.data.followsCount - this.followersCount = profile.data.followersCount - } else { - this.displayName = '' - this.description = '' - this.avatar = '' - this.followsCount = profile.data.followsCount - this.followersCount = undefined - } - }) - } - - async fetchInviteCodes() { - if (this.rootStore.session) { - try { - const res = - await this.rootStore.agent.com.atproto.server.getAccountInviteCodes( - {}, - ) - runInAction(() => { - this.invites = res.data.codes - this.invites.sort((a, b) => { - if (!isInviteAvailable(a)) { - return 1 - } - if (!isInviteAvailable(b)) { - return -1 - } - return 0 - }) - }) - } catch (e) { - logger.error('Failed to fetch user invite codes', { - error: e, - }) - } - await this.rootStore.invitedUsers.fetch(this.invites) - } - } - - async fetchAppPasswords() { - if (this.rootStore.session) { - try { - const res = - await this.rootStore.agent.com.atproto.server.listAppPasswords({}) - runInAction(() => { - this.appPasswords = res.data.passwords - }) - } catch (e) { - logger.error('Failed to fetch user app passwords', { - error: e, - }) - } - } - } - - async createAppPassword(name: string) { - if (this.rootStore.session) { - try { - if (this.appPasswords.find(p => p.name === name)) { - // TODO: this should be handled by the backend but it's not - throw new Error('App password with this name already exists') - } - const res = - await this.rootStore.agent.com.atproto.server.createAppPassword({ - name, - }) - runInAction(() => { - this.appPasswords.push(res.data) - }) - return res.data - } catch (e) { - logger.error('Failed to create app password', {error: e}) - } - } - } - - async deleteAppPassword(name: string) { - if (this.rootStore.session) { - try { - await this.rootStore.agent.com.atproto.server.revokeAppPassword({ - name: name, - }) - runInAction(() => { - this.appPasswords = this.appPasswords.filter(p => p.name !== name) - }) - } catch (e) { - logger.error('Failed to delete app password', {error: e}) - } - } - } -} - -function isInviteAvailable(invite: ComAtprotoServerDefs.InviteCode): boolean { - return invite.available - invite.uses.length > 0 && !invite.disabled -} diff --git a/src/state/models/media/gallery.ts b/src/state/models/media/gallery.ts index 1b22fadbd..04023bf82 100644 --- a/src/state/models/media/gallery.ts +++ b/src/state/models/media/gallery.ts @@ -1,18 +1,14 @@ import {makeAutoObservable, runInAction} from 'mobx' -import {RootStoreModel} from 'state/index' import {ImageModel} from './image' import {Image as RNImage} from 'react-native-image-crop-picker' import {openPicker} from 'lib/media/picker' import {getImageDim} from 'lib/media/manip' -import {isNative} from 'platform/detection' export class GalleryModel { images: ImageModel[] = [] - constructor(public rootStore: RootStoreModel) { - makeAutoObservable(this, { - rootStore: false, - }) + constructor() { + makeAutoObservable(this) } get isEmpty() { @@ -34,7 +30,7 @@ export class GalleryModel { // Temporarily enforce uniqueness but can eventually also use index if (!this.images.some(i => i.path === image_.path)) { - const image = new ImageModel(this.rootStore, image_) + const image = new ImageModel(image_) // Initial resize image.manipulate({}) @@ -42,18 +38,6 @@ export class GalleryModel { } } - async edit(image: ImageModel) { - if (isNative) { - this.crop(image) - } else { - this.rootStore.shell.openModal({ - name: 'edit-image', - image, - gallery: this, - }) - } - } - async paste(uri: string) { if (this.size >= 4) { return diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts index b3796060c..6a226484e 100644 --- a/src/state/models/media/image.ts +++ b/src/state/models/media/image.ts @@ -1,5 +1,4 @@ import {Image as RNImage} from 'react-native-image-crop-picker' -import {RootStoreModel} from 'state/index' import {makeAutoObservable, runInAction} from 'mobx' import {POST_IMG_MAX} from 'lib/constants' import * as ImageManipulator from 'expo-image-manipulator' @@ -42,10 +41,8 @@ export class ImageModel implements Omit<RNImage, 'size'> { } prevAttributes: ImageManipulationAttributes = {} - constructor(public rootStore: RootStoreModel, image: Omit<RNImage, 'size'>) { - makeAutoObservable(this, { - rootStore: false, - }) + constructor(image: Omit<RNImage, 'size'>) { + makeAutoObservable(this) this.path = image.path this.width = image.width @@ -178,7 +175,7 @@ export class ImageModel implements Omit<RNImage, 'size'> { height: this.height, }) - const cropped = await openCropper(this.rootStore, { + const cropped = await openCropper({ mediaType: 'photo', path: this.path, freeStyleCropEnabled: true, diff --git a/src/state/models/muted-threads.ts b/src/state/models/muted-threads.ts deleted file mode 100644 index e6f202745..000000000 --- a/src/state/models/muted-threads.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * This is a temporary client-side system for storing muted threads - * When the system lands on prod we should switch to that - */ - -import {makeAutoObservable} from 'mobx' -import {isObj, hasProp, isStrArray} from 'lib/type-guards' - -export class MutedThreads { - uris: Set<string> = new Set() - - constructor() { - makeAutoObservable( - this, - {serialize: false, hydrate: false}, - {autoBind: true}, - ) - } - - serialize() { - return {uris: Array.from(this.uris)} - } - - hydrate(v: unknown) { - if (isObj(v) && hasProp(v, 'uris') && isStrArray(v.uris)) { - this.uris = new Set(v.uris) - } - } -} diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts deleted file mode 100644 index 6ba78e711..000000000 --- a/src/state/models/root-store.ts +++ /dev/null @@ -1,257 +0,0 @@ -/** - * The root store is the base of all modeled state. - */ - -import {makeAutoObservable} from 'mobx' -import {BskyAgent} from '@atproto/api' -import {createContext, useContext} from 'react' -import {DeviceEventEmitter, EmitterSubscription} from 'react-native' -import {z} from 'zod' -import {isObj, hasProp} from 'lib/type-guards' -import {SessionModel} from './session' -import {ShellUiModel} from './ui/shell' -import {HandleResolutionsCache} from './cache/handle-resolutions' -import {ProfilesCache} from './cache/profiles-view' -import {PostsCache} from './cache/posts' -import {LinkMetasCache} from './cache/link-metas' -import {MeModel} from './me' -import {InvitedUsers} from './invited-users' -import {PreferencesModel} from './ui/preferences' -import {resetToTab} from '../../Navigation' -import {ImageSizesCache} from './cache/image-sizes' -import {MutedThreads} from './muted-threads' -import {reset as resetNavigation} from '../../Navigation' -import {logger} from '#/logger' - -// TEMPORARY (APP-700) -// remove after backend testing finishes -// -prf -import {applyDebugHeader} from 'lib/api/debug-appview-proxy-header' -import {OnboardingModel} from './discovery/onboarding' - -export const appInfo = z.object({ - build: z.string(), - name: z.string(), - namespace: z.string(), - version: z.string(), -}) -export type AppInfo = z.infer<typeof appInfo> - -export class RootStoreModel { - agent: BskyAgent - appInfo?: AppInfo - session = new SessionModel(this) - shell = new ShellUiModel(this) - preferences = new PreferencesModel(this) - me = new MeModel(this) - onboarding = new OnboardingModel(this) - invitedUsers = new InvitedUsers(this) - handleResolutions = new HandleResolutionsCache() - profiles = new ProfilesCache(this) - posts = new PostsCache(this) - linkMetas = new LinkMetasCache(this) - imageSizes = new ImageSizesCache() - mutedThreads = new MutedThreads() - - constructor(agent: BskyAgent) { - this.agent = agent - makeAutoObservable(this, { - agent: false, - serialize: false, - hydrate: false, - }) - } - - setAppInfo(info: AppInfo) { - this.appInfo = info - } - - serialize(): unknown { - return { - appInfo: this.appInfo, - session: this.session.serialize(), - me: this.me.serialize(), - onboarding: this.onboarding.serialize(), - preferences: this.preferences.serialize(), - invitedUsers: this.invitedUsers.serialize(), - mutedThreads: this.mutedThreads.serialize(), - } - } - - hydrate(v: unknown) { - if (isObj(v)) { - if (hasProp(v, 'appInfo')) { - const appInfoParsed = appInfo.safeParse(v.appInfo) - if (appInfoParsed.success) { - this.setAppInfo(appInfoParsed.data) - } - } - if (hasProp(v, 'me')) { - this.me.hydrate(v.me) - } - if (hasProp(v, 'onboarding')) { - this.onboarding.hydrate(v.onboarding) - } - if (hasProp(v, 'session')) { - this.session.hydrate(v.session) - } - if (hasProp(v, 'preferences')) { - this.preferences.hydrate(v.preferences) - } - if (hasProp(v, 'invitedUsers')) { - this.invitedUsers.hydrate(v.invitedUsers) - } - if (hasProp(v, 'mutedThreads')) { - this.mutedThreads.hydrate(v.mutedThreads) - } - } - } - - /** - * Called during init to resume any stored session. - */ - async attemptSessionResumption() { - logger.debug('RootStoreModel:attemptSessionResumption') - try { - await this.session.attemptSessionResumption() - logger.debug('Session initialized', { - hasSession: this.session.hasSession, - }) - this.updateSessionState() - } catch (e: any) { - logger.warn('Failed to initialize session', {error: e}) - } - } - - /** - * Called by the session model. Refreshes session-oriented state. - */ - async handleSessionChange( - agent: BskyAgent, - {hadSession}: {hadSession: boolean}, - ) { - logger.debug('RootStoreModel:handleSessionChange') - this.agent = agent - applyDebugHeader(this.agent) - this.me.clear() - await this.preferences.sync() - await this.me.load() - if (!hadSession) { - await resetNavigation() - } - this.emitSessionReady() - } - - /** - * Called by the session model. Handles session drops by informing the user. - */ - async handleSessionDrop() { - logger.debug('RootStoreModel:handleSessionDrop') - resetToTab('HomeTab') - this.me.clear() - this.emitSessionDropped() - } - - /** - * Clears all session-oriented state. - */ - clearAllSessionState() { - logger.debug('RootStoreModel:clearAllSessionState') - this.session.clear() - resetToTab('HomeTab') - this.me.clear() - } - - /** - * Periodic poll for new session state. - */ - async updateSessionState() { - if (!this.session.hasSession) { - return - } - try { - await this.me.updateIfNeeded() - await this.preferences.sync() - } catch (e: any) { - logger.error('Failed to fetch latest state', {error: e}) - } - } - - // global event bus - // = - // - some events need to be passed around between views and models - // in order to keep state in sync; these methods are for that - - // a post was deleted by the local user - onPostDeleted(handler: (uri: string) => void): EmitterSubscription { - return DeviceEventEmitter.addListener('post-deleted', handler) - } - emitPostDeleted(uri: string) { - DeviceEventEmitter.emit('post-deleted', uri) - } - - // a list was deleted by the local user - onListDeleted(handler: (uri: string) => void): EmitterSubscription { - return DeviceEventEmitter.addListener('list-deleted', handler) - } - emitListDeleted(uri: string) { - DeviceEventEmitter.emit('list-deleted', uri) - } - - // the session has started and been fully hydrated - onSessionLoaded(handler: () => void): EmitterSubscription { - return DeviceEventEmitter.addListener('session-loaded', handler) - } - emitSessionLoaded() { - DeviceEventEmitter.emit('session-loaded') - } - - // the session has completed all setup; good for post-initialization behaviors like triggering modals - onSessionReady(handler: () => void): EmitterSubscription { - return DeviceEventEmitter.addListener('session-ready', handler) - } - emitSessionReady() { - DeviceEventEmitter.emit('session-ready') - } - - // the session was dropped due to bad/expired refresh tokens - onSessionDropped(handler: () => void): EmitterSubscription { - return DeviceEventEmitter.addListener('session-dropped', handler) - } - emitSessionDropped() { - DeviceEventEmitter.emit('session-dropped') - } - - // the current screen has changed - // TODO is this still needed? - onNavigation(handler: () => void): EmitterSubscription { - return DeviceEventEmitter.addListener('navigation', handler) - } - emitNavigation() { - DeviceEventEmitter.emit('navigation') - } - - // a "soft reset" typically means scrolling to top and loading latest - // but it can depend on the screen - onScreenSoftReset(handler: () => void): EmitterSubscription { - return DeviceEventEmitter.addListener('screen-soft-reset', handler) - } - emitScreenSoftReset() { - DeviceEventEmitter.emit('screen-soft-reset') - } - - // the unread notifications count has changed - onUnreadNotifications(handler: (count: number) => void): EmitterSubscription { - return DeviceEventEmitter.addListener('unread-notifications', handler) - } - emitUnreadNotifications(count: number) { - DeviceEventEmitter.emit('unread-notifications', count) - } -} - -const throwawayInst = new RootStoreModel( - new BskyAgent({service: 'http://localhost'}), -) // this will be replaced by the loader, we just need to supply a value at init -const RootStoreContext = createContext<RootStoreModel>(throwawayInst) -export const RootStoreProvider = RootStoreContext.Provider -export const useStores = () => useContext(RootStoreContext) diff --git a/src/state/models/session.ts b/src/state/models/session.ts deleted file mode 100644 index 5b95c7d32..000000000 --- a/src/state/models/session.ts +++ /dev/null @@ -1,472 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import { - BskyAgent, - AtpSessionEvent, - AtpSessionData, - ComAtprotoServerDescribeServer as DescribeServer, -} from '@atproto/api' -import normalizeUrl from 'normalize-url' -import {isObj, hasProp} from 'lib/type-guards' -import {networkRetry} from 'lib/async/retry' -import {z} from 'zod' -import {RootStoreModel} from './root-store' -import {IS_PROD} from 'lib/constants' -import {track} from 'lib/analytics/analytics' -import {logger} from '#/logger' - -export type ServiceDescription = DescribeServer.OutputSchema - -export const activeSession = z.object({ - service: z.string(), - did: z.string(), -}) -export type ActiveSession = z.infer<typeof activeSession> - -export const accountData = z.object({ - service: z.string(), - refreshJwt: z.string().optional(), - accessJwt: z.string().optional(), - handle: z.string(), - did: z.string(), - email: z.string().optional(), - displayName: z.string().optional(), - aviUrl: z.string().optional(), - emailConfirmed: z.boolean().optional(), -}) -export type AccountData = z.infer<typeof accountData> - -interface AdditionalAccountData { - displayName?: string - aviUrl?: string -} - -export class SessionModel { - // DEBUG - // emergency log facility to help us track down this logout issue - // remove when resolved - // -prf - _log(message: string, details?: Record<string, any>) { - details = details || {} - details.state = { - data: this.data, - accounts: this.accounts.map( - a => - `${!!a.accessJwt && !!a.refreshJwt ? '✅' : '❌'} ${a.handle} (${ - a.service - })`, - ), - isResumingSession: this.isResumingSession, - } - logger.debug(message, details, logger.DebugContext.session) - } - - /** - * Currently-active session - */ - data: ActiveSession | null = null - /** - * A listing of the currently & previous sessions - */ - accounts: AccountData[] = [] - /** - * Flag to indicate if we're doing our initial-load session resumption - */ - isResumingSession = false - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable(this, { - rootStore: false, - serialize: false, - hydrate: false, - hasSession: false, - }) - } - - get currentSession() { - if (!this.data) { - return undefined - } - const {did, service} = this.data - return this.accounts.find( - account => - normalizeUrl(account.service) === normalizeUrl(service) && - account.did === did && - !!account.accessJwt && - !!account.refreshJwt, - ) - } - - get hasSession() { - return !!this.currentSession && !!this.rootStore.agent.session - } - - get hasAccounts() { - return this.accounts.length >= 1 - } - - get switchableAccounts() { - return this.accounts.filter(acct => acct.did !== this.data?.did) - } - - get emailNeedsConfirmation() { - return !this.currentSession?.emailConfirmed - } - - get isSandbox() { - if (!this.data) { - return false - } - return !IS_PROD(this.data.service) - } - - serialize(): unknown { - return { - data: this.data, - accounts: this.accounts, - } - } - - hydrate(v: unknown) { - this.accounts = [] - if (isObj(v)) { - if (hasProp(v, 'data') && activeSession.safeParse(v.data)) { - this.data = v.data as ActiveSession - } - if (hasProp(v, 'accounts') && Array.isArray(v.accounts)) { - for (const account of v.accounts) { - if (accountData.safeParse(account)) { - this.accounts.push(account as AccountData) - } - } - } - } - } - - clear() { - this.data = null - } - - /** - * Attempts to resume the previous session loaded from storage - */ - async attemptSessionResumption() { - const sess = this.currentSession - if (sess) { - this._log('SessionModel:attemptSessionResumption found stored session') - this.isResumingSession = true - try { - return await this.resumeSession(sess) - } finally { - runInAction(() => { - this.isResumingSession = false - }) - } - } else { - this._log( - 'SessionModel:attemptSessionResumption has no session to resume', - ) - } - } - - /** - * Sets the active session - */ - async setActiveSession(agent: BskyAgent, did: string) { - this._log('SessionModel:setActiveSession') - const hadSession = !!this.data - this.data = { - service: agent.service.toString(), - did, - } - await this.rootStore.handleSessionChange(agent, {hadSession}) - } - - /** - * Upserts a session into the accounts - */ - persistSession( - service: string, - did: string, - event: AtpSessionEvent, - session?: AtpSessionData, - addedInfo?: AdditionalAccountData, - ) { - this._log('SessionModel:persistSession', { - service, - did, - event, - hasSession: !!session, - }) - - const existingAccount = this.accounts.find( - account => account.service === service && account.did === did, - ) - - // fall back to any preexisting access tokens - let refreshJwt = session?.refreshJwt || existingAccount?.refreshJwt - let accessJwt = session?.accessJwt || existingAccount?.accessJwt - if (event === 'expired') { - // only clear the tokens when they're known to have expired - refreshJwt = undefined - accessJwt = undefined - } - - const newAccount = { - service, - did, - refreshJwt, - accessJwt, - - handle: session?.handle || existingAccount?.handle || '', - email: session?.email || existingAccount?.email || '', - displayName: addedInfo - ? addedInfo.displayName - : existingAccount?.displayName || '', - aviUrl: addedInfo ? addedInfo.aviUrl : existingAccount?.aviUrl || '', - emailConfirmed: session?.emailConfirmed, - } - if (!existingAccount) { - this.accounts.push(newAccount) - } else { - this.accounts = [ - newAccount, - ...this.accounts.filter( - account => !(account.service === service && account.did === did), - ), - ] - } - - // if the session expired, fire an event to let the user know - if (event === 'expired') { - this.rootStore.handleSessionDrop() - } - } - - /** - * Clears any session tokens from the accounts; used on logout. - */ - clearSessionTokens() { - this._log('SessionModel:clearSessionTokens') - this.accounts = this.accounts.map(acct => ({ - service: acct.service, - handle: acct.handle, - did: acct.did, - displayName: acct.displayName, - aviUrl: acct.aviUrl, - email: acct.email, - emailConfirmed: acct.emailConfirmed, - })) - } - - /** - * Fetches additional information about an account on load. - */ - async loadAccountInfo(agent: BskyAgent, did: string) { - const res = await agent.getProfile({actor: did}).catch(_e => undefined) - if (res) { - return { - displayName: res.data.displayName, - aviUrl: res.data.avatar, - } - } - } - - /** - * Helper to fetch the accounts config settings from an account. - */ - async describeService(service: string): Promise<ServiceDescription> { - const agent = new BskyAgent({service}) - const res = await agent.com.atproto.server.describeServer({}) - return res.data - } - - /** - * Attempt to resume a session that we still have access tokens for. - */ - async resumeSession(account: AccountData): Promise<boolean> { - this._log('SessionModel:resumeSession') - if (!(account.accessJwt && account.refreshJwt && account.service)) { - this._log( - 'SessionModel:resumeSession aborted due to lack of access tokens', - ) - return false - } - - const agent = new BskyAgent({ - service: account.service, - persistSession: (evt: AtpSessionEvent, sess?: AtpSessionData) => { - this.persistSession(account.service, account.did, evt, sess) - }, - }) - - try { - await networkRetry(3, () => - agent.resumeSession({ - accessJwt: account.accessJwt || '', - refreshJwt: account.refreshJwt || '', - did: account.did, - handle: account.handle, - email: account.email, - emailConfirmed: account.emailConfirmed, - }), - ) - const addedInfo = await this.loadAccountInfo(agent, account.did) - this.persistSession( - account.service, - account.did, - 'create', - agent.session, - addedInfo, - ) - this._log('SessionModel:resumeSession succeeded') - } catch (e: any) { - this._log('SessionModel:resumeSession failed', { - error: e.toString(), - }) - return false - } - - await this.setActiveSession(agent, account.did) - return true - } - - /** - * Create a new session. - */ - async login({ - service, - identifier, - password, - }: { - service: string - identifier: string - password: string - }) { - this._log('SessionModel:login') - const agent = new BskyAgent({service}) - await agent.login({identifier, password}) - if (!agent.session) { - throw new Error('Failed to establish session') - } - - const did = agent.session.did - const addedInfo = await this.loadAccountInfo(agent, did) - - this.persistSession(service, did, 'create', agent.session, addedInfo) - agent.setPersistSessionHandler( - (evt: AtpSessionEvent, sess?: AtpSessionData) => { - this.persistSession(service, did, evt, sess) - }, - ) - - await this.setActiveSession(agent, did) - this._log('SessionModel:login succeeded') - } - - async createAccount({ - service, - email, - password, - handle, - inviteCode, - }: { - service: string - email: string - password: string - handle: string - inviteCode?: string - }) { - this._log('SessionModel:createAccount') - const agent = new BskyAgent({service}) - await agent.createAccount({ - handle, - password, - email, - inviteCode, - }) - if (!agent.session) { - throw new Error('Failed to establish session') - } - - const did = agent.session.did - const addedInfo = await this.loadAccountInfo(agent, did) - - this.persistSession(service, did, 'create', agent.session, addedInfo) - agent.setPersistSessionHandler( - (evt: AtpSessionEvent, sess?: AtpSessionData) => { - this.persistSession(service, did, evt, sess) - }, - ) - - await this.setActiveSession(agent, did) - this._log('SessionModel:createAccount succeeded') - track('Create Account Successfully') - } - - /** - * Close all sessions across all accounts. - */ - async logout() { - this._log('SessionModel:logout') - // TODO - // need to evaluate why deleting the session has caused errors at times - // -prf - /*if (this.hasSession) { - this.rootStore.agent.com.atproto.session.delete().catch((e: any) => { - this.rootStore.log.warn( - '(Minor issue) Failed to delete session on the server', - e, - ) - }) - }*/ - this.clearSessionTokens() - this.rootStore.clearAllSessionState() - } - - /** - * Removes an account from the list of stored accounts. - */ - removeAccount(handle: string) { - this.accounts = this.accounts.filter(acc => acc.handle !== handle) - } - - /** - * Reloads the session from the server. Useful when account details change, like the handle. - */ - async reloadFromServer() { - const sess = this.currentSession - if (!sess) { - return - } - const res = await this.rootStore.agent - .getProfile({actor: sess.did}) - .catch(_e => undefined) - if (res?.success) { - const updated = { - ...sess, - handle: res.data.handle, - displayName: res.data.displayName, - aviUrl: res.data.avatar, - } - runInAction(() => { - this.accounts = [ - updated, - ...this.accounts.filter( - account => - !( - account.service === updated.service && - account.did === updated.did - ), - ), - ] - }) - await this.rootStore.me.load() - } - } - - updateLocalAccountData(changes: Partial<AccountData>) { - this.accounts = this.accounts.map(acct => - acct.did === this.data?.did ? {...acct, ...changes} : acct, - ) - } -} diff --git a/src/state/models/ui/create-account.ts b/src/state/models/ui/create-account.ts deleted file mode 100644 index 1711b530f..000000000 --- a/src/state/models/ui/create-account.ts +++ /dev/null @@ -1,216 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import {RootStoreModel} from '../root-store' -import {ServiceDescription} from '../session' -import {DEFAULT_SERVICE} from 'state/index' -import {ComAtprotoServerCreateAccount} from '@atproto/api' -import * as EmailValidator from 'email-validator' -import {createFullHandle} from 'lib/strings/handles' -import {cleanError} from 'lib/strings/errors' -import {getAge} from 'lib/strings/time' -import {track} from 'lib/analytics/analytics' -import {logger} from '#/logger' - -const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago - -export class CreateAccountModel { - step: number = 1 - isProcessing = false - isFetchingServiceDescription = false - didServiceDescriptionFetchFail = false - error = '' - - serviceUrl = DEFAULT_SERVICE - serviceDescription: ServiceDescription | undefined = undefined - userDomain = '' - inviteCode = '' - email = '' - password = '' - handle = '' - birthDate = DEFAULT_DATE - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable(this, {}, {autoBind: true}) - } - - get isAge13() { - return getAge(this.birthDate) >= 13 - } - - get isAge18() { - return getAge(this.birthDate) >= 18 - } - - // form state controls - // = - - next() { - this.error = '' - if (this.step === 2) { - if (!this.isAge13) { - this.error = - 'Unfortunately, you do not meet the requirements to create an account.' - return - } - } - this.step++ - } - - back() { - this.error = '' - this.step-- - } - - setStep(v: number) { - this.step = v - } - - async fetchServiceDescription() { - this.setError('') - this.setIsFetchingServiceDescription(true) - this.setDidServiceDescriptionFetchFail(false) - this.setServiceDescription(undefined) - if (!this.serviceUrl) { - return - } - try { - const desc = await this.rootStore.session.describeService(this.serviceUrl) - this.setServiceDescription(desc) - this.setUserDomain(desc.availableUserDomains[0]) - } catch (err: any) { - logger.warn( - `Failed to fetch service description for ${this.serviceUrl}`, - {error: err}, - ) - this.setError( - 'Unable to contact your service. Please check your Internet connection.', - ) - this.setDidServiceDescriptionFetchFail(true) - } finally { - this.setIsFetchingServiceDescription(false) - } - } - - async submit() { - if (!this.email) { - this.setStep(2) - return this.setError('Please enter your email.') - } - if (!EmailValidator.validate(this.email)) { - this.setStep(2) - return this.setError('Your email appears to be invalid.') - } - if (!this.password) { - this.setStep(2) - return this.setError('Please choose your password.') - } - if (!this.handle) { - this.setStep(3) - return this.setError('Please choose your handle.') - } - this.setError('') - this.setIsProcessing(true) - - try { - this.rootStore.onboarding.start() // start now to avoid flashing the wrong view - await this.rootStore.session.createAccount({ - service: this.serviceUrl, - email: this.email, - handle: createFullHandle(this.handle, this.userDomain), - password: this.password, - inviteCode: this.inviteCode.trim(), - }) - /* dont await */ this.rootStore.preferences.setBirthDate(this.birthDate) - track('Create Account') - } catch (e: any) { - this.rootStore.onboarding.skip() // undo starting the onboard - let errMsg = e.toString() - if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { - errMsg = - 'Invite code not accepted. Check that you input it correctly and try again.' - } - logger.error('Failed to create account', {error: e}) - this.setIsProcessing(false) - this.setError(cleanError(errMsg)) - throw e - } - } - - // form state accessors - // = - - get canBack() { - return this.step > 1 - } - - get canNext() { - if (this.step === 1) { - return !!this.serviceDescription - } else if (this.step === 2) { - return ( - (!this.isInviteCodeRequired || this.inviteCode) && - !!this.email && - !!this.password - ) - } - return !!this.handle - } - - get isServiceDescribed() { - return !!this.serviceDescription - } - - get isInviteCodeRequired() { - return this.serviceDescription?.inviteCodeRequired - } - - // setters - // = - - setIsProcessing(v: boolean) { - this.isProcessing = v - } - - setIsFetchingServiceDescription(v: boolean) { - this.isFetchingServiceDescription = v - } - - setDidServiceDescriptionFetchFail(v: boolean) { - this.didServiceDescriptionFetchFail = v - } - - setError(v: string) { - this.error = v - } - - setServiceUrl(v: string) { - this.serviceUrl = v - } - - setServiceDescription(v: ServiceDescription | undefined) { - this.serviceDescription = v - } - - setUserDomain(v: string) { - this.userDomain = v - } - - setInviteCode(v: string) { - this.inviteCode = v - } - - setEmail(v: string) { - this.email = v - } - - setPassword(v: string) { - this.password = v - } - - setHandle(v: string) { - this.handle = v - } - - setBirthDate(v: Date) { - this.birthDate = v - } -} diff --git a/src/state/models/ui/my-feeds.ts b/src/state/models/ui/my-feeds.ts deleted file mode 100644 index ade686338..000000000 --- a/src/state/models/ui/my-feeds.ts +++ /dev/null @@ -1,182 +0,0 @@ -import {makeAutoObservable, reaction} from 'mobx' -import {SavedFeedsModel} from './saved-feeds' -import {FeedsDiscoveryModel} from '../discovery/feeds' -import {FeedSourceModel} from '../content/feed-source' -import {RootStoreModel} from '../root-store' - -export type MyFeedsItem = - | { - _reactKey: string - type: 'spinner' - } - | { - _reactKey: string - type: 'saved-feeds-loading' - numItems: number - } - | { - _reactKey: string - type: 'discover-feeds-loading' - } - | { - _reactKey: string - type: 'error' - error: string - } - | { - _reactKey: string - type: 'saved-feeds-header' - } - | { - _reactKey: string - type: 'saved-feed' - feed: FeedSourceModel - } - | { - _reactKey: string - type: 'saved-feeds-load-more' - } - | { - _reactKey: string - type: 'discover-feeds-header' - } - | { - _reactKey: string - type: 'discover-feeds-no-results' - } - | { - _reactKey: string - type: 'discover-feed' - feed: FeedSourceModel - } - -export class MyFeedsUIModel { - saved: SavedFeedsModel - discovery: FeedsDiscoveryModel - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable(this) - this.saved = new SavedFeedsModel(this.rootStore) - this.discovery = new FeedsDiscoveryModel(this.rootStore) - } - - get isRefreshing() { - return !this.saved.isLoading && this.saved.isRefreshing - } - - get isLoading() { - return this.saved.isLoading || this.discovery.isLoading - } - - async setup() { - if (!this.saved.hasLoaded) { - await this.saved.refresh() - } - if (!this.discovery.hasLoaded) { - await this.discovery.refresh() - } - } - - clear() { - this.saved.clear() - this.discovery.clear() - } - - registerListeners() { - const dispose1 = reaction( - () => this.rootStore.preferences.savedFeeds, - () => this.saved.refresh(), - ) - const dispose2 = reaction( - () => this.rootStore.preferences.pinnedFeeds, - () => this.saved.refresh(), - ) - return () => { - dispose1() - dispose2() - } - } - - async refresh() { - return Promise.all([this.saved.refresh(), this.discovery.refresh()]) - } - - async loadMore() { - return this.discovery.loadMore() - } - - get items() { - let items: MyFeedsItem[] = [] - - items.push({ - _reactKey: '__saved_feeds_header__', - type: 'saved-feeds-header', - }) - if (this.saved.isLoading && !this.saved.hasContent) { - items.push({ - _reactKey: '__saved_feeds_loading__', - type: 'saved-feeds-loading', - numItems: this.rootStore.preferences.savedFeeds.length || 3, - }) - } else if (this.saved.hasError) { - items.push({ - _reactKey: '__saved_feeds_error__', - type: 'error', - error: this.saved.error, - }) - } else { - const savedSorted = this.saved.all - .slice() - .sort((a, b) => a.displayName.localeCompare(b.displayName)) - items = items.concat( - savedSorted.map(feed => ({ - _reactKey: `saved-${feed.uri}`, - type: 'saved-feed', - feed, - })), - ) - items.push({ - _reactKey: '__saved_feeds_load_more__', - type: 'saved-feeds-load-more', - }) - } - - items.push({ - _reactKey: '__discover_feeds_header__', - type: 'discover-feeds-header', - }) - if (this.discovery.isLoading && !this.discovery.hasContent) { - items.push({ - _reactKey: '__discover_feeds_loading__', - type: 'discover-feeds-loading', - }) - } else if (this.discovery.hasError) { - items.push({ - _reactKey: '__discover_feeds_error__', - type: 'error', - error: this.discovery.error, - }) - } else if (this.discovery.isEmpty) { - items.push({ - _reactKey: '__discover_feeds_no_results__', - type: 'discover-feeds-no-results', - }) - } else { - items = items.concat( - this.discovery.feeds.map(feed => ({ - _reactKey: `discover-${feed.uri}`, - type: 'discover-feed', - feed, - })), - ) - if (this.discovery.isLoading) { - items.push({ - _reactKey: '__discover_feeds_loading_more__', - type: 'spinner', - }) - } - } - - return items - } -} diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts deleted file mode 100644 index 6e43198a3..000000000 --- a/src/state/models/ui/preferences.ts +++ /dev/null @@ -1,702 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import { - LabelPreference as APILabelPreference, - BskyFeedViewPreference, - BskyThreadViewPreference, -} from '@atproto/api' -import AwaitLock from 'await-lock' -import isEqual from 'lodash.isequal' -import {isObj, hasProp} from 'lib/type-guards' -import {RootStoreModel} from '../root-store' -import {ModerationOpts} from '@atproto/api' -import {DEFAULT_FEEDS} from 'lib/constants' -import {deviceLocales} from 'platform/detection' -import {getAge} from 'lib/strings/time' -import {FeedTuner} from 'lib/api/feed-manip' -import {LANGUAGES} from '../../../locale/languages' -import {logger} from '#/logger' - -// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf -export type LabelPreference = APILabelPreference | 'show' -export type FeedViewPreference = BskyFeedViewPreference & { - lab_mergeFeedEnabled?: boolean | undefined -} -export type ThreadViewPreference = BskyThreadViewPreference & { - lab_treeViewEnabled?: boolean | undefined -} -const LABEL_GROUPS = [ - 'nsfw', - 'nudity', - 'suggestive', - 'gore', - 'hate', - 'spam', - 'impersonation', -] -const VISIBILITY_VALUES = ['ignore', 'warn', 'hide'] -const DEFAULT_LANG_CODES = (deviceLocales || []) - .concat(['en', 'ja', 'pt', 'de']) - .slice(0, 6) -const THREAD_SORT_VALUES = ['oldest', 'newest', 'most-likes', 'random'] - -interface LegacyPreferences { - hideReplies?: boolean - hideRepliesByLikeCount?: number - hideReposts?: boolean - hideQuotePosts?: boolean -} - -export class LabelPreferencesModel { - nsfw: LabelPreference = 'hide' - nudity: LabelPreference = 'warn' - suggestive: LabelPreference = 'warn' - gore: LabelPreference = 'warn' - hate: LabelPreference = 'hide' - spam: LabelPreference = 'hide' - impersonation: LabelPreference = 'warn' - - constructor() { - makeAutoObservable(this, {}, {autoBind: true}) - } -} - -export class PreferencesModel { - adultContentEnabled = false - primaryLanguage: string = deviceLocales[0] || 'en' - contentLanguages: string[] = deviceLocales || [] - postLanguage: string = deviceLocales[0] || 'en' - postLanguageHistory: string[] = DEFAULT_LANG_CODES - contentLabels = new LabelPreferencesModel() - savedFeeds: string[] = [] - pinnedFeeds: string[] = [] - birthDate: Date | undefined = undefined - homeFeed: FeedViewPreference = { - hideReplies: false, - hideRepliesByUnfollowed: false, - hideRepliesByLikeCount: 0, - hideReposts: false, - hideQuotePosts: false, - lab_mergeFeedEnabled: false, // experimental - } - thread: ThreadViewPreference = { - sort: 'oldest', - prioritizeFollowedUsers: true, - lab_treeViewEnabled: false, // experimental - } - requireAltTextEnabled: boolean = false - - // used to help with transitions from device-stored to server-stored preferences - legacyPreferences: LegacyPreferences | undefined - - // used to linearize async modifications to state - lock = new AwaitLock() - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable(this, {lock: false}, {autoBind: true}) - } - - get userAge(): number | undefined { - if (!this.birthDate) { - return undefined - } - return getAge(this.birthDate) - } - - serialize() { - return { - primaryLanguage: this.primaryLanguage, - contentLanguages: this.contentLanguages, - postLanguage: this.postLanguage, - postLanguageHistory: this.postLanguageHistory, - contentLabels: this.contentLabels, - savedFeeds: this.savedFeeds, - pinnedFeeds: this.pinnedFeeds, - requireAltTextEnabled: this.requireAltTextEnabled, - } - } - - /** - * The function hydrates an object with properties related to content languages, labels, saved feeds, - * and pinned feeds that it gets from the parameter `v` (probably local storage) - * @param {unknown} v - the data object to hydrate from - */ - hydrate(v: unknown) { - if (isObj(v)) { - if ( - hasProp(v, 'primaryLanguage') && - typeof v.primaryLanguage === 'string' - ) { - this.primaryLanguage = v.primaryLanguage - } else { - // default to the device languages - this.primaryLanguage = deviceLocales[0] || 'en' - } - // check if content languages in preferences exist, otherwise default to device languages - if ( - hasProp(v, 'contentLanguages') && - Array.isArray(v.contentLanguages) && - typeof v.contentLanguages.every(item => typeof item === 'string') - ) { - this.contentLanguages = v.contentLanguages - } else { - // default to the device languages - this.contentLanguages = deviceLocales - } - if (hasProp(v, 'postLanguage') && typeof v.postLanguage === 'string') { - this.postLanguage = v.postLanguage - } else { - // default to the device languages - this.postLanguage = deviceLocales[0] || 'en' - } - if ( - hasProp(v, 'postLanguageHistory') && - Array.isArray(v.postLanguageHistory) && - typeof v.postLanguageHistory.every(item => typeof item === 'string') - ) { - this.postLanguageHistory = v.postLanguageHistory - .concat(DEFAULT_LANG_CODES) - .slice(0, 6) - } else { - // default to a starter set - this.postLanguageHistory = DEFAULT_LANG_CODES - } - // check if content labels in preferences exist, then hydrate - if (hasProp(v, 'contentLabels') && typeof v.contentLabels === 'object') { - Object.assign(this.contentLabels, v.contentLabels) - } - // check if saved feeds in preferences, then hydrate - if ( - hasProp(v, 'savedFeeds') && - Array.isArray(v.savedFeeds) && - typeof v.savedFeeds.every(item => typeof item === 'string') - ) { - this.savedFeeds = v.savedFeeds - } - // check if pinned feeds in preferences exist, then hydrate - if ( - hasProp(v, 'pinnedFeeds') && - Array.isArray(v.pinnedFeeds) && - typeof v.pinnedFeeds.every(item => typeof item === 'string') - ) { - this.pinnedFeeds = v.pinnedFeeds - } - // check if requiring alt text is enabled in preferences, then hydrate - if ( - hasProp(v, 'requireAltTextEnabled') && - typeof v.requireAltTextEnabled === 'boolean' - ) { - this.requireAltTextEnabled = v.requireAltTextEnabled - } - // grab legacy values - this.legacyPreferences = getLegacyPreferences(v) - } - } - - /** - * This function fetches preferences and sets defaults for missing items. - */ - async sync() { - await this.lock.acquireAsync() - try { - // fetch preferences - const prefs = await this.rootStore.agent.getPreferences() - - runInAction(() => { - if (prefs.feedViewPrefs.home) { - this.homeFeed = prefs.feedViewPrefs.home - } - this.thread = prefs.threadViewPrefs - this.adultContentEnabled = prefs.adultContentEnabled - for (const label in prefs.contentLabels) { - if ( - LABEL_GROUPS.includes(label) && - VISIBILITY_VALUES.includes(prefs.contentLabels[label]) - ) { - this.contentLabels[label as keyof LabelPreferencesModel] = - prefs.contentLabels[label] - } - } - if (prefs.feeds.saved && !isEqual(this.savedFeeds, prefs.feeds.saved)) { - this.savedFeeds = prefs.feeds.saved - } - if ( - prefs.feeds.pinned && - !isEqual(this.pinnedFeeds, prefs.feeds.pinned) - ) { - this.pinnedFeeds = prefs.feeds.pinned - } - this.birthDate = prefs.birthDate - }) - - // sync legacy values if needed - await this.syncLegacyPreferences() - - // set defaults on missing items - if (typeof prefs.feeds.saved === 'undefined') { - try { - const {saved, pinned} = await DEFAULT_FEEDS( - this.rootStore.agent.service.toString(), - (handle: string) => - this.rootStore.agent - .resolveHandle({handle}) - .then(({data}) => data.did), - ) - runInAction(() => { - this.savedFeeds = saved - this.pinnedFeeds = pinned - }) - await this.rootStore.agent.setSavedFeeds(saved, pinned) - } catch (error) { - logger.error('Failed to set default feeds', {error}) - } - } - } finally { - this.lock.release() - } - } - - async syncLegacyPreferences() { - if (this.legacyPreferences) { - this.homeFeed = {...this.homeFeed, ...this.legacyPreferences} - this.legacyPreferences = undefined - await this.rootStore.agent.setFeedViewPrefs('home', this.homeFeed) - } - } - - /** - * This function resets the preferences to an empty array of no preferences. - */ - async reset() { - await this.lock.acquireAsync() - try { - runInAction(() => { - this.contentLabels = new LabelPreferencesModel() - this.contentLanguages = deviceLocales - this.postLanguage = deviceLocales ? deviceLocales.join(',') : 'en' - this.postLanguageHistory = DEFAULT_LANG_CODES - this.savedFeeds = [] - this.pinnedFeeds = [] - }) - await this.rootStore.agent.app.bsky.actor.putPreferences({ - preferences: [], - }) - } finally { - this.lock.release() - } - } - - // languages - // = - - hasContentLanguage(code2: string) { - return this.contentLanguages.includes(code2) - } - - toggleContentLanguage(code2: string) { - if (this.hasContentLanguage(code2)) { - this.contentLanguages = this.contentLanguages.filter( - lang => lang !== code2, - ) - } else { - this.contentLanguages = this.contentLanguages.concat([code2]) - } - } - - /** - * A getter that splits `this.postLanguage` into an array of strings. - * - * This was previously the main field on this model, but now we're - * concatenating lang codes to make multi-selection a little better. - */ - get postLanguages() { - // filter out empty strings if exist - return this.postLanguage.split(',').filter(Boolean) - } - - hasPostLanguage(code2: string) { - return this.postLanguages.includes(code2) - } - - togglePostLanguage(code2: string) { - if (this.hasPostLanguage(code2)) { - this.postLanguage = this.postLanguages - .filter(lang => lang !== code2) - .join(',') - } else { - // sort alphabetically for deterministic comparison in context menu - this.postLanguage = this.postLanguages - .concat([code2]) - .sort((a, b) => a.localeCompare(b)) - .join(',') - } - } - - setPostLanguage(commaSeparatedLangCodes: string) { - this.postLanguage = commaSeparatedLangCodes - } - - /** - * Saves whatever language codes are currently selected into a history array, - * which is then used to populate the language selector menu. - */ - savePostLanguageToHistory() { - // filter out duplicate `this.postLanguage` if exists, and prepend - // value to start of array - this.postLanguageHistory = [this.postLanguage] - .concat( - this.postLanguageHistory.filter( - commaSeparatedLangCodes => - commaSeparatedLangCodes !== this.postLanguage, - ), - ) - .slice(0, 6) - } - - getReadablePostLanguages() { - const all = this.postLanguages.map(code2 => { - const lang = LANGUAGES.find(l => l.code2 === code2) - return lang ? lang.name : code2 - }) - return all.join(', ') - } - - // moderation - // = - - async setContentLabelPref( - key: keyof LabelPreferencesModel, - value: LabelPreference, - ) { - this.contentLabels[key] = value - await this.rootStore.agent.setContentLabelPref(key, value) - } - - async setAdultContentEnabled(v: boolean) { - this.adultContentEnabled = v - await this.rootStore.agent.setAdultContentEnabled(v) - } - - get moderationOpts(): ModerationOpts { - return { - userDid: this.rootStore.session.currentSession?.did || '', - adultContentEnabled: this.adultContentEnabled, - labels: { - // TEMP translate old settings until this UI can be migrated -prf - porn: tempfixLabelPref(this.contentLabels.nsfw), - sexual: tempfixLabelPref(this.contentLabels.suggestive), - nudity: tempfixLabelPref(this.contentLabels.nudity), - nsfl: tempfixLabelPref(this.contentLabels.gore), - corpse: tempfixLabelPref(this.contentLabels.gore), - gore: tempfixLabelPref(this.contentLabels.gore), - torture: tempfixLabelPref(this.contentLabels.gore), - 'self-harm': tempfixLabelPref(this.contentLabels.gore), - 'intolerant-race': tempfixLabelPref(this.contentLabels.hate), - 'intolerant-gender': tempfixLabelPref(this.contentLabels.hate), - 'intolerant-sexual-orientation': tempfixLabelPref( - this.contentLabels.hate, - ), - 'intolerant-religion': tempfixLabelPref(this.contentLabels.hate), - intolerant: tempfixLabelPref(this.contentLabels.hate), - 'icon-intolerant': tempfixLabelPref(this.contentLabels.hate), - spam: tempfixLabelPref(this.contentLabels.spam), - impersonation: tempfixLabelPref(this.contentLabels.impersonation), - scam: 'warn', - }, - labelers: [ - { - labeler: { - did: '', - displayName: 'Bluesky Social', - }, - labels: {}, - }, - ], - } - } - - // feeds - // = - - isPinnedFeed(uri: string) { - return this.pinnedFeeds.includes(uri) - } - - async _optimisticUpdateSavedFeeds( - saved: string[], - pinned: string[], - cb: () => Promise<{saved: string[]; pinned: string[]}>, - ) { - const oldSaved = this.savedFeeds - const oldPinned = this.pinnedFeeds - this.savedFeeds = saved - this.pinnedFeeds = pinned - await this.lock.acquireAsync() - try { - const res = await cb() - runInAction(() => { - this.savedFeeds = res.saved - this.pinnedFeeds = res.pinned - }) - } catch (e) { - runInAction(() => { - this.savedFeeds = oldSaved - this.pinnedFeeds = oldPinned - }) - throw e - } finally { - this.lock.release() - } - } - - async setSavedFeeds(saved: string[], pinned: string[]) { - return this._optimisticUpdateSavedFeeds(saved, pinned, () => - this.rootStore.agent.setSavedFeeds(saved, pinned), - ) - } - - async addSavedFeed(v: string) { - return this._optimisticUpdateSavedFeeds( - [...this.savedFeeds.filter(uri => uri !== v), v], - this.pinnedFeeds, - () => this.rootStore.agent.addSavedFeed(v), - ) - } - - async removeSavedFeed(v: string) { - return this._optimisticUpdateSavedFeeds( - this.savedFeeds.filter(uri => uri !== v), - this.pinnedFeeds.filter(uri => uri !== v), - () => this.rootStore.agent.removeSavedFeed(v), - ) - } - - async addPinnedFeed(v: string) { - return this._optimisticUpdateSavedFeeds( - [...this.savedFeeds.filter(uri => uri !== v), v], - [...this.pinnedFeeds.filter(uri => uri !== v), v], - () => this.rootStore.agent.addPinnedFeed(v), - ) - } - - async removePinnedFeed(v: string) { - return this._optimisticUpdateSavedFeeds( - this.savedFeeds, - this.pinnedFeeds.filter(uri => uri !== v), - () => this.rootStore.agent.removePinnedFeed(v), - ) - } - - // other - // = - - async setBirthDate(birthDate: Date) { - this.birthDate = birthDate - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setPersonalDetails({birthDate}) - } finally { - this.lock.release() - } - } - - async toggleHomeFeedHideReplies() { - this.homeFeed.hideReplies = !this.homeFeed.hideReplies - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setFeedViewPrefs('home', { - hideReplies: this.homeFeed.hideReplies, - }) - } finally { - this.lock.release() - } - } - - async toggleHomeFeedHideRepliesByUnfollowed() { - this.homeFeed.hideRepliesByUnfollowed = - !this.homeFeed.hideRepliesByUnfollowed - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setFeedViewPrefs('home', { - hideRepliesByUnfollowed: this.homeFeed.hideRepliesByUnfollowed, - }) - } finally { - this.lock.release() - } - } - - async setHomeFeedHideRepliesByLikeCount(threshold: number) { - this.homeFeed.hideRepliesByLikeCount = threshold - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setFeedViewPrefs('home', { - hideRepliesByLikeCount: this.homeFeed.hideRepliesByLikeCount, - }) - } finally { - this.lock.release() - } - } - - async toggleHomeFeedHideReposts() { - this.homeFeed.hideReposts = !this.homeFeed.hideReposts - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setFeedViewPrefs('home', { - hideReposts: this.homeFeed.hideReposts, - }) - } finally { - this.lock.release() - } - } - - async toggleHomeFeedHideQuotePosts() { - this.homeFeed.hideQuotePosts = !this.homeFeed.hideQuotePosts - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setFeedViewPrefs('home', { - hideQuotePosts: this.homeFeed.hideQuotePosts, - }) - } finally { - this.lock.release() - } - } - - async toggleHomeFeedMergeFeedEnabled() { - this.homeFeed.lab_mergeFeedEnabled = !this.homeFeed.lab_mergeFeedEnabled - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setFeedViewPrefs('home', { - lab_mergeFeedEnabled: this.homeFeed.lab_mergeFeedEnabled, - }) - } finally { - this.lock.release() - } - } - - async setThreadSort(v: string) { - if (THREAD_SORT_VALUES.includes(v)) { - this.thread.sort = v - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setThreadViewPrefs({sort: v}) - } finally { - this.lock.release() - } - } - } - - async togglePrioritizedFollowedUsers() { - this.thread.prioritizeFollowedUsers = !this.thread.prioritizeFollowedUsers - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setThreadViewPrefs({ - prioritizeFollowedUsers: this.thread.prioritizeFollowedUsers, - }) - } finally { - this.lock.release() - } - } - - async toggleThreadTreeViewEnabled() { - this.thread.lab_treeViewEnabled = !this.thread.lab_treeViewEnabled - await this.lock.acquireAsync() - try { - await this.rootStore.agent.setThreadViewPrefs({ - lab_treeViewEnabled: this.thread.lab_treeViewEnabled, - }) - } finally { - this.lock.release() - } - } - - toggleRequireAltTextEnabled() { - this.requireAltTextEnabled = !this.requireAltTextEnabled - } - - setPrimaryLanguage(lang: string) { - this.primaryLanguage = lang - } - - getFeedTuners( - feedType: 'home' | 'following' | 'author' | 'custom' | 'list' | 'likes', - ) { - if (feedType === 'custom') { - return [ - FeedTuner.dedupReposts, - FeedTuner.preferredLangOnly(this.contentLanguages), - ] - } - if (feedType === 'list') { - return [FeedTuner.dedupReposts] - } - if (feedType === 'home' || feedType === 'following') { - const feedTuners = [] - - if (this.homeFeed.hideReposts) { - feedTuners.push(FeedTuner.removeReposts) - } else { - feedTuners.push(FeedTuner.dedupReposts) - } - - if (this.homeFeed.hideReplies) { - feedTuners.push(FeedTuner.removeReplies) - } else { - feedTuners.push( - FeedTuner.thresholdRepliesOnly({ - userDid: this.rootStore.session.data?.did || '', - minLikes: this.homeFeed.hideRepliesByLikeCount, - followedOnly: !!this.homeFeed.hideRepliesByUnfollowed, - }), - ) - } - - if (this.homeFeed.hideQuotePosts) { - feedTuners.push(FeedTuner.removeQuotePosts) - } - - return feedTuners - } - return [] - } -} - -// TEMP we need to permanently convert 'show' to 'ignore', for now we manually convert -prf -function tempfixLabelPref(pref: LabelPreference): APILabelPreference { - if (pref === 'show') { - return 'ignore' - } - return pref -} - -function getLegacyPreferences( - v: Record<string, unknown>, -): LegacyPreferences | undefined { - const legacyPreferences: LegacyPreferences = {} - if ( - hasProp(v, 'homeFeedRepliesEnabled') && - typeof v.homeFeedRepliesEnabled === 'boolean' - ) { - legacyPreferences.hideReplies = !v.homeFeedRepliesEnabled - } - if ( - hasProp(v, 'homeFeedRepliesThreshold') && - typeof v.homeFeedRepliesThreshold === 'number' - ) { - legacyPreferences.hideRepliesByLikeCount = v.homeFeedRepliesThreshold - } - if ( - hasProp(v, 'homeFeedRepostsEnabled') && - typeof v.homeFeedRepostsEnabled === 'boolean' - ) { - legacyPreferences.hideReposts = !v.homeFeedRepostsEnabled - } - if ( - hasProp(v, 'homeFeedQuotePostsEnabled') && - typeof v.homeFeedQuotePostsEnabled === 'boolean' - ) { - legacyPreferences.hideQuotePosts = !v.homeFeedQuotePostsEnabled - } - if (Object.keys(legacyPreferences).length) { - return legacyPreferences - } - return undefined -} diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts deleted file mode 100644 index f96340c65..000000000 --- a/src/state/models/ui/profile.ts +++ /dev/null @@ -1,257 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import {RootStoreModel} from '../root-store' -import {ProfileModel} from '../content/profile' -import {PostsFeedModel} from '../feeds/posts' -import {ActorFeedsModel} from '../lists/actor-feeds' -import {ListsListModel} from '../lists/lists-list' -import {logger} from '#/logger' - -export enum Sections { - PostsNoReplies = 'Posts', - PostsWithReplies = 'Posts & replies', - PostsWithMedia = 'Media', - Likes = 'Likes', - CustomAlgorithms = 'Feeds', - Lists = 'Lists', -} - -export interface ProfileUiParams { - user: string -} - -export class ProfileUiModel { - static LOADING_ITEM = {_reactKey: '__loading__'} - static END_ITEM = {_reactKey: '__end__'} - static EMPTY_ITEM = {_reactKey: '__empty__'} - - isAuthenticatedUser = false - - // data - profile: ProfileModel - feed: PostsFeedModel - algos: ActorFeedsModel - lists: ListsListModel - - // ui state - selectedViewIndex = 0 - - constructor( - public rootStore: RootStoreModel, - public params: ProfileUiParams, - ) { - makeAutoObservable( - this, - { - rootStore: false, - params: false, - }, - {autoBind: true}, - ) - this.profile = new ProfileModel(rootStore, {actor: params.user}) - this.feed = new PostsFeedModel(rootStore, 'author', { - actor: params.user, - limit: 10, - filter: 'posts_no_replies', - }) - this.algos = new ActorFeedsModel(rootStore, {actor: params.user}) - this.lists = new ListsListModel(rootStore, params.user) - } - - get currentView(): PostsFeedModel | ActorFeedsModel | ListsListModel { - if ( - this.selectedView === Sections.PostsNoReplies || - this.selectedView === Sections.PostsWithReplies || - this.selectedView === Sections.PostsWithMedia || - this.selectedView === Sections.Likes - ) { - return this.feed - } else if (this.selectedView === Sections.Lists) { - return this.lists - } - if (this.selectedView === Sections.CustomAlgorithms) { - return this.algos - } - throw new Error(`Invalid selector value: ${this.selectedViewIndex}`) - } - - get isInitialLoading() { - const view = this.currentView - return view.isLoading && !view.isRefreshing && !view.hasContent - } - - get isRefreshing() { - return this.profile.isRefreshing || this.currentView.isRefreshing - } - - get selectorItems() { - const items = [ - Sections.PostsNoReplies, - Sections.PostsWithReplies, - Sections.PostsWithMedia, - this.isAuthenticatedUser && Sections.Likes, - ].filter(Boolean) as string[] - if (this.algos.hasLoaded && !this.algos.isEmpty) { - items.push(Sections.CustomAlgorithms) - } - if (this.lists.hasLoaded && !this.lists.isEmpty) { - items.push(Sections.Lists) - } - return items - } - - get selectedView() { - // If, for whatever reason, the selected view index is not available, default back to posts - // This can happen when the user was focused on a view but performed an action that caused - // the view to disappear (e.g. deleting the last list in their list of lists https://imgflip.com/i/7txu1y) - return this.selectorItems[this.selectedViewIndex] || Sections.PostsNoReplies - } - - get uiItems() { - let arr: any[] = [] - // if loading, return loading item to show loading spinner - if (this.isInitialLoading) { - arr = arr.concat([ProfileUiModel.LOADING_ITEM]) - } else if (this.currentView.hasError) { - // if error, return error item to show error message - arr = arr.concat([ - { - _reactKey: '__error__', - error: this.currentView.error, - }, - ]) - } else { - if ( - this.selectedView === Sections.PostsNoReplies || - this.selectedView === Sections.PostsWithReplies || - this.selectedView === Sections.PostsWithMedia || - this.selectedView === Sections.Likes - ) { - if (this.feed.hasContent) { - arr = this.feed.slices.slice() - if (!this.feed.hasMore) { - arr = arr.concat([ProfileUiModel.END_ITEM]) - } - } else if (this.feed.isEmpty) { - arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) - } - } else if (this.selectedView === Sections.CustomAlgorithms) { - if (this.algos.hasContent) { - arr = this.algos.feeds - } else if (this.algos.isEmpty) { - arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) - } - } else if (this.selectedView === Sections.Lists) { - if (this.lists.hasContent) { - arr = this.lists.lists - } else if (this.lists.isEmpty) { - arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) - } - } else { - // fallback, add empty item, to show empty message - arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) - } - } - return arr - } - - get showLoadingMoreFooter() { - if ( - this.selectedView === Sections.PostsNoReplies || - this.selectedView === Sections.PostsWithReplies || - this.selectedView === Sections.PostsWithMedia || - this.selectedView === Sections.Likes - ) { - return this.feed.hasContent && this.feed.hasMore && this.feed.isLoading - } else if (this.selectedView === Sections.Lists) { - return this.lists.hasContent && this.lists.hasMore && this.lists.isLoading - } - return false - } - - // public api - // = - - setSelectedViewIndex(index: number) { - // ViewSelector fires onSelectView on mount - if (index === this.selectedViewIndex) return - - this.selectedViewIndex = index - - if ( - this.selectedView === Sections.PostsNoReplies || - this.selectedView === Sections.PostsWithReplies || - this.selectedView === Sections.PostsWithMedia - ) { - let filter = 'posts_no_replies' - if (this.selectedView === Sections.PostsWithReplies) { - filter = 'posts_with_replies' - } else if (this.selectedView === Sections.PostsWithMedia) { - filter = 'posts_with_media' - } - - this.feed = new PostsFeedModel( - this.rootStore, - 'author', - { - actor: this.params.user, - limit: 10, - filter, - }, - { - isSimpleFeed: ['posts_with_media'].includes(filter), - }, - ) - - this.feed.setup() - } else if (this.selectedView === Sections.Likes) { - this.feed = new PostsFeedModel( - this.rootStore, - 'likes', - { - actor: this.params.user, - limit: 10, - }, - { - isSimpleFeed: true, - }, - ) - - this.feed.setup() - } - } - - async setup() { - await Promise.all([ - this.profile - .setup() - .catch(err => logger.error('Failed to fetch profile', {error: err})), - this.feed - .setup() - .catch(err => logger.error('Failed to fetch feed', {error: err})), - ]) - runInAction(() => { - this.isAuthenticatedUser = - this.profile.did === this.rootStore.session.currentSession?.did - }) - this.algos.refresh() - // HACK: need to use the DID as a param, not the username -prf - this.lists.source = this.profile.did - this.lists - .loadMore() - .catch(err => logger.error('Failed to fetch lists', {error: err})) - } - - async refresh() { - await Promise.all([this.profile.refresh(), this.currentView.refresh()]) - } - - async loadMore() { - if ( - !this.currentView.isLoading && - !this.currentView.hasError && - !this.currentView.isEmpty - ) { - await this.currentView.loadMore() - } - } -} diff --git a/src/state/models/ui/saved-feeds.ts b/src/state/models/ui/saved-feeds.ts deleted file mode 100644 index 624da4f5f..000000000 --- a/src/state/models/ui/saved-feeds.ts +++ /dev/null @@ -1,155 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import {RootStoreModel} from '../root-store' -import {bundleAsync} from 'lib/async/bundle' -import {cleanError} from 'lib/strings/errors' -import {FeedSourceModel} from '../content/feed-source' -import {track} from 'lib/analytics/analytics' -import {logger} from '#/logger' - -export class SavedFeedsModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - - // data - all: FeedSourceModel[] = [] - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - { - rootStore: false, - }, - {autoBind: true}, - ) - } - - get hasContent() { - return this.all.length > 0 - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - get pinned(): FeedSourceModel[] { - return this.rootStore.preferences.savedFeeds - .filter(feed => this.rootStore.preferences.isPinnedFeed(feed)) - .map(uri => this.all.find(f => f.uri === uri)) - .filter(Boolean) as FeedSourceModel[] - } - - get unpinned(): FeedSourceModel[] { - return this.rootStore.preferences.savedFeeds - .filter(feed => !this.rootStore.preferences.isPinnedFeed(feed)) - .map(uri => this.all.find(f => f.uri === uri)) - .filter(Boolean) as FeedSourceModel[] - } - - get pinnedFeedNames() { - return this.pinned.map(f => f.displayName) - } - - // public api - // = - - clear() { - this.all = [] - } - - /** - * Refresh the preferences then reload all feed infos - */ - refresh = bundleAsync(async () => { - this._xLoading(true) - try { - await this.rootStore.preferences.sync() - const uris = dedup( - this.rootStore.preferences.pinnedFeeds.concat( - this.rootStore.preferences.savedFeeds, - ), - ) - const feeds = uris.map(uri => new FeedSourceModel(this.rootStore, uri)) - await Promise.all(feeds.map(f => f.setup())) - runInAction(() => { - this.all = feeds - this._updatePinSortOrder() - }) - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - }) - - async reorderPinnedFeeds(feeds: FeedSourceModel[]) { - this._updatePinSortOrder(feeds.map(f => f.uri)) - await this.rootStore.preferences.setSavedFeeds( - this.rootStore.preferences.savedFeeds, - feeds.filter(feed => feed.isPinned).map(feed => feed.uri), - ) - } - - async movePinnedFeed(item: FeedSourceModel, direction: 'up' | 'down') { - const pinned = this.rootStore.preferences.pinnedFeeds.slice() - const index = pinned.indexOf(item.uri) - if (index === -1) { - return - } - if (direction === 'up' && index !== 0) { - ;[pinned[index], pinned[index - 1]] = [pinned[index - 1], pinned[index]] - } else if (direction === 'down' && index < pinned.length - 1) { - ;[pinned[index], pinned[index + 1]] = [pinned[index + 1], pinned[index]] - } - this._updatePinSortOrder(pinned.concat(this.unpinned.map(f => f.uri))) - await this.rootStore.preferences.setSavedFeeds( - this.rootStore.preferences.savedFeeds, - pinned, - ) - track('CustomFeed:Reorder', { - name: item.displayName, - uri: item.uri, - index: pinned.indexOf(item.uri), - }) - } - - // state transitions - // = - - _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - _xIdle(err?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = cleanError(err) - if (err) { - logger.error('Failed to fetch user feeds', {err}) - } - } - - // helpers - // = - - _updatePinSortOrder(order?: string[]) { - order ??= this.rootStore.preferences.pinnedFeeds.concat( - this.rootStore.preferences.savedFeeds, - ) - this.all.sort((a, b) => { - return order!.indexOf(a.uri) - order!.indexOf(b.uri) - }) - } -} - -function dedup(strings: string[]): string[] { - return Array.from(new Set(strings)) -} diff --git a/src/state/models/ui/search.ts b/src/state/models/ui/search.ts deleted file mode 100644 index 2b2036751..000000000 --- a/src/state/models/ui/search.ts +++ /dev/null @@ -1,69 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import {searchProfiles, searchPosts} from 'lib/api/search' -import {PostThreadModel} from '../content/post-thread' -import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' -import {RootStoreModel} from '../root-store' - -export class SearchUIModel { - isPostsLoading = false - isProfilesLoading = false - query: string = '' - posts: PostThreadModel[] = [] - profiles: AppBskyActorDefs.ProfileView[] = [] - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable(this) - } - - async fetch(q: string) { - this.posts = [] - this.profiles = [] - this.query = q - if (!q.trim()) { - return - } - - this.isPostsLoading = true - this.isProfilesLoading = true - - const [postsSearch, profilesSearch] = await Promise.all([ - searchPosts(q).catch(_e => []), - searchProfiles(q).catch(_e => []), - ]) - - let posts: AppBskyFeedDefs.PostView[] = [] - if (postsSearch?.length) { - do { - const res = await this.rootStore.agent.app.bsky.feed.getPosts({ - uris: postsSearch - .splice(0, 25) - .map(p => `at://${p.user.did}/${p.tid}`), - }) - posts = posts.concat(res.data.posts) - } while (postsSearch.length) - } - runInAction(() => { - this.posts = posts.map(post => - PostThreadModel.fromPostView(this.rootStore, post), - ) - this.isPostsLoading = false - }) - - let profiles: AppBskyActorDefs.ProfileView[] = [] - if (profilesSearch?.length) { - do { - const res = await this.rootStore.agent.getProfiles({ - actors: profilesSearch.splice(0, 25).map(p => p.did), - }) - profiles = profiles.concat(res.data.profiles) - } while (profilesSearch.length) - } - - this.rootStore.me.follows.hydrateMany(profiles) - - runInAction(() => { - this.profiles = profiles - this.isProfilesLoading = false - }) - } -} diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts deleted file mode 100644 index 343fff86d..000000000 --- a/src/state/models/ui/shell.ts +++ /dev/null @@ -1,376 +0,0 @@ -import {AppBskyEmbedRecord, AppBskyActorDefs, ModerationUI} from '@atproto/api' -import {RootStoreModel} from '../root-store' -import {makeAutoObservable, runInAction} from 'mobx' -import {ProfileModel} from '../content/profile' -import {Image as RNImage} from 'react-native-image-crop-picker' -import {ImageModel} from '../media/image' -import {ListModel} from '../content/list' -import {GalleryModel} from '../media/gallery' -import {StyleProp, ViewStyle} from 'react-native' -import { - shouldRequestEmailConfirmation, - setEmailConfirmationRequested, -} from '#/state/shell/reminders' - -export type ColorMode = 'system' | 'light' | 'dark' - -export function isColorMode(v: unknown): v is ColorMode { - return v === 'system' || v === 'light' || v === 'dark' -} - -export interface ConfirmModal { - name: 'confirm' - title: string - message: string | (() => JSX.Element) - onPressConfirm: () => void | Promise<void> - onPressCancel?: () => void | Promise<void> - confirmBtnText?: string - confirmBtnStyle?: StyleProp<ViewStyle> - cancelBtnText?: string -} - -export interface EditProfileModal { - name: 'edit-profile' - profileView: ProfileModel - onUpdate?: () => void -} - -export interface ProfilePreviewModal { - name: 'profile-preview' - did: string -} - -export interface ServerInputModal { - name: 'server-input' - initialService: string - onSelect: (url: string) => void -} - -export interface ModerationDetailsModal { - name: 'moderation-details' - context: 'account' | 'content' - moderation: ModerationUI -} - -export type ReportModal = { - name: 'report' -} & ( - | { - uri: string - cid: string - } - | {did: string} -) - -export interface CreateOrEditListModal { - name: 'create-or-edit-list' - purpose?: string - list?: ListModel - onSave?: (uri: string) => void -} - -export interface UserAddRemoveListsModal { - name: 'user-add-remove-lists' - subject: string - displayName: string - onAdd?: (listUri: string) => void - onRemove?: (listUri: string) => void -} - -export interface ListAddUserModal { - name: 'list-add-user' - list: ListModel - onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void -} - -export interface EditImageModal { - name: 'edit-image' - image: ImageModel - gallery: GalleryModel -} - -export interface CropImageModal { - name: 'crop-image' - uri: string - onSelect: (img?: RNImage) => void -} - -export interface AltTextImageModal { - name: 'alt-text-image' - image: ImageModel -} - -export interface DeleteAccountModal { - name: 'delete-account' -} - -export interface RepostModal { - name: 'repost' - onRepost: () => void - onQuote: () => void - isReposted: boolean -} - -export interface SelfLabelModal { - name: 'self-label' - labels: string[] - hasMedia: boolean - onChange: (labels: string[]) => void -} - -export interface ChangeHandleModal { - name: 'change-handle' - onChanged: () => void -} - -export interface WaitlistModal { - name: 'waitlist' -} - -export interface InviteCodesModal { - name: 'invite-codes' -} - -export interface AddAppPasswordModal { - name: 'add-app-password' -} - -export interface ContentFilteringSettingsModal { - name: 'content-filtering-settings' -} - -export interface ContentLanguagesSettingsModal { - name: 'content-languages-settings' -} - -export interface PostLanguagesSettingsModal { - name: 'post-languages-settings' -} - -export interface BirthDateSettingsModal { - name: 'birth-date-settings' -} - -export interface VerifyEmailModal { - name: 'verify-email' - showReminder?: boolean -} - -export interface ChangeEmailModal { - name: 'change-email' -} - -export interface SwitchAccountModal { - name: 'switch-account' -} - -export interface LinkWarningModal { - name: 'link-warning' - text: string - href: string -} - -export type Modal = - // Account - | AddAppPasswordModal - | ChangeHandleModal - | DeleteAccountModal - | EditProfileModal - | ProfilePreviewModal - | BirthDateSettingsModal - | VerifyEmailModal - | ChangeEmailModal - | SwitchAccountModal - - // Curation - | ContentFilteringSettingsModal - | ContentLanguagesSettingsModal - | PostLanguagesSettingsModal - - // Moderation - | ModerationDetailsModal - | ReportModal - - // Lists - | CreateOrEditListModal - | UserAddRemoveListsModal - | ListAddUserModal - - // Posts - | AltTextImageModal - | CropImageModal - | EditImageModal - | ServerInputModal - | RepostModal - | SelfLabelModal - - // Bluesky access - | WaitlistModal - | InviteCodesModal - - // Generic - | ConfirmModal - | LinkWarningModal - -interface LightboxModel {} - -export class ProfileImageLightbox implements LightboxModel { - name = 'profile-image' - constructor(public profileView: ProfileModel) { - makeAutoObservable(this) - } -} - -interface ImagesLightboxItem { - uri: string - alt?: string -} - -export class ImagesLightbox implements LightboxModel { - name = 'images' - constructor(public images: ImagesLightboxItem[], public index: number) { - makeAutoObservable(this) - } - setIndex(index: number) { - this.index = index - } -} - -export interface ComposerOptsPostRef { - uri: string - cid: string - text: string - author: { - handle: string - displayName?: string - avatar?: string - } -} -export interface ComposerOptsQuote { - uri: string - cid: string - text: string - indexedAt: string - author: { - did: string - handle: string - displayName?: string - avatar?: string - } - embeds?: AppBskyEmbedRecord.ViewRecord['embeds'] -} -export interface ComposerOpts { - replyTo?: ComposerOptsPostRef - onPost?: () => void - quote?: ComposerOptsQuote - mention?: string // handle of user to mention -} - -export class ShellUiModel { - isModalActive = false - activeModals: Modal[] = [] - isLightboxActive = false - activeLightbox: ProfileImageLightbox | ImagesLightbox | null = null - isComposerActive = false - composerOpts: ComposerOpts | undefined - tickEveryMinute = Date.now() - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable(this, { - rootStore: false, - }) - - this.setupClock() - this.setupLoginModals() - } - - /** - * returns true if something was closed - * (used by the android hardware back btn) - */ - closeAnyActiveElement(): boolean { - if (this.isLightboxActive) { - this.closeLightbox() - return true - } - if (this.isModalActive) { - this.closeModal() - return true - } - if (this.isComposerActive) { - this.closeComposer() - return true - } - return false - } - - /** - * used to clear out any modals, eg for a navigation - */ - closeAllActiveElements() { - if (this.isLightboxActive) { - this.closeLightbox() - } - while (this.isModalActive) { - this.closeModal() - } - if (this.isComposerActive) { - this.closeComposer() - } - } - - openModal(modal: Modal) { - this.rootStore.emitNavigation() - this.isModalActive = true - this.activeModals.push(modal) - } - - closeModal() { - this.activeModals.pop() - this.isModalActive = this.activeModals.length > 0 - } - - openLightbox(lightbox: ProfileImageLightbox | ImagesLightbox) { - this.rootStore.emitNavigation() - this.isLightboxActive = true - this.activeLightbox = lightbox - } - - closeLightbox() { - this.isLightboxActive = false - this.activeLightbox = null - } - - openComposer(opts: ComposerOpts) { - this.rootStore.emitNavigation() - this.isComposerActive = true - this.composerOpts = opts - } - - closeComposer() { - this.isComposerActive = false - this.composerOpts = undefined - } - - setupClock() { - setInterval(() => { - runInAction(() => { - this.tickEveryMinute = Date.now() - }) - }, 60_000) - } - - setupLoginModals() { - this.rootStore.onSessionReady(() => { - if ( - shouldRequestEmailConfirmation( - this.rootStore.session, - this.rootStore.onboarding, - ) - ) { - this.openModal({name: 'verify-email', showReminder: true}) - setEmailConfirmationRequested() - } - }) - } -} diff --git a/src/state/muted-threads.tsx b/src/state/muted-threads.tsx new file mode 100644 index 000000000..2b3a7de6a --- /dev/null +++ b/src/state/muted-threads.tsx @@ -0,0 +1,59 @@ +import React from 'react' +import * as persisted from '#/state/persisted' + +type StateContext = persisted.Schema['mutedThreads'] +type ToggleContext = (uri: string) => boolean + +const stateContext = React.createContext<StateContext>( + persisted.defaults.mutedThreads, +) +const toggleContext = React.createContext<ToggleContext>((_: string) => false) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(persisted.get('mutedThreads')) + + const toggleThreadMute = React.useCallback( + (uri: string) => { + let muted = false + setState((arr: string[]) => { + if (arr.includes(uri)) { + arr = arr.filter(v => v !== uri) + muted = false + } else { + arr = arr.concat([uri]) + muted = true + } + persisted.write('mutedThreads', arr) + return arr + }) + return muted + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate(() => { + setState(persisted.get('mutedThreads')) + }) + }, [setState]) + + return ( + <stateContext.Provider value={state}> + <toggleContext.Provider value={toggleThreadMute}> + {children} + </toggleContext.Provider> + </stateContext.Provider> + ) +} + +export function useMutedThreads() { + return React.useContext(stateContext) +} + +export function useToggleThreadMute() { + return React.useContext(toggleContext) +} + +export function isThreadMuted(uri: string) { + return persisted.get('mutedThreads').includes(uri) +} diff --git a/src/state/persisted/index.ts b/src/state/persisted/index.ts index 67fac6b65..545fdc0e1 100644 --- a/src/state/persisted/index.ts +++ b/src/state/persisted/index.ts @@ -3,10 +3,10 @@ import {logger} from '#/logger' import {defaults, Schema} from '#/state/persisted/schema' import {migrate} from '#/state/persisted/legacy' import * as store from '#/state/persisted/store' -import BroadcastChannel from '#/state/persisted/broadcast' +import BroadcastChannel from '#/lib/broadcast' -export type {Schema} from '#/state/persisted/schema' -export {defaults as schema} from '#/state/persisted/schema' +export type {Schema, PersistedAccount} from '#/state/persisted/schema' +export {defaults} from '#/state/persisted/schema' const broadcast = new BroadcastChannel('BSKY_BROADCAST_CHANNEL') const UPDATE_EVENT = 'BSKY_UPDATE' @@ -19,7 +19,7 @@ const _emitter = new EventEmitter() * the Provider. */ export async function init() { - logger.debug('persisted state: initializing') + logger.info('persisted state: initializing') broadcast.onmessage = onBroadcastMessage @@ -28,11 +28,12 @@ export async function init() { const stored = await store.read() // check for new store if (!stored) await store.write(defaults) // opt: init new store _state = stored || defaults // return new store + logger.log('persisted state: initialized') } catch (e) { logger.error('persisted state: failed to load root state from storage', { error: e, }) - // AsyncStorage failured, but we can still continue in memory + // AsyncStorage failure, but we can still continue in memory return defaults } } @@ -50,7 +51,9 @@ export async function write<K extends keyof Schema>( await store.write(_state) // must happen on next tick, otherwise the tab will read stale storage data setTimeout(() => broadcast.postMessage({event: UPDATE_EVENT}), 0) - logger.debug(`persisted state: wrote root state to storage`) + logger.debug(`persisted state: wrote root state to storage`, { + updatedKey: key, + }) } catch (e) { logger.error(`persisted state: failed writing root state to storage`, { error: e, diff --git a/src/state/persisted/legacy.ts b/src/state/persisted/legacy.ts index 67eef81a0..025877529 100644 --- a/src/state/persisted/legacy.ts +++ b/src/state/persisted/legacy.ts @@ -66,45 +66,47 @@ type LegacySchema = { const DEPRECATED_ROOT_STATE_STORAGE_KEY = 'root' -export function transform(legacy: LegacySchema): Schema { +// TODO remove, assume that partial data may be here during our refactor +export function transform(legacy: Partial<LegacySchema>): Schema { return { colorMode: legacy.shell?.colorMode || defaults.colorMode, session: { - accounts: legacy.session.accounts || defaults.session.accounts, + accounts: legacy.session?.accounts || defaults.session.accounts, currentAccount: - legacy.session.accounts.find(a => a.did === legacy.session.data.did) || - defaults.session.currentAccount, + legacy.session?.accounts?.find( + a => a.did === legacy.session?.data?.did, + ) || defaults.session.currentAccount, }, reminders: { lastEmailConfirm: - legacy.reminders.lastEmailConfirm || + legacy.reminders?.lastEmailConfirm || defaults.reminders.lastEmailConfirm, }, languagePrefs: { primaryLanguage: - legacy.preferences.primaryLanguage || + legacy.preferences?.primaryLanguage || defaults.languagePrefs.primaryLanguage, contentLanguages: - legacy.preferences.contentLanguages || + legacy.preferences?.contentLanguages || defaults.languagePrefs.contentLanguages, postLanguage: - legacy.preferences.postLanguage || defaults.languagePrefs.postLanguage, + legacy.preferences?.postLanguage || defaults.languagePrefs.postLanguage, postLanguageHistory: - legacy.preferences.postLanguageHistory || + legacy.preferences?.postLanguageHistory || defaults.languagePrefs.postLanguageHistory, + appLanguage: + legacy.preferences?.postLanguage || defaults.languagePrefs.appLanguage, }, requireAltTextEnabled: - legacy.preferences.requireAltTextEnabled || + legacy.preferences?.requireAltTextEnabled || defaults.requireAltTextEnabled, - mutedThreads: legacy.mutedThreads.uris || defaults.mutedThreads, - invitedUsers: { - seenDids: legacy.invitedUsers.seenDids || defaults.invitedUsers.seenDids, + mutedThreads: legacy.mutedThreads?.uris || defaults.mutedThreads, + invites: { copiedInvites: - legacy.invitedUsers.copiedInvites || - defaults.invitedUsers.copiedInvites, + legacy.invitedUsers?.copiedInvites || defaults.invites.copiedInvites, }, onboarding: { - step: legacy.onboarding.step || defaults.onboarding.step, + step: legacy.onboarding?.step || defaults.onboarding.step, }, } } @@ -114,20 +116,52 @@ export function transform(legacy: LegacySchema): Schema { * local storage AND old storage exists. */ export async function migrate() { - logger.debug('persisted state: migrate') + logger.info('persisted state: migrate') try { const rawLegacyData = await AsyncStorage.getItem( DEPRECATED_ROOT_STATE_STORAGE_KEY, ) - const alreadyMigrated = Boolean(await read()) + const newData = await read() + const alreadyMigrated = Boolean(newData) + + try { + if (rawLegacyData) { + const legacy = JSON.parse(rawLegacyData) as Partial<LegacySchema> + logger.info(`persisted state: debug legacy data`, { + hasExistingLoggedInAccount: Boolean(legacy?.session?.data), + numberOfExistingAccounts: legacy?.session?.accounts?.length, + foundExistingCurrentAccount: Boolean( + legacy.session?.accounts?.find( + a => a.did === legacy.session?.data?.did, + ), + ), + }) + logger.info(`persisted state: debug new data`, { + hasExistingLoggedInAccount: Boolean(newData?.session?.currentAccount), + numberOfExistingAccounts: newData?.session?.accounts?.length, + existingAccountMatchesLegacy: Boolean( + newData?.session?.currentAccount?.did === + legacy?.session?.data?.did, + ), + }) + } else { + logger.info(`persisted state: no legacy to debug, fresh install`) + } + } catch (e) { + logger.error(`persisted state: legacy debugging failed`, {error: e}) + } if (!alreadyMigrated && rawLegacyData) { - logger.debug('persisted state: migrating legacy storage') + logger.info('persisted state: migrating legacy storage') const legacyData = JSON.parse(rawLegacyData) const newData = transform(legacyData) await write(newData) - logger.debug('persisted state: migrated legacy storage') + // track successful migrations + logger.log('persisted state: migrated legacy storage') + } else { + // track successful migrations + logger.log('persisted state: no migration needed') } } catch (e) { logger.error('persisted state: error migrating legacy storage', { @@ -135,3 +169,13 @@ export async function migrate() { }) } } + +export async function clearLegacyStorage() { + try { + await AsyncStorage.removeItem(DEPRECATED_ROOT_STATE_STORAGE_KEY) + } catch (e: any) { + logger.error(`persisted legacy store: failed to clear`, { + error: e.toString(), + }) + } +} diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index c00ee500a..71f9bd545 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -2,15 +2,19 @@ import {z} from 'zod' import {deviceLocales} from '#/platform/detection' // only data needed for rendering account page +// TODO agent.resumeSession requires the following fields const accountSchema = z.object({ service: z.string(), did: z.string(), - refreshJwt: z.string().optional(), - accessJwt: z.string().optional(), handle: z.string(), - displayName: z.string(), - aviUrl: z.string(), + email: z.string(), + emailConfirmed: z.boolean(), + refreshJwt: z.string().optional(), // optional because it can expire + accessJwt: z.string().optional(), // optional because it can expire + // displayName: z.string().optional(), + // aviUrl: z.string().optional(), }) +export type PersistedAccount = z.infer<typeof accountSchema> export const schema = z.object({ colorMode: z.enum(['system', 'light', 'dark']), @@ -26,11 +30,11 @@ export const schema = z.object({ contentLanguages: z.array(z.string()), // should move to server postLanguage: z.string(), // should move to server postLanguageHistory: z.array(z.string()), + appLanguage: z.string(), }), requireAltTextEnabled: z.boolean(), // should move to server mutedThreads: z.array(z.string()), // should move to server - invitedUsers: z.object({ - seenDids: z.array(z.string()), + invites: z.object({ copiedInvites: z.array(z.string()), }), onboarding: z.object({ @@ -55,11 +59,11 @@ export const defaults: Schema = { postLanguageHistory: (deviceLocales || []) .concat(['en', 'ja', 'pt', 'de']) .slice(0, 6), + appLanguage: deviceLocales[0] || 'en', }, requireAltTextEnabled: false, mutedThreads: [], - invitedUsers: { - seenDids: [], + invites: { copiedInvites: [], }, onboarding: { diff --git a/src/state/persisted/store.ts b/src/state/persisted/store.ts index 2b03bec20..04858fe5b 100644 --- a/src/state/persisted/store.ts +++ b/src/state/persisted/store.ts @@ -1,6 +1,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage' import {Schema, schema} from '#/state/persisted/schema' +import {logger} from '#/logger' const BSKY_STORAGE = 'BSKY_STORAGE' @@ -16,3 +17,11 @@ export async function read(): Promise<Schema | undefined> { return objData } } + +export async function clear() { + try { + await AsyncStorage.removeItem(BSKY_STORAGE) + } catch (e: any) { + logger.error(`persisted store: failed to clear`, {error: e.toString()}) + } +} diff --git a/src/state/preferences/alt-text-required.tsx b/src/state/preferences/alt-text-required.tsx new file mode 100644 index 000000000..81de9e006 --- /dev/null +++ b/src/state/preferences/alt-text-required.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import * as persisted from '#/state/persisted' + +type StateContext = persisted.Schema['requireAltTextEnabled'] +type SetContext = (v: persisted.Schema['requireAltTextEnabled']) => void + +const stateContext = React.createContext<StateContext>( + persisted.defaults.requireAltTextEnabled, +) +const setContext = React.createContext<SetContext>( + (_: persisted.Schema['requireAltTextEnabled']) => {}, +) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState( + persisted.get('requireAltTextEnabled'), + ) + + const setStateWrapped = React.useCallback( + (requireAltTextEnabled: persisted.Schema['requireAltTextEnabled']) => { + setState(requireAltTextEnabled) + persisted.write('requireAltTextEnabled', requireAltTextEnabled) + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate(() => { + setState(persisted.get('requireAltTextEnabled')) + }) + }, [setStateWrapped]) + + return ( + <stateContext.Provider value={state}> + <setContext.Provider value={setStateWrapped}> + {children} + </setContext.Provider> + </stateContext.Provider> + ) +} + +export function useRequireAltTextEnabled() { + return React.useContext(stateContext) +} + +export function useSetRequireAltTextEnabled() { + return React.useContext(setContext) +} diff --git a/src/state/preferences/feed-tuners.tsx b/src/state/preferences/feed-tuners.tsx new file mode 100644 index 000000000..c4954d20a --- /dev/null +++ b/src/state/preferences/feed-tuners.tsx @@ -0,0 +1,52 @@ +import {useMemo} from 'react' +import {FeedTuner} from '#/lib/api/feed-manip' +import {FeedDescriptor} from '../queries/post-feed' +import {useLanguagePrefs} from './languages' +import {usePreferencesQuery} from '../queries/preferences' +import {useSession} from '../session' + +export function useFeedTuners(feedDesc: FeedDescriptor) { + const langPrefs = useLanguagePrefs() + const {data: preferences} = usePreferencesQuery() + const {currentAccount} = useSession() + + return useMemo(() => { + if (feedDesc.startsWith('feedgen')) { + return [ + FeedTuner.dedupReposts, + FeedTuner.preferredLangOnly(langPrefs.contentLanguages), + ] + } + if (feedDesc.startsWith('list')) { + return [FeedTuner.dedupReposts] + } + if (feedDesc === 'home' || feedDesc === 'following') { + const feedTuners = [] + + if (preferences?.feedViewPrefs.hideReposts) { + feedTuners.push(FeedTuner.removeReposts) + } else { + feedTuners.push(FeedTuner.dedupReposts) + } + + if (preferences?.feedViewPrefs.hideReplies) { + feedTuners.push(FeedTuner.removeReplies) + } else { + feedTuners.push( + FeedTuner.thresholdRepliesOnly({ + userDid: currentAccount?.did || '', + minLikes: preferences?.feedViewPrefs.hideRepliesByLikeCount || 0, + followedOnly: !!preferences?.feedViewPrefs.hideRepliesByUnfollowed, + }), + ) + } + + if (preferences?.feedViewPrefs.hideQuotePosts) { + feedTuners.push(FeedTuner.removeQuotePosts) + } + + return feedTuners + } + return [] + }, [feedDesc, currentAccount, preferences, langPrefs]) +} diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx new file mode 100644 index 000000000..1f4348cfc --- /dev/null +++ b/src/state/preferences/index.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import {Provider as LanguagesProvider} from './languages' +import {Provider as AltTextRequiredProvider} from '../preferences/alt-text-required' + +export {useLanguagePrefs, useLanguagePrefsApi} from './languages' +export { + useRequireAltTextEnabled, + useSetRequireAltTextEnabled, +} from './alt-text-required' + +export function Provider({children}: React.PropsWithChildren<{}>) { + return ( + <LanguagesProvider> + <AltTextRequiredProvider>{children}</AltTextRequiredProvider> + </LanguagesProvider> + ) +} diff --git a/src/state/preferences/languages.tsx b/src/state/preferences/languages.tsx new file mode 100644 index 000000000..8e779cfe5 --- /dev/null +++ b/src/state/preferences/languages.tsx @@ -0,0 +1,142 @@ +import React from 'react' +import * as persisted from '#/state/persisted' + +type SetStateCb = ( + s: persisted.Schema['languagePrefs'], +) => persisted.Schema['languagePrefs'] +type StateContext = persisted.Schema['languagePrefs'] +type ApiContext = { + setPrimaryLanguage: (code2: string) => void + setPostLanguage: (commaSeparatedLangCodes: string) => void + toggleContentLanguage: (code2: string) => void + togglePostLanguage: (code2: string) => void + savePostLanguageToHistory: () => void + setAppLanguage: (code2: string) => void +} + +const stateContext = React.createContext<StateContext>( + persisted.defaults.languagePrefs, +) +const apiContext = React.createContext<ApiContext>({ + setPrimaryLanguage: (_: string) => {}, + setPostLanguage: (_: string) => {}, + toggleContentLanguage: (_: string) => {}, + togglePostLanguage: (_: string) => {}, + savePostLanguageToHistory: () => {}, + setAppLanguage: (_: string) => {}, +}) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(persisted.get('languagePrefs')) + + const setStateWrapped = React.useCallback( + (fn: SetStateCb) => { + const s = fn(persisted.get('languagePrefs')) + setState(s) + persisted.write('languagePrefs', s) + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate(() => { + setState(persisted.get('languagePrefs')) + }) + }, [setStateWrapped]) + + const api = React.useMemo( + () => ({ + setPrimaryLanguage(code2: string) { + setStateWrapped(s => ({...s, primaryLanguage: code2})) + }, + setPostLanguage(commaSeparatedLangCodes: string) { + setStateWrapped(s => ({...s, postLanguage: commaSeparatedLangCodes})) + }, + toggleContentLanguage(code2: string) { + setStateWrapped(s => { + const exists = s.contentLanguages.includes(code2) + const next = exists + ? s.contentLanguages.filter(lang => lang !== code2) + : s.contentLanguages.concat(code2) + return { + ...s, + contentLanguages: next, + } + }) + }, + togglePostLanguage(code2: string) { + setStateWrapped(s => { + const exists = hasPostLanguage(state.postLanguage, code2) + let next = s.postLanguage + + if (exists) { + next = toPostLanguages(s.postLanguage) + .filter(lang => lang !== code2) + .join(',') + } else { + // sort alphabetically for deterministic comparison in context menu + next = toPostLanguages(s.postLanguage) + .concat([code2]) + .sort((a, b) => a.localeCompare(b)) + .join(',') + } + + return { + ...s, + postLanguage: next, + } + }) + }, + /** + * Saves whatever language codes are currently selected into a history array, + * which is then used to populate the language selector menu. + */ + savePostLanguageToHistory() { + // filter out duplicate `this.postLanguage` if exists, and prepend + // value to start of array + setStateWrapped(s => ({ + ...s, + postLanguageHistory: [s.postLanguage] + .concat( + s.postLanguageHistory.filter( + commaSeparatedLangCodes => + commaSeparatedLangCodes !== s.postLanguage, + ), + ) + .slice(0, 6), + })) + }, + setAppLanguage(code2: string) { + setStateWrapped(s => ({...s, appLanguage: code2})) + }, + }), + [state, setStateWrapped], + ) + + return ( + <stateContext.Provider value={state}> + <apiContext.Provider value={api}>{children}</apiContext.Provider> + </stateContext.Provider> + ) +} + +export function useLanguagePrefs() { + return React.useContext(stateContext) +} + +export function useLanguagePrefsApi() { + return React.useContext(apiContext) +} + +export function getContentLanguages() { + return persisted.get('languagePrefs').contentLanguages +} + +export function toPostLanguages(postLanguage: string): string[] { + // filter out empty strings if exist + return postLanguage.split(',').filter(Boolean) +} + +export function hasPostLanguage(postLanguage: string, code2: string): boolean { + return toPostLanguages(postLanguage).includes(code2) +} diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts new file mode 100644 index 000000000..fbd1b38f9 --- /dev/null +++ b/src/state/queries/actor-autocomplete.ts @@ -0,0 +1,96 @@ +import React from 'react' +import {AppBskyActorDefs} from '@atproto/api' +import {useQuery, useQueryClient} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {getAgent} from '#/state/session' +import {useMyFollowsQuery} from '#/state/queries/my-follows' +import {STALE} from '#/state/queries' + +export const RQKEY = (prefix: string) => ['actor-autocomplete', prefix] + +export function useActorAutocompleteQuery(prefix: string) { + const {data: follows, isFetching} = useMyFollowsQuery() + + return useQuery<AppBskyActorDefs.ProfileViewBasic[]>({ + staleTime: STALE.MINUTES.ONE, + queryKey: RQKEY(prefix || ''), + async queryFn() { + const res = prefix + ? await getAgent().searchActorsTypeahead({ + term: prefix, + limit: 8, + }) + : undefined + return computeSuggestions(prefix, follows, res?.data.actors) + }, + enabled: !isFetching, + }) +} + +export type ActorAutocompleteFn = ReturnType<typeof useActorAutocompleteFn> +export function useActorAutocompleteFn() { + const queryClient = useQueryClient() + const {data: follows} = useMyFollowsQuery() + + return React.useCallback( + async ({query, limit = 8}: {query: string; limit?: number}) => { + let res + if (query) { + try { + res = await queryClient.fetchQuery({ + staleTime: STALE.MINUTES.ONE, + queryKey: RQKEY(query || ''), + queryFn: () => + getAgent().searchActorsTypeahead({ + term: query, + limit, + }), + }) + } catch (e) { + logger.error('useActorSearch: searchActorsTypeahead failed', { + error: e, + }) + } + } + + return computeSuggestions(query, follows, res?.data.actors) + }, + [follows, queryClient], + ) +} + +function computeSuggestions( + prefix: string, + follows: AppBskyActorDefs.ProfileViewBasic[] | undefined, + searched: AppBskyActorDefs.ProfileViewBasic[] = [], +) { + let items: AppBskyActorDefs.ProfileViewBasic[] = [] + if (follows) { + items = follows.filter(follow => prefixMatch(prefix, follow)).slice(0, 8) + } + for (const item of searched) { + if (!items.find(item2 => item2.handle === item.handle)) { + items.push({ + did: item.did, + handle: item.handle, + displayName: item.displayName, + avatar: item.avatar, + }) + } + } + return items +} + +function prefixMatch( + prefix: string, + info: AppBskyActorDefs.ProfileViewBasic, +): boolean { + if (info.handle.includes(prefix)) { + return true + } + if (info.displayName?.toLocaleLowerCase().includes(prefix)) { + return true + } + return false +} diff --git a/src/state/queries/app-passwords.ts b/src/state/queries/app-passwords.ts new file mode 100644 index 000000000..6a7e43610 --- /dev/null +++ b/src/state/queries/app-passwords.ts @@ -0,0 +1,56 @@ +import {ComAtprotoServerCreateAppPassword} from '@atproto/api' +import {useQuery, useQueryClient, useMutation} from '@tanstack/react-query' + +import {STALE} from '#/state/queries' +import {getAgent} from '../session' + +export const RQKEY = () => ['app-passwords'] + +export function useAppPasswordsQuery() { + return useQuery({ + staleTime: STALE.MINUTES.ONE, + queryKey: RQKEY(), + queryFn: async () => { + const res = await getAgent().com.atproto.server.listAppPasswords({}) + return res.data.passwords + }, + }) +} + +export function useAppPasswordCreateMutation() { + const queryClient = useQueryClient() + return useMutation< + ComAtprotoServerCreateAppPassword.OutputSchema, + Error, + {name: string} + >({ + mutationFn: async ({name}) => { + return ( + await getAgent().com.atproto.server.createAppPassword({ + name, + }) + ).data + }, + onSuccess() { + queryClient.invalidateQueries({ + queryKey: RQKEY(), + }) + }, + }) +} + +export function useAppPasswordDeleteMutation() { + const queryClient = useQueryClient() + return useMutation<void, Error, {name: string}>({ + mutationFn: async ({name}) => { + await getAgent().com.atproto.server.revokeAppPassword({ + name, + }) + }, + onSuccess() { + queryClient.invalidateQueries({ + queryKey: RQKEY(), + }) + }, + }) +} diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts new file mode 100644 index 000000000..9a0ff1eaf --- /dev/null +++ b/src/state/queries/feed.ts @@ -0,0 +1,312 @@ +import React from 'react' +import { + useQuery, + useInfiniteQuery, + InfiniteData, + QueryKey, + useMutation, + useQueryClient, +} from '@tanstack/react-query' +import { + AtUri, + RichText, + AppBskyFeedDefs, + AppBskyGraphDefs, + AppBskyUnspeccedGetPopularFeedGenerators, +} from '@atproto/api' + +import {router} from '#/routes' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' +import {getAgent} from '#/state/session' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {STALE} from '#/state/queries' + +export type FeedSourceFeedInfo = { + type: 'feed' + uri: string + route: { + href: string + name: string + params: Record<string, string> + } + cid: string + avatar: string | undefined + displayName: string + description: RichText + creatorDid: string + creatorHandle: string + likeCount: number | undefined + likeUri: string | undefined +} + +export type FeedSourceListInfo = { + type: 'list' + uri: string + route: { + href: string + name: string + params: Record<string, string> + } + cid: string + avatar: string | undefined + displayName: string + description: RichText + creatorDid: string + creatorHandle: string +} + +export type FeedSourceInfo = FeedSourceFeedInfo | FeedSourceListInfo + +export const feedSourceInfoQueryKey = ({uri}: {uri: string}) => [ + 'getFeedSourceInfo', + uri, +] + +const feedSourceNSIDs = { + feed: 'app.bsky.feed.generator', + list: 'app.bsky.graph.list', +} + +export function hydrateFeedGenerator( + view: AppBskyFeedDefs.GeneratorView, +): FeedSourceInfo { + const urip = new AtUri(view.uri) + const collection = + urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'lists' + const href = `/profile/${urip.hostname}/${collection}/${urip.rkey}` + const route = router.matchPath(href) + + return { + type: 'feed', + uri: view.uri, + cid: view.cid, + route: { + href, + name: route[0], + params: route[1], + }, + avatar: view.avatar, + displayName: view.displayName + ? sanitizeDisplayName(view.displayName) + : `Feed by ${sanitizeHandle(view.creator.handle, '@')}`, + description: new RichText({ + text: view.description || '', + facets: (view.descriptionFacets || [])?.slice(), + }), + creatorDid: view.creator.did, + creatorHandle: view.creator.handle, + likeCount: view.likeCount, + likeUri: view.viewer?.like, + } +} + +export function hydrateList(view: AppBskyGraphDefs.ListView): FeedSourceInfo { + const urip = new AtUri(view.uri) + const collection = + urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'lists' + const href = `/profile/${urip.hostname}/${collection}/${urip.rkey}` + const route = router.matchPath(href) + + return { + type: 'list', + uri: view.uri, + route: { + href, + name: route[0], + params: route[1], + }, + cid: view.cid, + avatar: view.avatar, + description: new RichText({ + text: view.description || '', + facets: (view.descriptionFacets || [])?.slice(), + }), + creatorDid: view.creator.did, + creatorHandle: view.creator.handle, + displayName: view.name + ? sanitizeDisplayName(view.name) + : `User List by ${sanitizeHandle(view.creator.handle, '@')}`, + } +} + +export function getFeedTypeFromUri(uri: string) { + const {pathname} = new AtUri(uri) + return pathname.includes(feedSourceNSIDs.feed) ? 'feed' : 'list' +} + +export function useFeedSourceInfoQuery({uri}: {uri: string}) { + const type = getFeedTypeFromUri(uri) + + return useQuery({ + staleTime: STALE.INFINITY, + queryKey: feedSourceInfoQueryKey({uri}), + queryFn: async () => { + let view: FeedSourceInfo + + if (type === 'feed') { + const res = await getAgent().app.bsky.feed.getFeedGenerator({feed: uri}) + view = hydrateFeedGenerator(res.data.view) + } else { + const res = await getAgent().app.bsky.graph.getList({ + list: uri, + limit: 1, + }) + view = hydrateList(res.data.list) + } + + return view + }, + }) +} + +export const isFeedPublicQueryKey = ({uri}: {uri: string}) => [ + 'isFeedPublic', + uri, +] + +export function useIsFeedPublicQuery({uri}: {uri: string}) { + return useQuery({ + queryKey: isFeedPublicQueryKey({uri}), + queryFn: async ({queryKey}) => { + const [, uri] = queryKey + try { + const res = await getAgent().app.bsky.feed.getFeed({ + feed: uri, + limit: 1, + }) + return Boolean(res.data.feed) + } catch (e: any) { + /** + * This should be an `XRPCError`, but I can't safely import from + * `@atproto/xrpc` due to a depdency on node's `crypto` module. + * + * @see https://github.com/bluesky-social/atproto/blob/c17971a2d8e424cc7f10c071d97c07c08aa319cf/packages/xrpc/src/client.ts#L126 + */ + if (e?.status === 401) { + return false + } + + return true + } + }, + }) +} + +export const useGetPopularFeedsQueryKey = ['getPopularFeeds'] + +export function useGetPopularFeedsQuery() { + return useInfiniteQuery< + AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema, + Error, + InfiniteData<AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema>, + QueryKey, + string | undefined + >({ + queryKey: useGetPopularFeedsQueryKey, + queryFn: async ({pageParam}) => { + const res = await getAgent().app.bsky.unspecced.getPopularFeedGenerators({ + limit: 10, + cursor: pageParam, + }) + return res.data + }, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + }) +} + +export function useSearchPopularFeedsMutation() { + return useMutation({ + mutationFn: async (query: string) => { + const res = await getAgent().app.bsky.unspecced.getPopularFeedGenerators({ + limit: 10, + query: query, + }) + + return res.data.feeds + }, + }) +} + +const FOLLOWING_FEED_STUB: FeedSourceInfo = { + type: 'feed', + displayName: 'Following', + uri: '', + route: { + href: '/', + name: 'Home', + params: {}, + }, + cid: '', + avatar: '', + description: new RichText({text: ''}), + creatorDid: '', + creatorHandle: '', + likeCount: 0, + likeUri: '', +} + +export function usePinnedFeedsInfos(): { + feeds: FeedSourceInfo[] + hasPinnedCustom: boolean +} { + const queryClient = useQueryClient() + const [tabs, setTabs] = React.useState<FeedSourceInfo[]>([ + FOLLOWING_FEED_STUB, + ]) + const {data: preferences} = usePreferencesQuery() + + const hasPinnedCustom = React.useMemo<boolean>(() => { + return tabs.some(tab => tab !== FOLLOWING_FEED_STUB) + }, [tabs]) + + React.useEffect(() => { + if (!preferences?.feeds?.pinned) return + const uris = preferences.feeds.pinned + + async function fetchFeedInfo() { + const reqs = [] + + for (const uri of uris) { + const cached = queryClient.getQueryData<FeedSourceInfo>( + feedSourceInfoQueryKey({uri}), + ) + + if (cached) { + reqs.push(cached) + } else { + reqs.push( + queryClient.fetchQuery({ + queryKey: feedSourceInfoQueryKey({uri}), + queryFn: async () => { + const type = getFeedTypeFromUri(uri) + + if (type === 'feed') { + const res = await getAgent().app.bsky.feed.getFeedGenerator({ + feed: uri, + }) + return hydrateFeedGenerator(res.data.view) + } else { + const res = await getAgent().app.bsky.graph.getList({ + list: uri, + limit: 1, + }) + return hydrateList(res.data.list) + } + }, + }), + ) + } + } + + const views = await Promise.all(reqs) + + setTabs([FOLLOWING_FEED_STUB].concat(views)) + } + + fetchFeedInfo() + }, [queryClient, setTabs, preferences?.feeds?.pinned]) + + return {feeds: tabs, hasPinnedCustom} +} diff --git a/src/state/queries/handle.ts b/src/state/queries/handle.ts new file mode 100644 index 000000000..d7c411699 --- /dev/null +++ b/src/state/queries/handle.ts @@ -0,0 +1,64 @@ +import React from 'react' +import {useQueryClient, useMutation} from '@tanstack/react-query' + +import {getAgent} from '#/state/session' +import {STALE} from '#/state/queries' + +const fetchHandleQueryKey = (handleOrDid: string) => ['handle', handleOrDid] +const fetchDidQueryKey = (handleOrDid: string) => ['did', handleOrDid] + +export function useFetchHandle() { + const queryClient = useQueryClient() + + return React.useCallback( + async (handleOrDid: string) => { + if (handleOrDid.startsWith('did:')) { + const res = await queryClient.fetchQuery({ + staleTime: STALE.MINUTES.FIVE, + queryKey: fetchHandleQueryKey(handleOrDid), + queryFn: () => getAgent().getProfile({actor: handleOrDid}), + }) + return res.data.handle + } + return handleOrDid + }, + [queryClient], + ) +} + +export function useUpdateHandleMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({handle}: {handle: string}) => { + await getAgent().updateHandle({handle}) + }, + onSuccess(_data, variables) { + queryClient.invalidateQueries({ + queryKey: fetchHandleQueryKey(variables.handle), + }) + }, + }) +} + +export function useFetchDid() { + const queryClient = useQueryClient() + + return React.useCallback( + async (handleOrDid: string) => { + return queryClient.fetchQuery({ + staleTime: STALE.INFINITY, + queryKey: fetchDidQueryKey(handleOrDid), + queryFn: async () => { + let identifier = handleOrDid + if (!identifier.startsWith('did:')) { + const res = await getAgent().resolveHandle({handle: identifier}) + identifier = res.data.did + } + return identifier + }, + }) + }, + [queryClient], + ) +} diff --git a/src/state/queries/index.ts b/src/state/queries/index.ts new file mode 100644 index 000000000..affebb907 --- /dev/null +++ b/src/state/queries/index.ts @@ -0,0 +1,16 @@ +import {BskyAgent} from '@atproto/api' + +export const PUBLIC_BSKY_AGENT = new BskyAgent({ + service: 'https://api.bsky.app', +}) + +export const STALE = { + MINUTES: { + ONE: 1e3 * 60, + FIVE: 1e3 * 60 * 5, + }, + HOURS: { + ONE: 1e3 * 60 * 60, + }, + INFINITY: Infinity, +} diff --git a/src/state/queries/invites.ts b/src/state/queries/invites.ts new file mode 100644 index 000000000..367917af5 --- /dev/null +++ b/src/state/queries/invites.ts @@ -0,0 +1,55 @@ +import {ComAtprotoServerDefs} from '@atproto/api' +import {useQuery} from '@tanstack/react-query' + +import {getAgent} from '#/state/session' +import {STALE} from '#/state/queries' +import {cleanError} from '#/lib/strings/errors' + +function isInviteAvailable(invite: ComAtprotoServerDefs.InviteCode): boolean { + return invite.available - invite.uses.length > 0 && !invite.disabled +} + +export type InviteCodesQueryResponse = Exclude< + ReturnType<typeof useInviteCodesQuery>['data'], + undefined +> +export function useInviteCodesQuery() { + return useQuery({ + staleTime: STALE.HOURS.ONE, + queryKey: ['inviteCodes'], + queryFn: async () => { + const res = await getAgent() + .com.atproto.server.getAccountInviteCodes({}) + .catch(e => { + if (cleanError(e) === 'Bad token scope') { + return null + } else { + throw e + } + }) + + if (res === null) { + return { + disabled: true, + all: [], + available: [], + used: [], + } + } + + if (!res.data?.codes) { + throw new Error(`useInviteCodesQuery: no codes returned`) + } + + const available = res.data.codes.filter(isInviteAvailable) + const used = res.data.codes.filter(code => !isInviteAvailable(code)) + + return { + disabled: false, + all: [...available, ...used], + available, + used, + } + }, + }) +} diff --git a/src/state/queries/like.ts b/src/state/queries/like.ts new file mode 100644 index 000000000..94857eb91 --- /dev/null +++ b/src/state/queries/like.ts @@ -0,0 +1,20 @@ +import {useMutation} from '@tanstack/react-query' + +import {getAgent} from '#/state/session' + +export function useLikeMutation() { + return useMutation({ + mutationFn: async ({uri, cid}: {uri: string; cid: string}) => { + const res = await getAgent().like(uri, cid) + return {uri: res.uri} + }, + }) +} + +export function useUnlikeMutation() { + return useMutation({ + mutationFn: async ({uri}: {uri: string}) => { + await getAgent().deleteLike(uri) + }, + }) +} diff --git a/src/state/queries/list-members.ts b/src/state/queries/list-members.ts new file mode 100644 index 000000000..d84089c90 --- /dev/null +++ b/src/state/queries/list-members.ts @@ -0,0 +1,69 @@ +import {AppBskyActorDefs, AppBskyGraphGetList} from '@atproto/api' +import { + useInfiniteQuery, + InfiniteData, + QueryClient, + QueryKey, +} from '@tanstack/react-query' + +import {getAgent} from '#/state/session' +import {STALE} from '#/state/queries' + +const PAGE_SIZE = 30 +type RQPageParam = string | undefined + +export const RQKEY = (uri: string) => ['list-members', uri] + +export function useListMembersQuery(uri: string) { + return useInfiniteQuery< + AppBskyGraphGetList.OutputSchema, + Error, + InfiniteData<AppBskyGraphGetList.OutputSchema>, + QueryKey, + RQPageParam + >({ + staleTime: STALE.MINUTES.ONE, + queryKey: RQKEY(uri), + async queryFn({pageParam}: {pageParam: RQPageParam}) { + const res = await getAgent().app.bsky.graph.getList({ + list: uri, + limit: PAGE_SIZE, + cursor: pageParam, + }) + return res.data + }, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + }) +} + +export function* findAllProfilesInQueryData( + queryClient: QueryClient, + did: string, +): Generator<AppBskyActorDefs.ProfileView, void> { + const queryDatas = queryClient.getQueriesData< + InfiniteData<AppBskyGraphGetList.OutputSchema> + >({ + queryKey: ['list-members'], + }) + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData) { + continue + } + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData?.pages) { + continue + } + for (const page of queryData?.pages) { + if (page.list.creator.did === did) { + yield page.list.creator + } + for (const item of page.items) { + if (item.subject.did === did) { + yield item.subject + } + } + } + } + } +} diff --git a/src/state/queries/list-memberships.ts b/src/state/queries/list-memberships.ts new file mode 100644 index 000000000..6cae3fa2e --- /dev/null +++ b/src/state/queries/list-memberships.ts @@ -0,0 +1,193 @@ +/** + * NOTE + * + * This query is a temporary solution to our lack of server API for + * querying user membership in an API. It is extremely inefficient. + * + * THIS SHOULD ONLY BE USED IN MODALS FOR MODIFYING A USER'S LIST MEMBERSHIP! + * Use the list-members query for rendering a list's members. + * + * It works by fetching *all* of the user's list item records and querying + * or manipulating that cache. For users with large lists, it will fall + * down completely, so be very conservative about how you use it. + * + * -prf + */ + +import {AtUri} from '@atproto/api' +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' + +import {useSession, getAgent} from '#/state/session' +import {RQKEY as LIST_MEMBERS_RQKEY} from '#/state/queries/list-members' +import {STALE} from '#/state/queries' + +// sanity limit is SANITY_PAGE_LIMIT*PAGE_SIZE total records +const SANITY_PAGE_LIMIT = 1000 +const PAGE_SIZE = 100 +// ...which comes 100,000k list members + +export const RQKEY = () => ['list-memberships'] + +export interface ListMembersip { + membershipUri: string + listUri: string + actorDid: string +} + +/** + * This API is dangerous! Read the note above! + */ +export function useDangerousListMembershipsQuery() { + const {currentAccount} = useSession() + return useQuery<ListMembersip[]>({ + staleTime: STALE.MINUTES.FIVE, + queryKey: RQKEY(), + async queryFn() { + if (!currentAccount) { + return [] + } + let cursor + let arr: ListMembersip[] = [] + for (let i = 0; i < SANITY_PAGE_LIMIT; i++) { + const res = await getAgent().app.bsky.graph.listitem.list({ + repo: currentAccount.did, + limit: PAGE_SIZE, + cursor, + }) + arr = arr.concat( + res.records.map(r => ({ + membershipUri: r.uri, + listUri: r.value.list, + actorDid: r.value.subject, + })), + ) + cursor = res.cursor + if (!cursor) { + break + } + } + return arr + }, + }) +} + +/** + * Returns undefined for pending, false for not a member, and string for a member (the URI of the membership record) + */ +export function getMembership( + memberships: ListMembersip[] | undefined, + list: string, + actor: string, +): string | false | undefined { + if (!memberships) { + return undefined + } + const membership = memberships.find( + m => m.listUri === list && m.actorDid === actor, + ) + return membership ? membership.membershipUri : false +} + +export function useListMembershipAddMutation() { + const {currentAccount} = useSession() + const queryClient = useQueryClient() + return useMutation< + {uri: string; cid: string}, + Error, + {listUri: string; actorDid: string} + >({ + mutationFn: async ({listUri, actorDid}) => { + if (!currentAccount) { + throw new Error('Not logged in') + } + const res = await getAgent().app.bsky.graph.listitem.create( + {repo: currentAccount.did}, + { + subject: actorDid, + list: listUri, + createdAt: new Date().toISOString(), + }, + ) + // TODO + // we need to wait for appview to update, but there's not an efficient + // query for that, so we use a timeout below + // -prf + return res + }, + onSuccess(data, variables) { + // manually update the cache; a refetch is too expensive + let memberships = queryClient.getQueryData<ListMembersip[]>(RQKEY()) + if (memberships) { + memberships = memberships + // avoid dups + .filter( + m => + !( + m.actorDid === variables.actorDid && + m.listUri === variables.listUri + ), + ) + .concat([ + { + ...variables, + membershipUri: data.uri, + }, + ]) + queryClient.setQueryData(RQKEY(), memberships) + } + // invalidate the members queries (used for rendering the listings) + // use a timeout to wait for the appview (see above) + setTimeout(() => { + queryClient.invalidateQueries({ + queryKey: LIST_MEMBERS_RQKEY(variables.listUri), + }) + }, 1e3) + }, + }) +} + +export function useListMembershipRemoveMutation() { + const {currentAccount} = useSession() + const queryClient = useQueryClient() + return useMutation< + void, + Error, + {listUri: string; actorDid: string; membershipUri: string} + >({ + mutationFn: async ({membershipUri}) => { + if (!currentAccount) { + throw new Error('Not logged in') + } + const membershipUrip = new AtUri(membershipUri) + await getAgent().app.bsky.graph.listitem.delete({ + repo: currentAccount.did, + rkey: membershipUrip.rkey, + }) + // TODO + // we need to wait for appview to update, but there's not an efficient + // query for that, so we use a timeout below + // -prf + }, + onSuccess(data, variables) { + // manually update the cache; a refetch is too expensive + let memberships = queryClient.getQueryData<ListMembersip[]>(RQKEY()) + if (memberships) { + memberships = memberships.filter( + m => + !( + m.actorDid === variables.actorDid && + m.listUri === variables.listUri + ), + ) + queryClient.setQueryData(RQKEY(), memberships) + } + // invalidate the members queries (used for rendering the listings) + // use a timeout to wait for the appview (see above) + setTimeout(() => { + queryClient.invalidateQueries({ + queryKey: LIST_MEMBERS_RQKEY(variables.listUri), + }) + }, 1e3) + }, + }) +} diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts new file mode 100644 index 000000000..550baecb3 --- /dev/null +++ b/src/state/queries/list.ts @@ -0,0 +1,274 @@ +import { + AtUri, + AppBskyGraphGetList, + AppBskyGraphList, + AppBskyGraphDefs, +} from '@atproto/api' +import {Image as RNImage} from 'react-native-image-crop-picker' +import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' +import chunk from 'lodash.chunk' +import {useSession, getAgent} from '../session' +import {invalidate as invalidateMyLists} from './my-lists' +import {RQKEY as PROFILE_LISTS_RQKEY} from './profile-lists' +import {uploadBlob} from '#/lib/api' +import {until} from '#/lib/async/until' +import {STALE} from '#/state/queries' + +export const RQKEY = (uri: string) => ['list', uri] + +export function useListQuery(uri?: string) { + return useQuery<AppBskyGraphDefs.ListView, Error>({ + staleTime: STALE.MINUTES.ONE, + queryKey: RQKEY(uri || ''), + async queryFn() { + if (!uri) { + throw new Error('URI not provided') + } + const res = await getAgent().app.bsky.graph.getList({ + list: uri, + limit: 1, + }) + return res.data.list + }, + enabled: !!uri, + }) +} + +export interface ListCreateMutateParams { + purpose: string + name: string + description: string + avatar: RNImage | null | undefined +} +export function useListCreateMutation() { + const {currentAccount} = useSession() + const queryClient = useQueryClient() + return useMutation<{uri: string; cid: string}, Error, ListCreateMutateParams>( + { + async mutationFn({purpose, name, description, avatar}) { + if (!currentAccount) { + throw new Error('Not logged in') + } + if ( + purpose !== 'app.bsky.graph.defs#curatelist' && + purpose !== 'app.bsky.graph.defs#modlist' + ) { + throw new Error('Invalid list purpose: must be curatelist or modlist') + } + const record: AppBskyGraphList.Record = { + purpose, + name, + description, + avatar: undefined, + createdAt: new Date().toISOString(), + } + if (avatar) { + const blobRes = await uploadBlob(getAgent(), avatar.path, avatar.mime) + record.avatar = blobRes.data.blob + } + const res = await getAgent().app.bsky.graph.list.create( + { + repo: currentAccount.did, + }, + record, + ) + + // wait for the appview to update + await whenAppViewReady(res.uri, (v: AppBskyGraphGetList.Response) => { + return typeof v?.data?.list.uri === 'string' + }) + return res + }, + onSuccess() { + invalidateMyLists(queryClient) + queryClient.invalidateQueries({ + queryKey: PROFILE_LISTS_RQKEY(currentAccount!.did), + }) + }, + }, + ) +} + +export interface ListMetadataMutateParams { + uri: string + name: string + description: string + avatar: RNImage | null | undefined +} +export function useListMetadataMutation() { + const {currentAccount} = useSession() + const queryClient = useQueryClient() + return useMutation< + {uri: string; cid: string}, + Error, + ListMetadataMutateParams + >({ + async mutationFn({uri, name, description, avatar}) { + const {hostname, rkey} = new AtUri(uri) + if (!currentAccount) { + throw new Error('Not logged in') + } + if (currentAccount.did !== hostname) { + throw new Error('You do not own this list') + } + + // get the current record + const {value: record} = await getAgent().app.bsky.graph.list.get({ + repo: currentAccount.did, + rkey, + }) + + // update the fields + record.name = name + record.description = description + if (avatar) { + const blobRes = await uploadBlob(getAgent(), avatar.path, avatar.mime) + record.avatar = blobRes.data.blob + } else if (avatar === null) { + record.avatar = undefined + } + const res = ( + await getAgent().com.atproto.repo.putRecord({ + repo: currentAccount.did, + collection: 'app.bsky.graph.list', + rkey, + record, + }) + ).data + + // wait for the appview to update + await whenAppViewReady(res.uri, (v: AppBskyGraphGetList.Response) => { + const list = v.data.list + return ( + list.name === record.name && list.description === record.description + ) + }) + return res + }, + onSuccess(data, variables) { + invalidateMyLists(queryClient) + queryClient.invalidateQueries({ + queryKey: PROFILE_LISTS_RQKEY(currentAccount!.did), + }) + queryClient.invalidateQueries({ + queryKey: RQKEY(variables.uri), + }) + }, + }) +} + +export function useListDeleteMutation() { + const {currentAccount} = useSession() + const queryClient = useQueryClient() + return useMutation<void, Error, {uri: string}>({ + mutationFn: async ({uri}) => { + if (!currentAccount) { + return + } + // fetch all the listitem records that belong to this list + let cursor + let listitemRecordUris: string[] = [] + for (let i = 0; i < 100; i++) { + const res = await getAgent().app.bsky.graph.listitem.list({ + repo: currentAccount.did, + cursor, + limit: 100, + }) + listitemRecordUris = listitemRecordUris.concat( + res.records + .filter(record => record.value.list === uri) + .map(record => record.uri), + ) + cursor = res.cursor + if (!cursor) { + break + } + } + + // batch delete the list and listitem records + const createDel = (uri: string) => { + const urip = new AtUri(uri) + return { + $type: 'com.atproto.repo.applyWrites#delete', + collection: urip.collection, + rkey: urip.rkey, + } + } + const writes = listitemRecordUris + .map(uri => createDel(uri)) + .concat([createDel(uri)]) + + // apply in chunks + for (const writesChunk of chunk(writes, 10)) { + await getAgent().com.atproto.repo.applyWrites({ + repo: currentAccount.did, + writes: writesChunk, + }) + } + + // wait for the appview to update + await whenAppViewReady(uri, (v: AppBskyGraphGetList.Response) => { + return !v?.success + }) + }, + onSuccess() { + invalidateMyLists(queryClient) + queryClient.invalidateQueries({ + queryKey: PROFILE_LISTS_RQKEY(currentAccount!.did), + }) + // TODO!! /* dont await */ this.rootStore.preferences.removeSavedFeed(this.uri) + }, + }) +} + +export function useListMuteMutation() { + const queryClient = useQueryClient() + return useMutation<void, Error, {uri: string; mute: boolean}>({ + mutationFn: async ({uri, mute}) => { + if (mute) { + await getAgent().muteModList(uri) + } else { + await getAgent().unmuteModList(uri) + } + }, + onSuccess(data, variables) { + queryClient.invalidateQueries({ + queryKey: RQKEY(variables.uri), + }) + }, + }) +} + +export function useListBlockMutation() { + const queryClient = useQueryClient() + return useMutation<void, Error, {uri: string; block: boolean}>({ + mutationFn: async ({uri, block}) => { + if (block) { + await getAgent().blockModList(uri) + } else { + await getAgent().unblockModList(uri) + } + }, + onSuccess(data, variables) { + queryClient.invalidateQueries({ + queryKey: RQKEY(variables.uri), + }) + }, + }) +} + +async function whenAppViewReady( + uri: string, + fn: (res: AppBskyGraphGetList.Response) => boolean, +) { + await until( + 5, // 5 tries + 1e3, // 1s delay between tries + fn, + () => + getAgent().app.bsky.graph.getList({ + list: uri, + limit: 1, + }), + ) +} diff --git a/src/state/queries/my-blocked-accounts.ts b/src/state/queries/my-blocked-accounts.ts new file mode 100644 index 000000000..badaaec34 --- /dev/null +++ b/src/state/queries/my-blocked-accounts.ts @@ -0,0 +1,56 @@ +import {AppBskyActorDefs, AppBskyGraphGetBlocks} from '@atproto/api' +import { + useInfiniteQuery, + InfiniteData, + QueryClient, + QueryKey, +} from '@tanstack/react-query' + +import {getAgent} from '#/state/session' + +export const RQKEY = () => ['my-blocked-accounts'] +type RQPageParam = string | undefined + +export function useMyBlockedAccountsQuery() { + return useInfiniteQuery< + AppBskyGraphGetBlocks.OutputSchema, + Error, + InfiniteData<AppBskyGraphGetBlocks.OutputSchema>, + QueryKey, + RQPageParam + >({ + queryKey: RQKEY(), + async queryFn({pageParam}: {pageParam: RQPageParam}) { + const res = await getAgent().app.bsky.graph.getBlocks({ + limit: 30, + cursor: pageParam, + }) + return res.data + }, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + }) +} + +export function* findAllProfilesInQueryData( + queryClient: QueryClient, + did: string, +): Generator<AppBskyActorDefs.ProfileView, void> { + const queryDatas = queryClient.getQueriesData< + InfiniteData<AppBskyGraphGetBlocks.OutputSchema> + >({ + queryKey: ['my-blocked-accounts'], + }) + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData?.pages) { + continue + } + for (const page of queryData?.pages) { + for (const block of page.blocks) { + if (block.did === did) { + yield block + } + } + } + } +} diff --git a/src/state/queries/my-follows.ts b/src/state/queries/my-follows.ts new file mode 100644 index 000000000..f95c3f5a7 --- /dev/null +++ b/src/state/queries/my-follows.ts @@ -0,0 +1,45 @@ +import {AppBskyActorDefs} from '@atproto/api' +import {useQuery} from '@tanstack/react-query' +import {useSession, getAgent} from '../session' +import {STALE} from '#/state/queries' + +// sanity limit is SANITY_PAGE_LIMIT*PAGE_SIZE total records +const SANITY_PAGE_LIMIT = 1000 +const PAGE_SIZE = 100 +// ...which comes 10,000k follows + +export const RQKEY = () => ['my-follows'] + +export function useMyFollowsQuery() { + const {currentAccount} = useSession() + return useQuery<AppBskyActorDefs.ProfileViewBasic[]>({ + staleTime: STALE.MINUTES.ONE, + queryKey: RQKEY(), + async queryFn() { + if (!currentAccount) { + return [] + } + let cursor + let arr: AppBskyActorDefs.ProfileViewBasic[] = [] + for (let i = 0; i < SANITY_PAGE_LIMIT; i++) { + const res = await getAgent().getFollows({ + actor: currentAccount.did, + cursor, + limit: PAGE_SIZE, + }) + // TODO + // res.data.follows = res.data.follows.filter( + // profile => + // !moderateProfile(profile, this.rootStore.preferences.moderationOpts) + // .account.filter, + // ) + arr = arr.concat(res.data.follows) + if (!res.data.cursor) { + break + } + cursor = res.data.cursor + } + return arr + }, + }) +} diff --git a/src/state/queries/my-lists.ts b/src/state/queries/my-lists.ts new file mode 100644 index 000000000..3265cb21e --- /dev/null +++ b/src/state/queries/my-lists.ts @@ -0,0 +1,92 @@ +import {AppBskyGraphDefs} from '@atproto/api' +import {useQuery, QueryClient} from '@tanstack/react-query' + +import {accumulate} from '#/lib/async/accumulate' +import {useSession, getAgent} from '#/state/session' +import {STALE} from '#/state/queries' + +export type MyListsFilter = 'all' | 'curate' | 'mod' +export const RQKEY = (filter: MyListsFilter) => ['my-lists', filter] + +export function useMyListsQuery(filter: MyListsFilter) { + const {currentAccount} = useSession() + return useQuery<AppBskyGraphDefs.ListView[]>({ + staleTime: STALE.MINUTES.ONE, + queryKey: RQKEY(filter), + async queryFn() { + let lists: AppBskyGraphDefs.ListView[] = [] + const promises = [ + accumulate(cursor => + getAgent() + .app.bsky.graph.getLists({ + actor: currentAccount!.did, + cursor, + limit: 50, + }) + .then(res => ({ + cursor: res.data.cursor, + items: res.data.lists, + })), + ), + ] + if (filter === 'all' || filter === 'mod') { + promises.push( + accumulate(cursor => + getAgent() + .app.bsky.graph.getListMutes({ + cursor, + limit: 50, + }) + .then(res => ({ + cursor: res.data.cursor, + items: res.data.lists, + })), + ), + ) + promises.push( + accumulate(cursor => + getAgent() + .app.bsky.graph.getListBlocks({ + cursor, + limit: 50, + }) + .then(res => ({ + cursor: res.data.cursor, + items: res.data.lists, + })), + ), + ) + } + const resultset = await Promise.all(promises) + for (const res of resultset) { + for (let list of res) { + if ( + filter === 'curate' && + list.purpose !== 'app.bsky.graph.defs#curatelist' + ) { + continue + } + if ( + filter === 'mod' && + list.purpose !== 'app.bsky.graph.defs#modlist' + ) { + continue + } + if (!lists.find(l => l.uri === list.uri)) { + lists.push(list) + } + } + } + return lists + }, + enabled: !!currentAccount, + }) +} + +export function invalidate(qc: QueryClient, filter?: MyListsFilter) { + if (filter) { + qc.invalidateQueries({queryKey: RQKEY(filter)}) + } else { + qc.invalidateQueries({queryKey: ['my-lists']}) + } +} diff --git a/src/state/queries/my-muted-accounts.ts b/src/state/queries/my-muted-accounts.ts new file mode 100644 index 000000000..8929e04d3 --- /dev/null +++ b/src/state/queries/my-muted-accounts.ts @@ -0,0 +1,56 @@ +import {AppBskyActorDefs, AppBskyGraphGetMutes} from '@atproto/api' +import { + useInfiniteQuery, + InfiniteData, + QueryClient, + QueryKey, +} from '@tanstack/react-query' + +import {getAgent} from '#/state/session' + +export const RQKEY = () => ['my-muted-accounts'] +type RQPageParam = string | undefined + +export function useMyMutedAccountsQuery() { + return useInfiniteQuery< + AppBskyGraphGetMutes.OutputSchema, + Error, + InfiniteData<AppBskyGraphGetMutes.OutputSchema>, + QueryKey, + RQPageParam + >({ + queryKey: RQKEY(), + async queryFn({pageParam}: {pageParam: RQPageParam}) { + const res = await getAgent().app.bsky.graph.getMutes({ + limit: 30, + cursor: pageParam, + }) + return res.data + }, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + }) +} + +export function* findAllProfilesInQueryData( + queryClient: QueryClient, + did: string, +): Generator<AppBskyActorDefs.ProfileView, void> { + const queryDatas = queryClient.getQueriesData< + InfiniteData<AppBskyGraphGetMutes.OutputSchema> + >({ + queryKey: ['my-muted-accounts'], + }) + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData?.pages) { + continue + } + for (const page of queryData?.pages) { + for (const mute of page.mutes) { + if (mute.did === did) { + yield mute + } + } + } + } +} diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts new file mode 100644 index 000000000..16025f856 --- /dev/null +++ b/src/state/queries/notifications/feed.ts @@ -0,0 +1,125 @@ +/** + * NOTE + * The ./unread.ts API: + * + * - Provides a `checkUnread()` function to sync with the server, + * - Periodically calls `checkUnread()`, and + * - Caches the first page of notifications. + * + * IMPORTANT: This query uses ./unread.ts's cache as its first page, + * IMPORTANT: which means the cache-freshness of this query is driven by the unread API. + * + * Follow these rules: + * + * 1. Call `checkUnread()` if you want to fetch latest in the background. + * 2. Call `checkUnread({invalidate: true})` if you want latest to sync into this query's results immediately. + * 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead. + */ + +import {AppBskyFeedDefs} from '@atproto/api' +import { + useInfiniteQuery, + InfiniteData, + QueryKey, + useQueryClient, + QueryClient, +} from '@tanstack/react-query' +import {useModerationOpts} from '../preferences' +import {useUnreadNotificationsApi} from './unread' +import {fetchPage} from './util' +import {FeedPage} from './types' +import {useMutedThreads} from '#/state/muted-threads' +import {STALE} from '..' + +export type {NotificationType, FeedNotification, FeedPage} from './types' + +const PAGE_SIZE = 30 + +type RQPageParam = string | undefined + +export function RQKEY() { + return ['notification-feed'] +} + +export function useNotificationFeedQuery(opts?: {enabled?: boolean}) { + const queryClient = useQueryClient() + const moderationOpts = useModerationOpts() + const threadMutes = useMutedThreads() + const unreads = useUnreadNotificationsApi() + const enabled = opts?.enabled !== false + + return useInfiniteQuery< + FeedPage, + Error, + InfiniteData<FeedPage>, + QueryKey, + RQPageParam + >({ + staleTime: STALE.INFINITY, + queryKey: RQKEY(), + async queryFn({pageParam}: {pageParam: RQPageParam}) { + let page + if (!pageParam) { + // for the first page, we check the cached page held by the unread-checker first + page = unreads.getCachedUnreadPage() + } + if (!page) { + page = await fetchPage({ + limit: PAGE_SIZE, + cursor: pageParam, + queryClient, + moderationOpts, + threadMutes, + }) + } + + // if the first page has an unread, mark all read + if (!pageParam && page.items[0] && !page.items[0].notification.isRead) { + unreads.markAllRead() + } + + return page + }, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + enabled, + }) +} + +/** + * This helper is used by the post-thread placeholder function to + * find a post in the query-data cache + */ +export function findPostInQueryData( + queryClient: QueryClient, + uri: string, +): AppBskyFeedDefs.PostView | undefined { + const generator = findAllPostsInQueryData(queryClient, uri) + const result = generator.next() + if (result.done) { + return undefined + } else { + return result.value + } +} + +export function* findAllPostsInQueryData( + queryClient: QueryClient, + uri: string, +): Generator<AppBskyFeedDefs.PostView, void> { + const queryDatas = queryClient.getQueriesData<InfiniteData<FeedPage>>({ + queryKey: ['notification-feed'], + }) + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData?.pages) { + continue + } + for (const page of queryData?.pages) { + for (const item of page.items) { + if (item.subject?.uri === uri) { + yield item.subject + } + } + } + } +} diff --git a/src/state/queries/notifications/types.ts b/src/state/queries/notifications/types.ts new file mode 100644 index 000000000..0e88f1071 --- /dev/null +++ b/src/state/queries/notifications/types.ts @@ -0,0 +1,34 @@ +import { + AppBskyNotificationListNotifications, + AppBskyFeedDefs, +} from '@atproto/api' + +export type NotificationType = + | 'post-like' + | 'feedgen-like' + | 'repost' + | 'mention' + | 'reply' + | 'quote' + | 'follow' + | 'unknown' + +export interface FeedNotification { + _reactKey: string + type: NotificationType + notification: AppBskyNotificationListNotifications.Notification + additional?: AppBskyNotificationListNotifications.Notification[] + subjectUri?: string + subject?: AppBskyFeedDefs.PostView +} + +export interface FeedPage { + cursor: string | undefined + items: FeedNotification[] +} + +export interface CachedFeedPage { + sessDid: string // used to invalidate on session changes + syncedAt: Date + data: FeedPage | undefined +} diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx new file mode 100644 index 000000000..6c130aaea --- /dev/null +++ b/src/state/queries/notifications/unread.tsx @@ -0,0 +1,179 @@ +/** + * A kind of companion API to ./feed.ts. See that file for more info. + */ + +import React from 'react' +import * as Notifications from 'expo-notifications' +import {useQueryClient} from '@tanstack/react-query' +import BroadcastChannel from '#/lib/broadcast' +import {useSession, getAgent} from '#/state/session' +import {useModerationOpts} from '../preferences' +import {fetchPage} from './util' +import {CachedFeedPage, FeedPage} from './types' +import {isNative} from '#/platform/detection' +import {useMutedThreads} from '#/state/muted-threads' +import {RQKEY as RQKEY_NOTIFS} from './feed' +import {logger} from '#/logger' +import {truncateAndInvalidate} from '../util' + +const UPDATE_INTERVAL = 30 * 1e3 // 30sec + +const broadcast = new BroadcastChannel('NOTIFS_BROADCAST_CHANNEL') + +type StateContext = string + +interface ApiContext { + markAllRead: () => Promise<void> + checkUnread: (opts?: {invalidate?: boolean}) => Promise<void> + getCachedUnreadPage: () => FeedPage | undefined +} + +const stateContext = React.createContext<StateContext>('') + +const apiContext = React.createContext<ApiContext>({ + async markAllRead() {}, + async checkUnread() {}, + getCachedUnreadPage: () => undefined, +}) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const {hasSession, currentAccount} = useSession() + const queryClient = useQueryClient() + const moderationOpts = useModerationOpts() + const threadMutes = useMutedThreads() + + const [numUnread, setNumUnread] = React.useState('') + + const checkUnreadRef = React.useRef<ApiContext['checkUnread'] | null>(null) + const cacheRef = React.useRef<CachedFeedPage>({ + sessDid: currentAccount?.did || '', + syncedAt: new Date(), + data: undefined, + }) + + // periodic sync + React.useEffect(() => { + if (!hasSession || !checkUnreadRef.current) { + return + } + checkUnreadRef.current() // fire on init + const interval = setInterval(checkUnreadRef.current, UPDATE_INTERVAL) + return () => clearInterval(interval) + }, [hasSession]) + + // listen for broadcasts + React.useEffect(() => { + const listener = ({data}: MessageEvent) => { + cacheRef.current = { + sessDid: currentAccount?.did || '', + syncedAt: new Date(), + data: undefined, + } + setNumUnread(data.event) + } + broadcast.addEventListener('message', listener) + return () => { + broadcast.removeEventListener('message', listener) + } + }, [setNumUnread, currentAccount]) + + // create API + const api = React.useMemo<ApiContext>(() => { + return { + async markAllRead() { + // update server + await getAgent().updateSeenNotifications( + cacheRef.current.syncedAt.toISOString(), + ) + + // update & broadcast + setNumUnread('') + broadcast.postMessage({event: ''}) + }, + + async checkUnread({invalidate}: {invalidate?: boolean} = {}) { + try { + if (!getAgent().session) return + + // count + const page = await fetchPage({ + cursor: undefined, + limit: 40, + queryClient, + moderationOpts, + threadMutes, + }) + const unreadCount = countUnread(page) + const unreadCountStr = + unreadCount >= 30 + ? '30+' + : unreadCount === 0 + ? '' + : String(unreadCount) + if (isNative) { + Notifications.setBadgeCountAsync(Math.min(unreadCount, 30)) + } + + // track last sync + const now = new Date() + const lastIndexed = + page.items[0] && new Date(page.items[0].notification.indexedAt) + cacheRef.current = { + sessDid: currentAccount?.did || '', + data: page, + syncedAt: !lastIndexed || now > lastIndexed ? now : lastIndexed, + } + + // update & broadcast + setNumUnread(unreadCountStr) + if (invalidate) { + truncateAndInvalidate(queryClient, RQKEY_NOTIFS()) + } + broadcast.postMessage({event: unreadCountStr}) + } catch (e) { + logger.error('Failed to check unread notifications', {error: e}) + } + }, + + getCachedUnreadPage() { + // return cached page if was for the current user + // (protects against session changes serving data from the past session) + if (cacheRef.current.sessDid === currentAccount?.did) { + return cacheRef.current.data + } + }, + } + }, [setNumUnread, queryClient, moderationOpts, threadMutes, currentAccount]) + checkUnreadRef.current = api.checkUnread + + return ( + <stateContext.Provider value={numUnread}> + <apiContext.Provider value={api}>{children}</apiContext.Provider> + </stateContext.Provider> + ) +} + +export function useUnreadNotifications() { + return React.useContext(stateContext) +} + +export function useUnreadNotificationsApi() { + return React.useContext(apiContext) +} + +function countUnread(page: FeedPage) { + let num = 0 + for (const item of page.items) { + if (!item.notification.isRead) { + num++ + } + if (item.additional) { + for (const item2 of item.additional) { + if (!item2.isRead) { + num++ + } + } + } + } + return num +} diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts new file mode 100644 index 000000000..b8f320473 --- /dev/null +++ b/src/state/queries/notifications/util.ts @@ -0,0 +1,219 @@ +import { + AppBskyNotificationListNotifications, + ModerationOpts, + moderateProfile, + moderatePost, + AppBskyFeedDefs, + AppBskyFeedPost, + AppBskyFeedRepost, + AppBskyFeedLike, +} from '@atproto/api' +import chunk from 'lodash.chunk' +import {QueryClient} from '@tanstack/react-query' +import {getAgent} from '../../session' +import {precacheProfile as precacheResolvedUri} from '../resolve-uri' +import {NotificationType, FeedNotification, FeedPage} from './types' + +const GROUPABLE_REASONS = ['like', 'repost', 'follow'] +const MS_1HR = 1e3 * 60 * 60 +const MS_2DAY = MS_1HR * 48 + +// exported api +// = + +export async function fetchPage({ + cursor, + limit, + queryClient, + moderationOpts, + threadMutes, +}: { + cursor: string | undefined + limit: number + queryClient: QueryClient + moderationOpts: ModerationOpts | undefined + threadMutes: string[] +}): Promise<FeedPage> { + const res = await getAgent().listNotifications({ + limit, + cursor, + }) + + // filter out notifs by mod rules + const notifs = res.data.notifications.filter( + notif => !shouldFilterNotif(notif, moderationOpts), + ) + + // group notifications which are essentially similar (follows, likes on a post) + let notifsGrouped = groupNotifications(notifs) + + // we fetch subjects of notifications (usually posts) now instead of lazily + // in the UI to avoid relayouts + const subjects = await fetchSubjects(notifsGrouped) + for (const notif of notifsGrouped) { + if (notif.subjectUri) { + notif.subject = subjects.get(notif.subjectUri) + if (notif.subject) { + precacheResolvedUri(queryClient, notif.subject.author) // precache the handle->did resolution + } + } + } + + // apply thread muting + notifsGrouped = notifsGrouped.filter( + notif => !isThreadMuted(notif, threadMutes), + ) + + return { + cursor: res.data.cursor, + items: notifsGrouped, + } +} + +// internal methods +// = + +// TODO this should be in the sdk as moderateNotification -prf +function shouldFilterNotif( + notif: AppBskyNotificationListNotifications.Notification, + moderationOpts: ModerationOpts | undefined, +): boolean { + if (!moderationOpts) { + return false + } + const profile = moderateProfile(notif.author, moderationOpts) + if ( + profile.account.filter || + profile.profile.filter || + notif.author.viewer?.muted + ) { + return true + } + if ( + notif.type === 'reply' || + notif.type === 'quote' || + notif.type === 'mention' + ) { + // NOTE: the notification overlaps the post enough for this to work + const post = moderatePost(notif, moderationOpts) + if (post.content.filter) { + return true + } + } + // TODO: thread muting is not being applied + // (this requires fetching the post) + return false +} + +function groupNotifications( + notifs: AppBskyNotificationListNotifications.Notification[], +): FeedNotification[] { + const groupedNotifs: FeedNotification[] = [] + for (const notif of notifs) { + const ts = +new Date(notif.indexedAt) + let grouped = false + if (GROUPABLE_REASONS.includes(notif.reason)) { + for (const groupedNotif of groupedNotifs) { + const ts2 = +new Date(groupedNotif.notification.indexedAt) + if ( + Math.abs(ts2 - ts) < MS_2DAY && + notif.reason === groupedNotif.notification.reason && + notif.reasonSubject === groupedNotif.notification.reasonSubject && + notif.author.did !== groupedNotif.notification.author.did && + notif.isRead === groupedNotif.notification.isRead + ) { + groupedNotif.additional = groupedNotif.additional || [] + groupedNotif.additional.push(notif) + grouped = true + break + } + } + } + if (!grouped) { + const type = toKnownType(notif) + groupedNotifs.push({ + _reactKey: `notif-${notif.uri}`, + type, + notification: notif, + subjectUri: getSubjectUri(type, notif), + }) + } + } + return groupedNotifs +} + +async function fetchSubjects( + groupedNotifs: FeedNotification[], +): Promise<Map<string, AppBskyFeedDefs.PostView>> { + const uris = new Set<string>() + for (const notif of groupedNotifs) { + if (notif.subjectUri) { + uris.add(notif.subjectUri) + } + } + const uriChunks = chunk(Array.from(uris), 25) + const postsChunks = await Promise.all( + uriChunks.map(uris => + getAgent() + .app.bsky.feed.getPosts({uris}) + .then(res => res.data.posts), + ), + ) + const map = new Map<string, AppBskyFeedDefs.PostView>() + for (const post of postsChunks.flat()) { + if ( + AppBskyFeedPost.isRecord(post.record) && + AppBskyFeedPost.validateRecord(post.record).success + ) { + map.set(post.uri, post) + } + } + return map +} + +function toKnownType( + notif: AppBskyNotificationListNotifications.Notification, +): NotificationType { + if (notif.reason === 'like') { + if (notif.reasonSubject?.includes('feed.generator')) { + return 'feedgen-like' + } + return 'post-like' + } + if ( + notif.reason === 'repost' || + notif.reason === 'mention' || + notif.reason === 'reply' || + notif.reason === 'quote' || + notif.reason === 'follow' + ) { + return notif.reason as NotificationType + } + return 'unknown' +} + +function getSubjectUri( + type: NotificationType, + notif: AppBskyNotificationListNotifications.Notification, +): string | undefined { + if (type === 'reply' || type === 'quote' || type === 'mention') { + return notif.uri + } else if (type === 'post-like' || type === 'repost') { + if ( + AppBskyFeedRepost.isRecord(notif.record) || + AppBskyFeedLike.isRecord(notif.record) + ) { + return typeof notif.record.subject?.uri === 'string' + ? notif.record.subject?.uri + : undefined + } + } +} + +function isThreadMuted(notif: FeedNotification, mutes: string[]): boolean { + if (!notif.subject) { + return false + } + const record = notif.subject.record as AppBskyFeedPost.Record // assured in fetchSubjects() + return mutes.includes(record.reply?.root.uri || notif.subject.uri) +} diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts new file mode 100644 index 000000000..7589aa346 --- /dev/null +++ b/src/state/queries/post-feed.ts @@ -0,0 +1,303 @@ +import {useCallback} from 'react' +import {AppBskyFeedDefs, AppBskyFeedPost, moderatePost} from '@atproto/api' +import { + useInfiniteQuery, + InfiniteData, + QueryKey, + QueryClient, + useQueryClient, +} from '@tanstack/react-query' +import {useFeedTuners} from '../preferences/feed-tuners' +import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip' +import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types' +import {FollowingFeedAPI} from 'lib/api/feed/following' +import {AuthorFeedAPI} from 'lib/api/feed/author' +import {LikesFeedAPI} from 'lib/api/feed/likes' +import {CustomFeedAPI} from 'lib/api/feed/custom' +import {ListFeedAPI} from 'lib/api/feed/list' +import {MergeFeedAPI} from 'lib/api/feed/merge' +import {logger} from '#/logger' +import {STALE} from '#/state/queries' +import {precacheFeedPosts as precacheResolvedUris} from './resolve-uri' +import {getAgent} from '#/state/session' +import {DEFAULT_LOGGED_OUT_PREFERENCES} from '#/state/queries/preferences/const' +import {getModerationOpts} from '#/state/queries/preferences/moderation' +import {KnownError} from '#/view/com/posts/FeedErrorMessage' + +type ActorDid = string +type AuthorFilter = + | 'posts_with_replies' + | 'posts_no_replies' + | 'posts_with_media' +type FeedUri = string +type ListUri = string +export type FeedDescriptor = + | 'home' + | 'following' + | `author|${ActorDid}|${AuthorFilter}` + | `feedgen|${FeedUri}` + | `likes|${ActorDid}` + | `list|${ListUri}` +export interface FeedParams { + disableTuner?: boolean + mergeFeedEnabled?: boolean + mergeFeedSources?: string[] +} + +type RQPageParam = {cursor: string | undefined; api: FeedAPI} | undefined + +export function RQKEY(feedDesc: FeedDescriptor, params?: FeedParams) { + return ['post-feed', feedDesc, params || {}] +} + +export interface FeedPostSliceItem { + _reactKey: string + uri: string + post: AppBskyFeedDefs.PostView + record: AppBskyFeedPost.Record + reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource +} + +export interface FeedPostSlice { + _reactKey: string + rootUri: string + isThread: boolean + items: FeedPostSliceItem[] +} + +export interface FeedPageUnselected { + api: FeedAPI + cursor: string | undefined + feed: AppBskyFeedDefs.FeedViewPost[] +} + +export interface FeedPage { + api: FeedAPI + tuner: FeedTuner | NoopFeedTuner + cursor: string | undefined + slices: FeedPostSlice[] +} + +export function usePostFeedQuery( + feedDesc: FeedDescriptor, + params?: FeedParams, + opts?: {enabled?: boolean}, +) { + const queryClient = useQueryClient() + const feedTuners = useFeedTuners(feedDesc) + const enabled = opts?.enabled !== false + + return useInfiniteQuery< + FeedPageUnselected, + Error, + InfiniteData<FeedPage>, + QueryKey, + RQPageParam + >({ + enabled, + staleTime: STALE.INFINITY, + queryKey: RQKEY(feedDesc, params), + async queryFn({pageParam}: {pageParam: RQPageParam}) { + logger.debug('usePostFeedQuery', {feedDesc, pageParam}) + + const {api, cursor} = pageParam + ? pageParam + : { + api: createApi(feedDesc, params || {}, feedTuners), + cursor: undefined, + } + + const res = await api.fetch({cursor, limit: 30}) + precacheResolvedUris(queryClient, res.feed) // precache the handle->did resolution + + /* + * If this is a public view, we need to check if posts fail moderation. + * If all fail, we throw an error. If only some fail, we continue and let + * moderations happen later, which results in some posts being shown and + * some not. + */ + if (!getAgent().session) { + assertSomePostsPassModeration(res.feed) + } + + return { + api, + cursor: res.cursor, + feed: res.feed, + } + }, + initialPageParam: undefined, + getNextPageParam: lastPage => + lastPage.cursor + ? { + api: lastPage.api, + cursor: lastPage.cursor, + } + : undefined, + select: useCallback( + (data: InfiniteData<FeedPageUnselected, RQPageParam>) => { + const tuner = params?.disableTuner + ? new NoopFeedTuner() + : new FeedTuner(feedTuners) + return { + pageParams: data.pageParams, + pages: data.pages.map(page => ({ + api: page.api, + tuner, + cursor: page.cursor, + slices: tuner.tune(page.feed).map(slice => ({ + _reactKey: slice._reactKey, + rootUri: slice.rootItem.post.uri, + isThread: + slice.items.length > 1 && + slice.items.every( + item => + item.post.author.did === slice.items[0].post.author.did, + ), + items: slice.items + .map((item, i) => { + if ( + AppBskyFeedPost.isRecord(item.post.record) && + AppBskyFeedPost.validateRecord(item.post.record).success + ) { + return { + _reactKey: `${slice._reactKey}-${i}`, + uri: item.post.uri, + post: item.post, + record: item.post.record, + reason: + i === 0 && slice.source ? slice.source : item.reason, + } + } + return undefined + }) + .filter(Boolean) as FeedPostSliceItem[], + })), + })), + } + }, + [feedTuners, params?.disableTuner], + ), + }) +} + +export async function pollLatest(page: FeedPage | undefined) { + if (!page) { + return false + } + + logger.debug('usePostFeedQuery: pollLatest') + const post = await page.api.peekLatest() + if (post) { + const slices = page.tuner.tune([post], { + dryRun: true, + maintainOrder: true, + }) + if (slices[0]) { + return true + } + } + + return false +} + +function createApi( + feedDesc: FeedDescriptor, + params: FeedParams, + feedTuners: FeedTunerFn[], +) { + if (feedDesc === 'home') { + return new MergeFeedAPI(params, feedTuners) + } else if (feedDesc === 'following') { + return new FollowingFeedAPI() + } else if (feedDesc.startsWith('author')) { + const [_, actor, filter] = feedDesc.split('|') + return new AuthorFeedAPI({actor, filter}) + } else if (feedDesc.startsWith('likes')) { + const [_, actor] = feedDesc.split('|') + return new LikesFeedAPI({actor}) + } else if (feedDesc.startsWith('feedgen')) { + const [_, feed] = feedDesc.split('|') + return new CustomFeedAPI({feed}) + } else if (feedDesc.startsWith('list')) { + const [_, list] = feedDesc.split('|') + return new ListFeedAPI({list}) + } else { + // shouldnt happen + return new FollowingFeedAPI() + } +} + +/** + * This helper is used by the post-thread placeholder function to + * find a post in the query-data cache + */ +export function findPostInQueryData( + queryClient: QueryClient, + uri: string, +): AppBskyFeedDefs.PostView | undefined { + const generator = findAllPostsInQueryData(queryClient, uri) + const result = generator.next() + if (result.done) { + return undefined + } else { + return result.value + } +} + +export function* findAllPostsInQueryData( + queryClient: QueryClient, + uri: string, +): Generator<AppBskyFeedDefs.PostView, void> { + const queryDatas = queryClient.getQueriesData< + InfiniteData<FeedPageUnselected> + >({ + queryKey: ['post-feed'], + }) + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData?.pages) { + continue + } + for (const page of queryData?.pages) { + for (const item of page.feed) { + if (item.post.uri === uri) { + yield item.post + } + if ( + AppBskyFeedDefs.isPostView(item.reply?.parent) && + item.reply?.parent?.uri === uri + ) { + yield item.reply.parent + } + if ( + AppBskyFeedDefs.isPostView(item.reply?.root) && + item.reply?.root?.uri === uri + ) { + yield item.reply.root + } + } + } + } +} + +function assertSomePostsPassModeration(feed: AppBskyFeedDefs.FeedViewPost[]) { + // assume false + let somePostsPassModeration = false + + for (const item of feed) { + const moderationOpts = getModerationOpts({ + userDid: '', + preferences: DEFAULT_LOGGED_OUT_PREFERENCES, + }) + const moderation = moderatePost(item.post, moderationOpts) + + if (!moderation.content.filter) { + // we have a sfw post + somePostsPassModeration = true + } + } + + if (!somePostsPassModeration) { + throw new Error(KnownError.FeedNSFPublic) + } +} diff --git a/src/state/queries/post-liked-by.ts b/src/state/queries/post-liked-by.ts new file mode 100644 index 000000000..2cde07f28 --- /dev/null +++ b/src/state/queries/post-liked-by.ts @@ -0,0 +1,61 @@ +import {AppBskyActorDefs, AppBskyFeedGetLikes} from '@atproto/api' +import { + useInfiniteQuery, + InfiniteData, + QueryClient, + QueryKey, +} from '@tanstack/react-query' + +import {getAgent} from '#/state/session' + +const PAGE_SIZE = 30 +type RQPageParam = string | undefined + +// TODO refactor invalidate on mutate? +export const RQKEY = (resolvedUri: string) => ['post-liked-by', resolvedUri] + +export function usePostLikedByQuery(resolvedUri: string | undefined) { + return useInfiniteQuery< + AppBskyFeedGetLikes.OutputSchema, + Error, + InfiniteData<AppBskyFeedGetLikes.OutputSchema>, + QueryKey, + RQPageParam + >({ + queryKey: RQKEY(resolvedUri || ''), + async queryFn({pageParam}: {pageParam: RQPageParam}) { + const res = await getAgent().getLikes({ + uri: resolvedUri || '', + limit: PAGE_SIZE, + cursor: pageParam, + }) + return res.data + }, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + enabled: !!resolvedUri, + }) +} + +export function* findAllProfilesInQueryData( + queryClient: QueryClient, + did: string, +): Generator<AppBskyActorDefs.ProfileView, void> { + const queryDatas = queryClient.getQueriesData< + InfiniteData<AppBskyFeedGetLikes.OutputSchema> + >({ + queryKey: ['post-liked-by'], + }) + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData?.pages) { + continue + } + for (const page of queryData?.pages) { + for (const like of page.likes) { + if (like.actor.did === did) { + yield like.actor + } + } + } + } +} diff --git a/src/state/queries/post-reposted-by.ts b/src/state/queries/post-reposted-by.ts new file mode 100644 index 000000000..db5fa6514 --- /dev/null +++ b/src/state/queries/post-reposted-by.ts @@ -0,0 +1,61 @@ +import {AppBskyActorDefs, AppBskyFeedGetRepostedBy} from '@atproto/api' +import { + useInfiniteQuery, + InfiniteData, + QueryClient, + QueryKey, +} from '@tanstack/react-query' + +import {getAgent} from '#/state/session' + +const PAGE_SIZE = 30 +type RQPageParam = string | undefined + +// TODO refactor invalidate on mutate? +export const RQKEY = (resolvedUri: string) => ['post-reposted-by', resolvedUri] + +export function usePostRepostedByQuery(resolvedUri: string | undefined) { + return useInfiniteQuery< + AppBskyFeedGetRepostedBy.OutputSchema, + Error, + InfiniteData<AppBskyFeedGetRepostedBy.OutputSchema>, + QueryKey, + RQPageParam + >({ + queryKey: RQKEY(resolvedUri || ''), + async queryFn({pageParam}: {pageParam: RQPageParam}) { + const res = await getAgent().getRepostedBy({ + uri: resolvedUri || '', + limit: PAGE_SIZE, + cursor: pageParam, + }) + return res.data + }, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + enabled: !!resolvedUri, + }) +} + +export function* findAllProfilesInQueryData( + queryClient: QueryClient, + did: string, +): Generator<AppBskyActorDefs.ProfileView, void> { + const queryDatas = queryClient.getQueriesData< + InfiniteData<AppBskyFeedGetRepostedBy.OutputSchema> + >({ + queryKey: ['post-reposted-by'], + }) + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData?.pages) { + continue + } + for (const page of queryData?.pages) { + for (const repostedBy of page.repostedBy) { + if (repostedBy.did === did) { + yield repostedBy + } + } + } + } +} diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts new file mode 100644 index 000000000..cde45723a --- /dev/null +++ b/src/state/queries/post-thread.ts @@ -0,0 +1,307 @@ +import { + AppBskyFeedDefs, + AppBskyFeedPost, + AppBskyFeedGetPostThread, +} from '@atproto/api' +import {useQuery, useQueryClient, QueryClient} from '@tanstack/react-query' + +import {getAgent} from '#/state/session' +import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' +import {findPostInQueryData as findPostInFeedQueryData} from './post-feed' +import {findPostInQueryData as findPostInNotifsQueryData} from './notifications/feed' +import {precacheThreadPosts as precacheResolvedUris} from './resolve-uri' + +export const RQKEY = (uri: string) => ['post-thread', uri] +type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread'] + +export interface ThreadCtx { + depth: number + isHighlightedPost?: boolean + hasMore?: boolean + showChildReplyLine?: boolean + showParentReplyLine?: boolean + isParentLoading?: boolean + isChildLoading?: boolean +} + +export type ThreadPost = { + type: 'post' + _reactKey: string + uri: string + post: AppBskyFeedDefs.PostView + record: AppBskyFeedPost.Record + parent?: ThreadNode + replies?: ThreadNode[] + viewer?: AppBskyFeedDefs.ViewerThreadState + ctx: ThreadCtx +} + +export type ThreadNotFound = { + type: 'not-found' + _reactKey: string + uri: string + ctx: ThreadCtx +} + +export type ThreadBlocked = { + type: 'blocked' + _reactKey: string + uri: string + ctx: ThreadCtx +} + +export type ThreadUnknown = { + type: 'unknown' + uri: string +} + +export type ThreadNode = + | ThreadPost + | ThreadNotFound + | ThreadBlocked + | ThreadUnknown + +export function usePostThreadQuery(uri: string | undefined) { + const queryClient = useQueryClient() + return useQuery<ThreadNode, Error>({ + queryKey: RQKEY(uri || ''), + async queryFn() { + const res = await getAgent().getPostThread({uri: uri!}) + if (res.success) { + const nodes = responseToThreadNodes(res.data.thread) + precacheResolvedUris(queryClient, nodes) // precache the handle->did resolution + return nodes + } + return {type: 'unknown', uri: uri!} + }, + enabled: !!uri, + placeholderData: () => { + if (!uri) { + return undefined + } + { + const item = findPostInQueryData(queryClient, uri) + if (item) { + return threadNodeToPlaceholderThread(item) + } + } + { + const item = findPostInFeedQueryData(queryClient, uri) + if (item) { + return postViewToPlaceholderThread(item) + } + } + { + const item = findPostInNotifsQueryData(queryClient, uri) + if (item) { + return postViewToPlaceholderThread(item) + } + } + return undefined + }, + }) +} + +export function sortThread( + node: ThreadNode, + opts: UsePreferencesQueryResponse['threadViewPrefs'], +): ThreadNode { + if (node.type !== 'post') { + return node + } + if (node.replies) { + node.replies.sort((a: ThreadNode, b: ThreadNode) => { + if (a.type !== 'post') { + return 1 + } + if (b.type !== 'post') { + return -1 + } + + const aIsByOp = a.post.author.did === node.post?.author.did + const bIsByOp = b.post.author.did === node.post?.author.did + if (aIsByOp && bIsByOp) { + return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest + } else if (aIsByOp) { + return -1 // op's own reply + } else if (bIsByOp) { + return 1 // op's own reply + } + if (opts.prioritizeFollowedUsers) { + const af = a.post.author.viewer?.following + const bf = b.post.author.viewer?.following + if (af && !bf) { + return -1 + } else if (!af && bf) { + return 1 + } + } + if (opts.sort === 'oldest') { + return a.post.indexedAt.localeCompare(b.post.indexedAt) + } else if (opts.sort === 'newest') { + return b.post.indexedAt.localeCompare(a.post.indexedAt) + } else if (opts.sort === 'most-likes') { + if (a.post.likeCount === b.post.likeCount) { + return b.post.indexedAt.localeCompare(a.post.indexedAt) // newest + } else { + return (b.post.likeCount || 0) - (a.post.likeCount || 0) // most likes + } + } else if (opts.sort === 'random') { + return 0.5 - Math.random() // this is vaguely criminal but we can get away with it + } + return b.post.indexedAt.localeCompare(a.post.indexedAt) + }) + node.replies.forEach(reply => sortThread(reply, opts)) + } + return node +} + +// internal methods +// = + +function responseToThreadNodes( + node: ThreadViewNode, + depth = 0, + direction: 'up' | 'down' | 'start' = 'start', +): ThreadNode { + if ( + AppBskyFeedDefs.isThreadViewPost(node) && + AppBskyFeedPost.isRecord(node.post.record) && + AppBskyFeedPost.validateRecord(node.post.record).success + ) { + return { + type: 'post', + _reactKey: node.post.uri, + uri: node.post.uri, + post: node.post, + record: node.post.record, + parent: + node.parent && direction !== 'down' + ? responseToThreadNodes(node.parent, depth - 1, 'up') + : undefined, + replies: + node.replies?.length && direction !== 'up' + ? node.replies + .map(reply => responseToThreadNodes(reply, depth + 1, 'down')) + // do not show blocked posts in replies + .filter(node => node.type !== 'blocked') + : undefined, + viewer: node.viewer, + ctx: { + depth, + isHighlightedPost: depth === 0, + hasMore: + direction === 'down' && !node.replies?.length && !!node.replyCount, + showChildReplyLine: + direction === 'up' || + (direction === 'down' && !!node.replies?.length), + showParentReplyLine: + (direction === 'up' && !!node.parent) || + (direction === 'down' && depth !== 1), + }, + } + } else if (AppBskyFeedDefs.isBlockedPost(node)) { + return {type: 'blocked', _reactKey: node.uri, uri: node.uri, ctx: {depth}} + } else if (AppBskyFeedDefs.isNotFoundPost(node)) { + return {type: 'not-found', _reactKey: node.uri, uri: node.uri, ctx: {depth}} + } else { + return {type: 'unknown', uri: ''} + } +} + +function findPostInQueryData( + queryClient: QueryClient, + uri: string, +): ThreadNode | undefined { + const generator = findAllPostsInQueryData(queryClient, uri) + const result = generator.next() + if (result.done) { + return undefined + } else { + return result.value + } +} + +export function* findAllPostsInQueryData( + queryClient: QueryClient, + uri: string, +): Generator<ThreadNode, void> { + const queryDatas = queryClient.getQueriesData<ThreadNode>({ + queryKey: ['post-thread'], + }) + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData) { + continue + } + for (const item of traverseThread(queryData)) { + if (item.uri === uri) { + yield item + } + } + } +} + +function* traverseThread(node: ThreadNode): Generator<ThreadNode, void> { + if (node.type === 'post') { + if (node.parent) { + yield* traverseThread(node.parent) + } + yield node + if (node.replies?.length) { + for (const reply of node.replies) { + yield* traverseThread(reply) + } + } + } +} + +function threadNodeToPlaceholderThread( + node: ThreadNode, +): ThreadNode | undefined { + if (node.type !== 'post') { + return undefined + } + return { + type: node.type, + _reactKey: node._reactKey, + uri: node.uri, + post: node.post, + record: node.record, + parent: undefined, + replies: undefined, + viewer: node.viewer, + ctx: { + depth: 0, + isHighlightedPost: true, + hasMore: false, + showChildReplyLine: false, + showParentReplyLine: false, + isParentLoading: !!node.record.reply, + isChildLoading: !!node.post.replyCount, + }, + } +} + +function postViewToPlaceholderThread( + post: AppBskyFeedDefs.PostView, +): ThreadNode { + return { + type: 'post', + _reactKey: post.uri, + uri: post.uri, + post: post, + record: post.record as AppBskyFeedPost.Record, // validated in notifs + parent: undefined, + replies: undefined, + viewer: post.viewer, + ctx: { + depth: 0, + isHighlightedPost: true, + hasMore: false, + showChildReplyLine: false, + showParentReplyLine: false, + isParentLoading: !!(post.record as AppBskyFeedPost.Record).reply, + isChildLoading: !!post.replyCount, + }, + } +} diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts new file mode 100644 index 000000000..b31696446 --- /dev/null +++ b/src/state/queries/post.ts @@ -0,0 +1,178 @@ +import React from 'react' +import {AppBskyFeedDefs, AtUri} from '@atproto/api' +import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' + +import {getAgent} from '#/state/session' +import {updatePostShadow} from '#/state/cache/post-shadow' + +export const RQKEY = (postUri: string) => ['post', postUri] + +export function usePostQuery(uri: string | undefined) { + return useQuery<AppBskyFeedDefs.PostView>({ + queryKey: RQKEY(uri || ''), + async queryFn() { + const res = await getAgent().getPosts({uris: [uri!]}) + if (res.success && res.data.posts[0]) { + return res.data.posts[0] + } + + throw new Error('No data') + }, + enabled: !!uri, + }) +} + +export function useGetPost() { + const queryClient = useQueryClient() + return React.useCallback( + async ({uri}: {uri: string}) => { + return queryClient.fetchQuery({ + queryKey: RQKEY(uri || ''), + async queryFn() { + const urip = new AtUri(uri) + + if (!urip.host.startsWith('did:')) { + const res = await getAgent().resolveHandle({ + handle: urip.host, + }) + urip.host = res.data.did + } + + const res = await getAgent().getPosts({ + uris: [urip.toString()!], + }) + + if (res.success && res.data.posts[0]) { + return res.data.posts[0] + } + + throw new Error('useGetPost: post not found') + }, + }) + }, + [queryClient], + ) +} + +export function usePostLikeMutation() { + return useMutation< + {uri: string}, // responds with the uri of the like + Error, + {uri: string; cid: string; likeCount: number} // the post's uri, cid, and likes + >({ + mutationFn: post => getAgent().like(post.uri, post.cid), + onMutate(variables) { + // optimistically update the post-shadow + updatePostShadow(variables.uri, { + likeCount: variables.likeCount + 1, + likeUri: 'pending', + }) + }, + onSuccess(data, variables) { + // finalize the post-shadow with the like URI + updatePostShadow(variables.uri, { + likeUri: data.uri, + }) + }, + onError(error, variables) { + // revert the optimistic update + updatePostShadow(variables.uri, { + likeCount: variables.likeCount, + likeUri: undefined, + }) + }, + }) +} + +export function usePostUnlikeMutation() { + return useMutation< + void, + Error, + {postUri: string; likeUri: string; likeCount: number} + >({ + mutationFn: async ({likeUri}) => { + await getAgent().deleteLike(likeUri) + }, + onMutate(variables) { + // optimistically update the post-shadow + updatePostShadow(variables.postUri, { + likeCount: variables.likeCount - 1, + likeUri: undefined, + }) + }, + onError(error, variables) { + // revert the optimistic update + updatePostShadow(variables.postUri, { + likeCount: variables.likeCount, + likeUri: variables.likeUri, + }) + }, + }) +} + +export function usePostRepostMutation() { + return useMutation< + {uri: string}, // responds with the uri of the repost + Error, + {uri: string; cid: string; repostCount: number} // the post's uri, cid, and reposts + >({ + mutationFn: post => getAgent().repost(post.uri, post.cid), + onMutate(variables) { + // optimistically update the post-shadow + updatePostShadow(variables.uri, { + repostCount: variables.repostCount + 1, + repostUri: 'pending', + }) + }, + onSuccess(data, variables) { + // finalize the post-shadow with the repost URI + updatePostShadow(variables.uri, { + repostUri: data.uri, + }) + }, + onError(error, variables) { + // revert the optimistic update + updatePostShadow(variables.uri, { + repostCount: variables.repostCount, + repostUri: undefined, + }) + }, + }) +} + +export function usePostUnrepostMutation() { + return useMutation< + void, + Error, + {postUri: string; repostUri: string; repostCount: number} + >({ + mutationFn: async ({repostUri}) => { + await getAgent().deleteRepost(repostUri) + }, + onMutate(variables) { + // optimistically update the post-shadow + updatePostShadow(variables.postUri, { + repostCount: variables.repostCount - 1, + repostUri: undefined, + }) + }, + onError(error, variables) { + // revert the optimistic update + updatePostShadow(variables.postUri, { + repostCount: variables.repostCount, + repostUri: variables.repostUri, + }) + }, + }) +} + +export function usePostDeleteMutation() { + return useMutation<void, Error, {uri: string}>({ + mutationFn: async ({uri}) => { + await getAgent().deletePost(uri) + }, + onSuccess(data, variables) { + updatePostShadow(variables.uri, {isDeleted: true}) + }, + }) +} diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts new file mode 100644 index 000000000..b7f9206e8 --- /dev/null +++ b/src/state/queries/preferences/const.ts @@ -0,0 +1,51 @@ +import { + UsePreferencesQueryResponse, + ThreadViewPreferences, +} from '#/state/queries/preferences/types' +import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation' + +export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] = + { + hideReplies: false, + hideRepliesByUnfollowed: false, + hideRepliesByLikeCount: 0, + hideReposts: false, + hideQuotePosts: false, + lab_mergeFeedEnabled: false, // experimental + } + +export const DEFAULT_THREAD_VIEW_PREFS: ThreadViewPreferences = { + sort: 'newest', + prioritizeFollowedUsers: true, + lab_treeViewEnabled: false, +} + +const DEFAULT_PROD_FEED_PREFIX = (rkey: string) => + `at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/${rkey}` +export const DEFAULT_PROD_FEEDS = { + pinned: [DEFAULT_PROD_FEED_PREFIX('whats-hot')], + saved: [DEFAULT_PROD_FEED_PREFIX('whats-hot')], +} + +export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = { + birthDate: new Date('2022-11-17'), // TODO(pwi) + adultContentEnabled: false, + feeds: { + saved: [], + pinned: [], + unpinned: [], + }, + // labels are undefined until set by user + contentLabels: { + nsfw: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.nsfw, + nudity: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.nudity, + suggestive: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.suggestive, + gore: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.gore, + hate: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.hate, + spam: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.spam, + impersonation: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES.impersonation, + }, + feedViewPrefs: DEFAULT_HOME_FEED_PREFS, + threadViewPrefs: DEFAULT_THREAD_VIEW_PREFS, + userAge: 13, // TODO(pwi) +} diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts new file mode 100644 index 000000000..afdec267d --- /dev/null +++ b/src/state/queries/preferences/index.ts @@ -0,0 +1,271 @@ +import {useMemo} from 'react' +import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' +import {LabelPreference, BskyFeedViewPreference} from '@atproto/api' + +import {track} from '#/lib/analytics/analytics' +import {getAge} from '#/lib/strings/time' +import {useSession, getAgent} from '#/state/session' +import {DEFAULT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation' +import { + ConfigurableLabelGroup, + UsePreferencesQueryResponse, + ThreadViewPreferences, +} from '#/state/queries/preferences/types' +import {temp__migrateLabelPref} from '#/state/queries/preferences/util' +import { + DEFAULT_HOME_FEED_PREFS, + DEFAULT_THREAD_VIEW_PREFS, + DEFAULT_LOGGED_OUT_PREFERENCES, +} from '#/state/queries/preferences/const' +import {getModerationOpts} from '#/state/queries/preferences/moderation' +import {STALE} from '#/state/queries' + +export * from '#/state/queries/preferences/types' +export * from '#/state/queries/preferences/moderation' +export * from '#/state/queries/preferences/const' + +export const preferencesQueryKey = ['getPreferences'] + +export function usePreferencesQuery() { + return useQuery({ + staleTime: STALE.MINUTES.ONE, + queryKey: preferencesQueryKey, + queryFn: async () => { + const agent = getAgent() + + if (agent.session?.did === undefined) { + return DEFAULT_LOGGED_OUT_PREFERENCES + } else { + const res = await agent.getPreferences() + const preferences: UsePreferencesQueryResponse = { + ...res, + feeds: { + saved: res.feeds?.saved || [], + pinned: res.feeds?.pinned || [], + unpinned: + res.feeds.saved?.filter(f => { + return !res.feeds.pinned?.includes(f) + }) || [], + }, + // labels are undefined until set by user + contentLabels: { + nsfw: temp__migrateLabelPref( + res.contentLabels?.nsfw || DEFAULT_LABEL_PREFERENCES.nsfw, + ), + nudity: temp__migrateLabelPref( + res.contentLabels?.nudity || DEFAULT_LABEL_PREFERENCES.nudity, + ), + suggestive: temp__migrateLabelPref( + res.contentLabels?.suggestive || + DEFAULT_LABEL_PREFERENCES.suggestive, + ), + gore: temp__migrateLabelPref( + res.contentLabels?.gore || DEFAULT_LABEL_PREFERENCES.gore, + ), + hate: temp__migrateLabelPref( + res.contentLabels?.hate || DEFAULT_LABEL_PREFERENCES.hate, + ), + spam: temp__migrateLabelPref( + res.contentLabels?.spam || DEFAULT_LABEL_PREFERENCES.spam, + ), + impersonation: temp__migrateLabelPref( + res.contentLabels?.impersonation || + DEFAULT_LABEL_PREFERENCES.impersonation, + ), + }, + feedViewPrefs: { + ...DEFAULT_HOME_FEED_PREFS, + ...(res.feedViewPrefs.home || {}), + }, + threadViewPrefs: { + ...DEFAULT_THREAD_VIEW_PREFS, + ...(res.threadViewPrefs ?? {}), + }, + userAge: res.birthDate ? getAge(res.birthDate) : undefined, + } + return preferences + } + }, + }) +} + +export function useModerationOpts() { + const {currentAccount} = useSession() + const prefs = usePreferencesQuery() + const opts = useMemo(() => { + if (!prefs.data) { + return + } + return getModerationOpts({ + userDid: currentAccount?.did || '', + preferences: prefs.data, + }) + }, [currentAccount?.did, prefs.data]) + return opts +} + +export function useClearPreferencesMutation() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async () => { + await getAgent().app.bsky.actor.putPreferences({preferences: []}) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function usePreferencesSetContentLabelMutation() { + const queryClient = useQueryClient() + + return useMutation< + void, + unknown, + {labelGroup: ConfigurableLabelGroup; visibility: LabelPreference} + >({ + mutationFn: async ({labelGroup, visibility}) => { + await getAgent().setContentLabelPref(labelGroup, visibility) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function usePreferencesSetAdultContentMutation() { + const queryClient = useQueryClient() + + return useMutation<void, unknown, {enabled: boolean}>({ + mutationFn: async ({enabled}) => { + await getAgent().setAdultContentEnabled(enabled) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function usePreferencesSetBirthDateMutation() { + const queryClient = useQueryClient() + + return useMutation<void, unknown, {birthDate: Date}>({ + mutationFn: async ({birthDate}: {birthDate: Date}) => { + await getAgent().setPersonalDetails({birthDate}) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function useSetFeedViewPreferencesMutation() { + const queryClient = useQueryClient() + + return useMutation<void, unknown, Partial<BskyFeedViewPreference>>({ + mutationFn: async prefs => { + await getAgent().setFeedViewPrefs('home', prefs) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function useSetThreadViewPreferencesMutation() { + const queryClient = useQueryClient() + + return useMutation<void, unknown, Partial<ThreadViewPreferences>>({ + mutationFn: async prefs => { + await getAgent().setThreadViewPrefs(prefs) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function useSetSaveFeedsMutation() { + const queryClient = useQueryClient() + + return useMutation< + void, + unknown, + Pick<UsePreferencesQueryResponse['feeds'], 'saved' | 'pinned'> + >({ + mutationFn: async ({saved, pinned}) => { + await getAgent().setSavedFeeds(saved, pinned) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function useSaveFeedMutation() { + const queryClient = useQueryClient() + + return useMutation<void, unknown, {uri: string}>({ + mutationFn: async ({uri}) => { + await getAgent().addSavedFeed(uri) + track('CustomFeed:Save') + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function useRemoveFeedMutation() { + const queryClient = useQueryClient() + + return useMutation<void, unknown, {uri: string}>({ + mutationFn: async ({uri}) => { + await getAgent().removeSavedFeed(uri) + track('CustomFeed:Unsave') + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function usePinFeedMutation() { + const queryClient = useQueryClient() + + return useMutation<void, unknown, {uri: string}>({ + mutationFn: async ({uri}) => { + await getAgent().addPinnedFeed(uri) + track('CustomFeed:Pin', {uri}) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} + +export function useUnpinFeedMutation() { + const queryClient = useQueryClient() + + return useMutation<void, unknown, {uri: string}>({ + mutationFn: async ({uri}) => { + await getAgent().removePinnedFeed(uri) + track('CustomFeed:Unpin', {uri}) + // triggers a refetch + await queryClient.invalidateQueries({ + queryKey: preferencesQueryKey, + }) + }, + }) +} diff --git a/src/state/queries/preferences/moderation.ts b/src/state/queries/preferences/moderation.ts new file mode 100644 index 000000000..cdae52937 --- /dev/null +++ b/src/state/queries/preferences/moderation.ts @@ -0,0 +1,181 @@ +import { + LabelPreference, + ComAtprotoLabelDefs, + ModerationOpts, +} from '@atproto/api' + +import { + LabelGroup, + ConfigurableLabelGroup, + UsePreferencesQueryResponse, +} from '#/state/queries/preferences/types' + +export type Label = ComAtprotoLabelDefs.Label + +export type LabelGroupConfig = { + id: LabelGroup + title: string + isAdultImagery?: boolean + subtitle?: string + warning: string + values: string[] +} + +export const DEFAULT_LABEL_PREFERENCES: Record< + ConfigurableLabelGroup, + LabelPreference +> = { + nsfw: 'hide', + nudity: 'warn', + suggestive: 'warn', + gore: 'warn', + hate: 'hide', + spam: 'hide', + impersonation: 'hide', +} + +/** + * More strict than our default settings for logged in users. + * + * TODO(pwi) + */ +export const DEFAULT_LOGGED_OUT_LABEL_PREFERENCES: Record< + ConfigurableLabelGroup, + LabelPreference +> = { + nsfw: 'hide', + nudity: 'hide', + suggestive: 'hide', + gore: 'hide', + hate: 'hide', + spam: 'hide', + impersonation: 'hide', +} + +export const ILLEGAL_LABEL_GROUP: LabelGroupConfig = { + id: 'illegal', + title: 'Illegal Content', + warning: 'Illegal Content', + values: ['csam', 'dmca-violation', 'nudity-nonconsensual'], +} + +export const ALWAYS_FILTER_LABEL_GROUP: LabelGroupConfig = { + id: 'always-filter', + title: 'Content Warning', + warning: 'Content Warning', + values: ['!filter'], +} + +export const ALWAYS_WARN_LABEL_GROUP: LabelGroupConfig = { + id: 'always-warn', + title: 'Content Warning', + warning: 'Content Warning', + values: ['!warn', 'account-security'], +} + +export const UNKNOWN_LABEL_GROUP: LabelGroupConfig = { + id: 'unknown', + title: 'Unknown Label', + warning: 'Content Warning', + values: [], +} + +export const CONFIGURABLE_LABEL_GROUPS: Record< + ConfigurableLabelGroup, + LabelGroupConfig +> = { + nsfw: { + id: 'nsfw', + title: 'Explicit Sexual Images', + subtitle: 'i.e. pornography', + warning: 'Sexually Explicit', + values: ['porn', 'nsfl'], + isAdultImagery: true, + }, + nudity: { + id: 'nudity', + title: 'Other Nudity', + subtitle: 'Including non-sexual and artistic', + warning: 'Nudity', + values: ['nudity'], + isAdultImagery: true, + }, + suggestive: { + id: 'suggestive', + title: 'Sexually Suggestive', + subtitle: 'Does not include nudity', + warning: 'Sexually Suggestive', + values: ['sexual'], + isAdultImagery: true, + }, + gore: { + id: 'gore', + title: 'Violent / Bloody', + subtitle: 'Gore, self-harm, torture', + warning: 'Violence', + values: ['gore', 'self-harm', 'torture', 'nsfl', 'corpse'], + isAdultImagery: true, + }, + hate: { + id: 'hate', + title: 'Hate Group Iconography', + subtitle: 'Images of terror groups, articles covering events, etc.', + warning: 'Hate Groups', + values: ['icon-kkk', 'icon-nazi', 'icon-intolerant', 'behavior-intolerant'], + }, + spam: { + id: 'spam', + title: 'Spam', + subtitle: 'Excessive unwanted interactions', + warning: 'Spam', + values: ['spam'], + }, + impersonation: { + id: 'impersonation', + title: 'Impersonation', + subtitle: 'Accounts falsely claiming to be people or orgs', + warning: 'Impersonation', + values: ['impersonation'], + }, +} + +export function getModerationOpts({ + userDid, + preferences, +}: { + userDid: string + preferences: UsePreferencesQueryResponse +}): ModerationOpts { + return { + userDid: userDid, + adultContentEnabled: preferences.adultContentEnabled, + labels: { + porn: preferences.contentLabels.nsfw, + sexual: preferences.contentLabels.suggestive, + nudity: preferences.contentLabels.nudity, + nsfl: preferences.contentLabels.gore, + corpse: preferences.contentLabels.gore, + gore: preferences.contentLabels.gore, + torture: preferences.contentLabels.gore, + 'self-harm': preferences.contentLabels.gore, + 'intolerant-race': preferences.contentLabels.hate, + 'intolerant-gender': preferences.contentLabels.hate, + 'intolerant-sexual-orientation': preferences.contentLabels.hate, + 'intolerant-religion': preferences.contentLabels.hate, + intolerant: preferences.contentLabels.hate, + 'icon-intolerant': preferences.contentLabels.hate, + spam: preferences.contentLabels.spam, + impersonation: preferences.contentLabels.impersonation, + scam: 'warn', + }, + labelers: [ + { + labeler: { + did: '', + displayName: 'Bluesky Social', + }, + labels: {}, + }, + ], + } +} diff --git a/src/state/queries/preferences/types.ts b/src/state/queries/preferences/types.ts new file mode 100644 index 000000000..5fca8d558 --- /dev/null +++ b/src/state/queries/preferences/types.ts @@ -0,0 +1,52 @@ +import { + BskyPreferences, + LabelPreference, + BskyThreadViewPreference, + BskyFeedViewPreference, +} from '@atproto/api' + +export type ConfigurableLabelGroup = + | 'nsfw' + | 'nudity' + | 'suggestive' + | 'gore' + | 'hate' + | 'spam' + | 'impersonation' +export type LabelGroup = + | ConfigurableLabelGroup + | 'illegal' + | 'always-filter' + | 'always-warn' + | 'unknown' + +export type UsePreferencesQueryResponse = Omit< + BskyPreferences, + 'contentLabels' | 'feedViewPrefs' | 'feeds' +> & { + /* + * Content labels previously included 'show', which has been deprecated in + * favor of 'ignore'. The API can return legacy data from the database, and + * we clean up the data in `usePreferencesQuery`. + */ + contentLabels: Record<ConfigurableLabelGroup, LabelPreference> + feedViewPrefs: BskyFeedViewPreference & { + lab_mergeFeedEnabled?: boolean + } + /** + * User thread-view prefs, including newer fields that may not be typed yet. + */ + threadViewPrefs: ThreadViewPreferences + userAge: number | undefined + feeds: Required<BskyPreferences['feeds']> & { + unpinned: string[] + } +} + +export type ThreadViewPreferences = Pick< + BskyThreadViewPreference, + 'prioritizeFollowedUsers' +> & { + sort: 'oldest' | 'newest' | 'most-likes' | 'random' | string + lab_treeViewEnabled?: boolean +} diff --git a/src/state/queries/preferences/util.ts b/src/state/queries/preferences/util.ts new file mode 100644 index 000000000..7b8160c28 --- /dev/null +++ b/src/state/queries/preferences/util.ts @@ -0,0 +1,16 @@ +import {LabelPreference} from '@atproto/api' + +/** + * Content labels previously included 'show', which has been deprecated in + * favor of 'ignore'. The API can return legacy data from the database, and + * we clean up the data in `usePreferencesQuery`. + * + * @deprecated + */ +export function temp__migrateLabelPref( + pref: LabelPreference | 'show', +): LabelPreference { + // @ts-ignore + if (pref === 'show') return 'ignore' + return pref +} diff --git a/src/state/queries/profile-extra-info.ts b/src/state/queries/profile-extra-info.ts new file mode 100644 index 000000000..8fc32c33e --- /dev/null +++ b/src/state/queries/profile-extra-info.ts @@ -0,0 +1,34 @@ +import {useQuery} from '@tanstack/react-query' + +import {getAgent} from '#/state/session' +import {STALE} from '#/state/queries' + +// TODO refactor invalidate on mutate? +export const RQKEY = (did: string) => ['profile-extra-info', did] + +/** + * Fetches some additional information for the profile screen which + * is not available in the API's ProfileView + */ +export function useProfileExtraInfoQuery(did: string) { + return useQuery({ + staleTime: STALE.MINUTES.ONE, + queryKey: RQKEY(did), + async queryFn() { + const [listsRes, feedsRes] = await Promise.all([ + getAgent().app.bsky.graph.getLists({ + actor: did, + limit: 1, + }), + getAgent().app.bsky.feed.getActorFeeds({ + actor: did, + limit: 1, + }), + ]) + return { + hasLists: listsRes.data.lists.length > 0, + hasFeedgens: feedsRes.data.feeds.length > 0, + } + }, + }) +} diff --git a/src/state/queries/profile-feedgens.ts b/src/state/queries/profile-feedgens.ts new file mode 100644 index 000000000..7d33eb9c8 --- /dev/null +++ b/src/state/queries/profile-feedgens.ts @@ -0,0 +1,37 @@ +import {AppBskyFeedGetActorFeeds} from '@atproto/api' +import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query' + +import {getAgent} from '#/state/session' + +const PAGE_SIZE = 30 +type RQPageParam = string | undefined + +// TODO refactor invalidate on mutate? +export const RQKEY = (did: string) => ['profile-feedgens', did] + +export function useProfileFeedgensQuery( + did: string, + opts?: {enabled?: boolean}, +) { + const enabled = opts?.enabled !== false + return useInfiniteQuery< + AppBskyFeedGetActorFeeds.OutputSchema, + Error, + InfiniteData<AppBskyFeedGetActorFeeds.OutputSchema>, + QueryKey, + RQPageParam + >({ + queryKey: RQKEY(did), + async queryFn({pageParam}: {pageParam: RQPageParam}) { + const res = await getAgent().app.bsky.feed.getActorFeeds({ + actor: did, + limit: PAGE_SIZE, + cursor: pageParam, + }) + return res.data + }, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + enabled, + }) +} diff --git a/src/state/queries/profile-followers.ts b/src/state/queries/profile-followers.ts new file mode 100644 index 000000000..fdefc8253 --- /dev/null +++ b/src/state/queries/profile-followers.ts @@ -0,0 +1,60 @@ +import {AppBskyActorDefs, AppBskyGraphGetFollowers} from '@atproto/api' +import { + useInfiniteQuery, + InfiniteData, + QueryClient, + QueryKey, +} from '@tanstack/react-query' + +import {getAgent} from '#/state/session' + +const PAGE_SIZE = 30 +type RQPageParam = string | undefined + +export const RQKEY = (did: string) => ['profile-followers', did] + +export function useProfileFollowersQuery(did: string | undefined) { + return useInfiniteQuery< + AppBskyGraphGetFollowers.OutputSchema, + Error, + InfiniteData<AppBskyGraphGetFollowers.OutputSchema>, + QueryKey, + RQPageParam + >({ + queryKey: RQKEY(did || ''), + async queryFn({pageParam}: {pageParam: RQPageParam}) { + const res = await getAgent().app.bsky.graph.getFollowers({ + actor: did || '', + limit: PAGE_SIZE, + cursor: pageParam, + }) + return res.data + }, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + enabled: !!did, + }) +} + +export function* findAllProfilesInQueryData( + queryClient: QueryClient, + did: string, +): Generator<AppBskyActorDefs.ProfileView, void> { + const queryDatas = queryClient.getQueriesData< + InfiniteData<AppBskyGraphGetFollowers.OutputSchema> + >({ + queryKey: ['profile-followers'], + }) + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData?.pages) { + continue + } + for (const page of queryData?.pages) { + for (const follower of page.followers) { + if (follower.did === did) { + yield follower + } + } + } + } +} diff --git a/src/state/queries/profile-follows.ts b/src/state/queries/profile-follows.ts new file mode 100644 index 000000000..428c8aebd --- /dev/null +++ b/src/state/queries/profile-follows.ts @@ -0,0 +1,63 @@ +import {AppBskyActorDefs, AppBskyGraphGetFollows} from '@atproto/api' +import { + useInfiniteQuery, + InfiniteData, + QueryClient, + QueryKey, +} from '@tanstack/react-query' + +import {getAgent} from '#/state/session' +import {STALE} from '#/state/queries' + +const PAGE_SIZE = 30 +type RQPageParam = string | undefined + +// TODO refactor invalidate on mutate? +export const RQKEY = (did: string) => ['profile-follows', did] + +export function useProfileFollowsQuery(did: string | undefined) { + return useInfiniteQuery< + AppBskyGraphGetFollows.OutputSchema, + Error, + InfiniteData<AppBskyGraphGetFollows.OutputSchema>, + QueryKey, + RQPageParam + >({ + staleTime: STALE.MINUTES.ONE, + queryKey: RQKEY(did || ''), + async queryFn({pageParam}: {pageParam: RQPageParam}) { + const res = await getAgent().app.bsky.graph.getFollows({ + actor: did || '', + limit: PAGE_SIZE, + cursor: pageParam, + }) + return res.data + }, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + enabled: !!did, + }) +} + +export function* findAllProfilesInQueryData( + queryClient: QueryClient, + did: string, +): Generator<AppBskyActorDefs.ProfileView, void> { + const queryDatas = queryClient.getQueriesData< + InfiniteData<AppBskyGraphGetFollows.OutputSchema> + >({ + queryKey: ['profile-follows'], + }) + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData?.pages) { + continue + } + for (const page of queryData?.pages) { + for (const follow of page.follows) { + if (follow.did === did) { + yield follow + } + } + } + } +} diff --git a/src/state/queries/profile-lists.ts b/src/state/queries/profile-lists.ts new file mode 100644 index 000000000..505d33b9f --- /dev/null +++ b/src/state/queries/profile-lists.ts @@ -0,0 +1,32 @@ +import {AppBskyGraphGetLists} from '@atproto/api' +import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query' +import {getAgent} from '#/state/session' + +const PAGE_SIZE = 30 +type RQPageParam = string | undefined + +export const RQKEY = (did: string) => ['profile-lists', did] + +export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) { + const enabled = opts?.enabled !== false + return useInfiniteQuery< + AppBskyGraphGetLists.OutputSchema, + Error, + InfiniteData<AppBskyGraphGetLists.OutputSchema>, + QueryKey, + RQPageParam + >({ + queryKey: RQKEY(did), + async queryFn({pageParam}: {pageParam: RQPageParam}) { + const res = await getAgent().app.bsky.graph.getLists({ + actor: did, + limit: PAGE_SIZE, + cursor: pageParam, + }) + return res.data + }, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + enabled, + }) +} diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts new file mode 100644 index 000000000..9435d7ad5 --- /dev/null +++ b/src/state/queries/profile.ts @@ -0,0 +1,502 @@ +import {useCallback} from 'react' +import { + AtUri, + AppBskyActorDefs, + AppBskyActorProfile, + AppBskyActorGetProfile, +} from '@atproto/api' +import { + useQuery, + useQueryClient, + useMutation, + QueryClient, +} from '@tanstack/react-query' +import {Image as RNImage} from 'react-native-image-crop-picker' +import {useSession, getAgent} from '../session' +import {updateProfileShadow} from '../cache/profile-shadow' +import {uploadBlob} from '#/lib/api' +import {until} from '#/lib/async/until' +import {Shadow} from '#/state/cache/types' +import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' +import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts' +import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts' +import {STALE} from '#/state/queries' + +export const RQKEY = (did: string) => ['profile', did] + +export function useProfileQuery({did}: {did: string | undefined}) { + return useQuery({ + // WARNING + // this staleTime is load-bearing + // if you remove it, the UI infinite-loops + // -prf + staleTime: STALE.MINUTES.FIVE, + queryKey: RQKEY(did || ''), + queryFn: async () => { + const res = await getAgent().getProfile({actor: did || ''}) + return res.data + }, + enabled: !!did, + }) +} + +interface ProfileUpdateParams { + profile: AppBskyActorDefs.ProfileView + updates: AppBskyActorProfile.Record + newUserAvatar: RNImage | undefined | null + newUserBanner: RNImage | undefined | null +} +export function useProfileUpdateMutation() { + const queryClient = useQueryClient() + return useMutation<void, Error, ProfileUpdateParams>({ + mutationFn: async ({profile, updates, newUserAvatar, newUserBanner}) => { + await getAgent().upsertProfile(async existing => { + existing = existing || {} + existing.displayName = updates.displayName + existing.description = updates.description + if (newUserAvatar) { + const res = await uploadBlob( + getAgent(), + newUserAvatar.path, + newUserAvatar.mime, + ) + existing.avatar = res.data.blob + } else if (newUserAvatar === null) { + existing.avatar = undefined + } + if (newUserBanner) { + const res = await uploadBlob( + getAgent(), + newUserBanner.path, + newUserBanner.mime, + ) + existing.banner = res.data.blob + } else if (newUserBanner === null) { + existing.banner = undefined + } + return existing + }) + await whenAppViewReady(profile.did, res => { + if (typeof newUserAvatar !== 'undefined') { + if (newUserAvatar === null && res.data.avatar) { + // url hasnt cleared yet + return false + } else if (res.data.avatar === profile.avatar) { + // url hasnt changed yet + return false + } + } + if (typeof newUserBanner !== 'undefined') { + if (newUserBanner === null && res.data.banner) { + // url hasnt cleared yet + return false + } else if (res.data.banner === profile.banner) { + // url hasnt changed yet + return false + } + } + return ( + res.data.displayName === updates.displayName && + res.data.description === updates.description + ) + }) + }, + onSuccess(data, variables) { + // invalidate cache + queryClient.invalidateQueries({ + queryKey: RQKEY(variables.profile.did), + }) + }, + }) +} + +export function useProfileFollowMutationQueue( + profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>, +) { + const did = profile.did + const initialFollowingUri = profile.viewer?.following + const followMutation = useProfileFollowMutation() + const unfollowMutation = useProfileUnfollowMutation() + + const queueToggle = useToggleMutationQueue({ + initialState: initialFollowingUri, + runMutation: async (prevFollowingUri, shouldFollow) => { + if (shouldFollow) { + const {uri} = await followMutation.mutateAsync({ + did, + skipOptimistic: true, + }) + return uri + } else { + if (prevFollowingUri) { + await unfollowMutation.mutateAsync({ + did, + followUri: prevFollowingUri, + skipOptimistic: true, + }) + } + return undefined + } + }, + onSuccess(finalFollowingUri) { + // finalize + updateProfileShadow(did, { + followingUri: finalFollowingUri, + }) + }, + }) + + const queueFollow = useCallback(() => { + // optimistically update + updateProfileShadow(did, { + followingUri: 'pending', + }) + return queueToggle(true) + }, [did, queueToggle]) + + const queueUnfollow = useCallback(() => { + // optimistically update + updateProfileShadow(did, { + followingUri: undefined, + }) + return queueToggle(false) + }, [did, queueToggle]) + + return [queueFollow, queueUnfollow] +} + +function useProfileFollowMutation() { + return useMutation< + {uri: string; cid: string}, + Error, + {did: string; skipOptimistic?: boolean} + >({ + mutationFn: async ({did}) => { + return await getAgent().follow(did) + }, + onMutate(variables) { + if (!variables.skipOptimistic) { + // optimistically update + updateProfileShadow(variables.did, { + followingUri: 'pending', + }) + } + }, + onSuccess(data, variables) { + if (!variables.skipOptimistic) { + // finalize + updateProfileShadow(variables.did, { + followingUri: data.uri, + }) + } + }, + onError(error, variables) { + if (!variables.skipOptimistic) { + // revert the optimistic update + updateProfileShadow(variables.did, { + followingUri: undefined, + }) + } + }, + }) +} + +function useProfileUnfollowMutation() { + return useMutation< + void, + Error, + {did: string; followUri: string; skipOptimistic?: boolean} + >({ + mutationFn: async ({followUri}) => { + return await getAgent().deleteFollow(followUri) + }, + onMutate(variables) { + if (!variables.skipOptimistic) { + // optimistically update + updateProfileShadow(variables.did, { + followingUri: undefined, + }) + } + }, + onError(error, variables) { + if (!variables.skipOptimistic) { + // revert the optimistic update + updateProfileShadow(variables.did, { + followingUri: variables.followUri, + }) + } + }, + }) +} + +export function useProfileMuteMutationQueue( + profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>, +) { + const did = profile.did + const initialMuted = profile.viewer?.muted + const muteMutation = useProfileMuteMutation() + const unmuteMutation = useProfileUnmuteMutation() + + const queueToggle = useToggleMutationQueue({ + initialState: initialMuted, + runMutation: async (_prevMuted, shouldMute) => { + if (shouldMute) { + await muteMutation.mutateAsync({ + did, + skipOptimistic: true, + }) + return true + } else { + await unmuteMutation.mutateAsync({ + did, + skipOptimistic: true, + }) + return false + } + }, + onSuccess(finalMuted) { + // finalize + updateProfileShadow(did, {muted: finalMuted}) + }, + }) + + const queueMute = useCallback(() => { + // optimistically update + updateProfileShadow(did, { + muted: true, + }) + return queueToggle(true) + }, [did, queueToggle]) + + const queueUnmute = useCallback(() => { + // optimistically update + updateProfileShadow(did, { + muted: false, + }) + return queueToggle(false) + }, [did, queueToggle]) + + return [queueMute, queueUnmute] +} + +function useProfileMuteMutation() { + const queryClient = useQueryClient() + return useMutation<void, Error, {did: string; skipOptimistic?: boolean}>({ + mutationFn: async ({did}) => { + await getAgent().mute(did) + }, + onMutate(variables) { + if (!variables.skipOptimistic) { + // optimistically update + updateProfileShadow(variables.did, { + muted: true, + }) + } + }, + onSuccess() { + queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()}) + }, + onError(error, variables) { + if (!variables.skipOptimistic) { + // revert the optimistic update + updateProfileShadow(variables.did, { + muted: false, + }) + } + }, + }) +} + +function useProfileUnmuteMutation() { + const queryClient = useQueryClient() + return useMutation<void, Error, {did: string; skipOptimistic?: boolean}>({ + mutationFn: async ({did}) => { + await getAgent().unmute(did) + }, + onMutate(variables) { + if (!variables.skipOptimistic) { + // optimistically update + updateProfileShadow(variables.did, { + muted: false, + }) + } + }, + onSuccess() { + queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()}) + }, + onError(error, variables) { + if (!variables.skipOptimistic) { + // revert the optimistic update + updateProfileShadow(variables.did, { + muted: true, + }) + } + }, + }) +} + +export function useProfileBlockMutationQueue( + profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>, +) { + const did = profile.did + const initialBlockingUri = profile.viewer?.blocking + const blockMutation = useProfileBlockMutation() + const unblockMutation = useProfileUnblockMutation() + + const queueToggle = useToggleMutationQueue({ + initialState: initialBlockingUri, + runMutation: async (prevBlockUri, shouldFollow) => { + if (shouldFollow) { + const {uri} = await blockMutation.mutateAsync({ + did, + skipOptimistic: true, + }) + return uri + } else { + if (prevBlockUri) { + await unblockMutation.mutateAsync({ + did, + blockUri: prevBlockUri, + skipOptimistic: true, + }) + } + return undefined + } + }, + onSuccess(finalBlockingUri) { + // finalize + updateProfileShadow(did, { + blockingUri: finalBlockingUri, + }) + }, + }) + + const queueBlock = useCallback(() => { + // optimistically update + updateProfileShadow(did, { + blockingUri: 'pending', + }) + return queueToggle(true) + }, [did, queueToggle]) + + const queueUnblock = useCallback(() => { + // optimistically update + updateProfileShadow(did, { + blockingUri: undefined, + }) + return queueToggle(false) + }, [did, queueToggle]) + + return [queueBlock, queueUnblock] +} + +function useProfileBlockMutation() { + const {currentAccount} = useSession() + const queryClient = useQueryClient() + return useMutation< + {uri: string; cid: string}, + Error, + {did: string; skipOptimistic?: boolean} + >({ + mutationFn: async ({did}) => { + if (!currentAccount) { + throw new Error('Not signed in') + } + return await getAgent().app.bsky.graph.block.create( + {repo: currentAccount.did}, + {subject: did, createdAt: new Date().toISOString()}, + ) + }, + onMutate(variables) { + if (!variables.skipOptimistic) { + // optimistically update + updateProfileShadow(variables.did, { + blockingUri: 'pending', + }) + } + }, + onSuccess(data, variables) { + if (!variables.skipOptimistic) { + // finalize + updateProfileShadow(variables.did, { + blockingUri: data.uri, + }) + } + queryClient.invalidateQueries({queryKey: RQKEY_MY_BLOCKED()}) + }, + onError(error, variables) { + if (!variables.skipOptimistic) { + // revert the optimistic update + updateProfileShadow(variables.did, { + blockingUri: undefined, + }) + } + }, + }) +} + +function useProfileUnblockMutation() { + const {currentAccount} = useSession() + return useMutation< + void, + Error, + {did: string; blockUri: string; skipOptimistic?: boolean} + >({ + mutationFn: async ({blockUri}) => { + if (!currentAccount) { + throw new Error('Not signed in') + } + const {rkey} = new AtUri(blockUri) + await getAgent().app.bsky.graph.block.delete({ + repo: currentAccount.did, + rkey, + }) + }, + onMutate(variables) { + if (!variables.skipOptimistic) { + // optimistically update + updateProfileShadow(variables.did, { + blockingUri: undefined, + }) + } + }, + onError(error, variables) { + if (!variables.skipOptimistic) { + // revert the optimistic update + updateProfileShadow(variables.did, { + blockingUri: variables.blockUri, + }) + } + }, + }) +} + +async function whenAppViewReady( + actor: string, + fn: (res: AppBskyActorGetProfile.Response) => boolean, +) { + await until( + 5, // 5 tries + 1e3, // 1s delay between tries + fn, + () => getAgent().app.bsky.actor.getProfile({actor}), + ) +} + +export function* findAllProfilesInQueryData( + queryClient: QueryClient, + did: string, +): Generator<AppBskyActorDefs.ProfileViewDetailed, void> { + const queryDatas = + queryClient.getQueriesData<AppBskyActorDefs.ProfileViewDetailed>({ + queryKey: ['profile'], + }) + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData) { + continue + } + if (queryData.did === did) { + yield queryData + } + } +} diff --git a/src/state/queries/resolve-uri.ts b/src/state/queries/resolve-uri.ts new file mode 100644 index 000000000..a75998466 --- /dev/null +++ b/src/state/queries/resolve-uri.ts @@ -0,0 +1,76 @@ +import {QueryClient, useQuery, UseQueryResult} from '@tanstack/react-query' +import {AtUri, AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api' + +import {getAgent} from '#/state/session' +import {STALE} from '#/state/queries' +import {ThreadNode} from './post-thread' + +export const RQKEY = (didOrHandle: string) => ['resolved-did', didOrHandle] + +type UriUseQueryResult = UseQueryResult<{did: string; uri: string}, Error> +export function useResolveUriQuery(uri: string | undefined): UriUseQueryResult { + const urip = new AtUri(uri || '') + const res = useResolveDidQuery(urip.host) + if (res.data) { + urip.host = res.data + return { + ...res, + data: {did: urip.host, uri: urip.toString()}, + } as UriUseQueryResult + } + return res as UriUseQueryResult +} + +export function useResolveDidQuery(didOrHandle: string | undefined) { + return useQuery<string, Error>({ + staleTime: STALE.HOURS.ONE, + queryKey: RQKEY(didOrHandle || ''), + async queryFn() { + if (!didOrHandle) { + return '' + } + if (!didOrHandle.startsWith('did:')) { + const res = await getAgent().resolveHandle({handle: didOrHandle}) + didOrHandle = res.data.did + } + return didOrHandle + }, + enabled: !!didOrHandle, + }) +} + +export function precacheProfile( + queryClient: QueryClient, + profile: + | AppBskyActorDefs.ProfileView + | AppBskyActorDefs.ProfileViewBasic + | AppBskyActorDefs.ProfileViewDetailed, +) { + queryClient.setQueryData(RQKEY(profile.handle), profile.did) +} + +export function precacheFeedPosts( + queryClient: QueryClient, + posts: AppBskyFeedDefs.FeedViewPost[], +) { + for (const post of posts) { + precacheProfile(queryClient, post.post.author) + } +} + +export function precacheThreadPosts( + queryClient: QueryClient, + node: ThreadNode, +) { + if (node.type === 'post') { + precacheProfile(queryClient, node.post.author) + if (node.parent) { + precacheThreadPosts(queryClient, node.parent) + } + if (node.replies?.length) { + for (const reply of node.replies) { + precacheThreadPosts(queryClient, reply) + } + } + } +} diff --git a/src/state/queries/search-posts.ts b/src/state/queries/search-posts.ts new file mode 100644 index 000000000..03f3ba339 --- /dev/null +++ b/src/state/queries/search-posts.ts @@ -0,0 +1,30 @@ +import {AppBskyFeedSearchPosts} from '@atproto/api' +import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query' + +import {getAgent} from '#/state/session' + +const searchPostsQueryKey = ({query}: {query: string}) => [ + 'search-posts', + query, +] + +export function useSearchPostsQuery({query}: {query: string}) { + return useInfiniteQuery< + AppBskyFeedSearchPosts.OutputSchema, + Error, + InfiniteData<AppBskyFeedSearchPosts.OutputSchema>, + QueryKey, + string | undefined + >({ + queryKey: searchPostsQueryKey({query}), + queryFn: async () => { + const res = await getAgent().app.bsky.feed.searchPosts({ + q: query, + limit: 25, + }) + return res.data + }, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + }) +} diff --git a/src/state/queries/service.ts b/src/state/queries/service.ts new file mode 100644 index 000000000..5f7e10778 --- /dev/null +++ b/src/state/queries/service.ts @@ -0,0 +1,26 @@ +import {BskyAgent} from '@atproto/api' +import {useQuery} from '@tanstack/react-query' + +export const RQKEY = (serviceUrl: string) => ['service', serviceUrl] + +export function useServiceQuery(serviceUrl: string) { + return useQuery({ + queryKey: RQKEY(serviceUrl), + queryFn: async () => { + const agent = new BskyAgent({service: serviceUrl}) + const res = await agent.com.atproto.server.describeServer() + return res.data + }, + enabled: isValidUrl(serviceUrl), + }) +} + +function isValidUrl(url: string) { + try { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const urlp = new URL(url) + return true + } catch { + return false + } +} diff --git a/src/state/queries/suggested-feeds.ts b/src/state/queries/suggested-feeds.ts new file mode 100644 index 000000000..7e6b534ad --- /dev/null +++ b/src/state/queries/suggested-feeds.ts @@ -0,0 +1,29 @@ +import {useInfiniteQuery, InfiniteData, QueryKey} from '@tanstack/react-query' +import {AppBskyFeedGetSuggestedFeeds} from '@atproto/api' + +import {getAgent} from '#/state/session' +import {STALE} from '#/state/queries' + +export const suggestedFeedsQueryKey = ['suggestedFeeds'] + +export function useSuggestedFeedsQuery() { + return useInfiniteQuery< + AppBskyFeedGetSuggestedFeeds.OutputSchema, + Error, + InfiniteData<AppBskyFeedGetSuggestedFeeds.OutputSchema>, + QueryKey, + string | undefined + >({ + staleTime: STALE.HOURS.ONE, + queryKey: suggestedFeedsQueryKey, + queryFn: async ({pageParam}) => { + const res = await getAgent().app.bsky.feed.getSuggestedFeeds({ + limit: 10, + cursor: pageParam, + }) + return res.data + }, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + }) +} diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts new file mode 100644 index 000000000..932226b75 --- /dev/null +++ b/src/state/queries/suggested-follows.ts @@ -0,0 +1,163 @@ +import React from 'react' +import { + AppBskyActorDefs, + AppBskyActorGetSuggestions, + AppBskyGraphGetSuggestedFollowsByActor, + moderateProfile, +} from '@atproto/api' +import { + useInfiniteQuery, + useQueryClient, + useQuery, + InfiniteData, + QueryClient, + QueryKey, +} from '@tanstack/react-query' + +import {useSession, getAgent} from '#/state/session' +import {useModerationOpts} from '#/state/queries/preferences' +import {STALE} from '#/state/queries' + +const suggestedFollowsQueryKey = ['suggested-follows'] +const suggestedFollowsByActorQueryKey = (did: string) => [ + 'suggested-follows-by-actor', + did, +] + +export function useSuggestedFollowsQuery() { + const {currentAccount} = useSession() + const moderationOpts = useModerationOpts() + + return useInfiniteQuery< + AppBskyActorGetSuggestions.OutputSchema, + Error, + InfiniteData<AppBskyActorGetSuggestions.OutputSchema>, + QueryKey, + string | undefined + >({ + enabled: !!moderationOpts, + staleTime: STALE.HOURS.ONE, + queryKey: suggestedFollowsQueryKey, + queryFn: async ({pageParam}) => { + const res = await getAgent().app.bsky.actor.getSuggestions({ + limit: 25, + cursor: pageParam, + }) + + res.data.actors = res.data.actors + .filter( + actor => !moderateProfile(actor, moderationOpts!).account.filter, + ) + .filter(actor => { + const viewer = actor.viewer + if (viewer) { + if ( + viewer.following || + viewer.muted || + viewer.mutedByList || + viewer.blockedBy || + viewer.blocking + ) { + return false + } + } + if (actor.did === currentAccount?.did) { + return false + } + return true + }) + + return res.data + }, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + }) +} + +export function useSuggestedFollowsByActorQuery({did}: {did: string}) { + return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({ + queryKey: suggestedFollowsByActorQueryKey(did), + queryFn: async () => { + const res = await getAgent().app.bsky.graph.getSuggestedFollowsByActor({ + actor: did, + }) + return res.data + }, + }) +} + +export function useGetSuggestedFollowersByActor() { + const queryClient = useQueryClient() + + return React.useCallback( + async (actor: string) => { + const res = await queryClient.fetchQuery({ + staleTime: STALE.MINUTES.ONE, + queryKey: suggestedFollowsByActorQueryKey(actor), + queryFn: async () => { + const res = + await getAgent().app.bsky.graph.getSuggestedFollowsByActor({ + actor: actor, + }) + return res.data + }, + }) + + return res + }, + [queryClient], + ) +} + +export function* findAllProfilesInQueryData( + queryClient: QueryClient, + did: string, +): Generator<AppBskyActorDefs.ProfileView, void> { + yield* findAllProfilesInSuggestedFollowsQueryData(queryClient, did) + yield* findAllProfilesInSuggestedFollowsByActorQueryData(queryClient, did) +} + +function* findAllProfilesInSuggestedFollowsQueryData( + queryClient: QueryClient, + did: string, +) { + const queryDatas = queryClient.getQueriesData< + InfiniteData<AppBskyActorGetSuggestions.OutputSchema> + >({ + queryKey: ['suggested-follows'], + }) + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData?.pages) { + continue + } + for (const page of queryData?.pages) { + for (const actor of page.actors) { + if (actor.did === did) { + yield actor + } + } + } + } +} + +function* findAllProfilesInSuggestedFollowsByActorQueryData( + queryClient: QueryClient, + did: string, +) { + const queryDatas = + queryClient.getQueriesData<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema>( + { + queryKey: ['suggested-follows-by-actor'], + }, + ) + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData) { + continue + } + for (const suggestion of queryData.suggestions) { + if (suggestion.did === did) { + yield suggestion + } + } + } +} diff --git a/src/state/queries/util.ts b/src/state/queries/util.ts new file mode 100644 index 000000000..0b3eefea2 --- /dev/null +++ b/src/state/queries/util.ts @@ -0,0 +1,17 @@ +import {QueryClient, QueryKey, InfiniteData} from '@tanstack/react-query' + +export function truncateAndInvalidate<T = any>( + queryClient: QueryClient, + querykey: QueryKey, +) { + queryClient.setQueryData<InfiniteData<T>>(querykey, data => { + if (data) { + return { + pageParams: data.pageParams.slice(0, 1), + pages: data.pages.slice(0, 1), + } + } + return data + }) + queryClient.invalidateQueries({queryKey: querykey}) +} diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx new file mode 100644 index 000000000..e6def1fab --- /dev/null +++ b/src/state/session/index.tsx @@ -0,0 +1,555 @@ +import React from 'react' +import {BskyAgent, AtpPersistSessionHandler} from '@atproto/api' +import {useQueryClient} from '@tanstack/react-query' + +import {networkRetry} from '#/lib/async/retry' +import {logger} from '#/logger' +import * as persisted from '#/state/persisted' +import {PUBLIC_BSKY_AGENT} from '#/state/queries' +import {IS_PROD} from '#/lib/constants' +import {emitSessionLoaded, emitSessionDropped} from '../events' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' +import {useCloseAllActiveElements} from '#/state/util' + +let __globalAgent: BskyAgent = PUBLIC_BSKY_AGENT + +/** + * NOTE + * Never hold on to the object returned by this function. + * Call `getAgent()` at the time of invocation to ensure + * that you never have a stale agent. + */ +export function getAgent() { + return __globalAgent +} + +export type SessionAccount = persisted.PersistedAccount + +export type SessionState = { + isInitialLoad: boolean + isSwitchingAccounts: boolean + accounts: SessionAccount[] + currentAccount: SessionAccount | undefined +} +export type StateContext = SessionState & { + hasSession: boolean + isSandbox: boolean +} +export type ApiContext = { + createAccount: (props: { + service: string + email: string + password: string + handle: string + inviteCode?: string + }) => Promise<void> + login: (props: { + service: string + identifier: string + password: string + }) => Promise<void> + /** + * A full logout. Clears the `currentAccount` from session, AND removes + * access tokens from all accounts, so that returning as any user will + * require a full login. + */ + logout: () => Promise<void> + /** + * A partial logout. Clears the `currentAccount` from session, but DOES NOT + * clear access tokens from accounts, allowing the user to return to their + * other accounts without logging in. + * + * Used when adding a new account, deleting an account. + */ + clearCurrentAccount: () => void + initSession: (account: SessionAccount) => Promise<void> + resumeSession: (account?: SessionAccount) => Promise<void> + removeAccount: (account: SessionAccount) => void + selectAccount: (account: SessionAccount) => Promise<void> + updateCurrentAccount: ( + account: Partial< + Pick<SessionAccount, 'handle' | 'email' | 'emailConfirmed'> + >, + ) => void +} + +const StateContext = React.createContext<StateContext>({ + isInitialLoad: true, + isSwitchingAccounts: false, + accounts: [], + currentAccount: undefined, + hasSession: false, + isSandbox: false, +}) + +const ApiContext = React.createContext<ApiContext>({ + createAccount: async () => {}, + login: async () => {}, + logout: async () => {}, + initSession: async () => {}, + resumeSession: async () => {}, + removeAccount: () => {}, + selectAccount: async () => {}, + updateCurrentAccount: () => {}, + clearCurrentAccount: () => {}, +}) + +function createPersistSessionHandler( + account: SessionAccount, + persistSessionCallback: (props: { + expired: boolean + refreshedAccount: SessionAccount + }) => void, +): AtpPersistSessionHandler { + return function persistSession(event, session) { + const expired = !(event === 'create' || event === 'update') + const refreshedAccount: SessionAccount = { + service: account.service, + did: session?.did || account.did, + handle: session?.handle || account.handle, + email: session?.email || account.email, + emailConfirmed: session?.emailConfirmed || account.emailConfirmed, + refreshJwt: session?.refreshJwt, // undefined when expired or creation fails + accessJwt: session?.accessJwt, // undefined when expired or creation fails + } + + logger.debug( + `session: BskyAgent.persistSession`, + { + expired, + did: refreshedAccount.did, + handle: refreshedAccount.handle, + }, + logger.DebugContext.session, + ) + + if (expired) { + emitSessionDropped() + } + + persistSessionCallback({ + expired, + refreshedAccount, + }) + } +} + +export function Provider({children}: React.PropsWithChildren<{}>) { + const queryClient = useQueryClient() + const isDirty = React.useRef(false) + const [state, setState] = React.useState<SessionState>({ + isInitialLoad: true, // try to resume the session first + isSwitchingAccounts: false, + accounts: persisted.get('session').accounts, + currentAccount: undefined, // assume logged out to start + }) + + const setStateAndPersist = React.useCallback( + (fn: (prev: SessionState) => SessionState) => { + isDirty.current = true + setState(fn) + }, + [setState], + ) + + const upsertAccount = React.useCallback( + (account: SessionAccount, expired = false) => { + setStateAndPersist(s => { + return { + ...s, + currentAccount: expired ? undefined : account, + accounts: [account, ...s.accounts.filter(a => a.did !== account.did)], + } + }) + }, + [setStateAndPersist], + ) + + const createAccount = React.useCallback<ApiContext['createAccount']>( + async ({service, email, password, handle, inviteCode}: any) => { + logger.debug( + `session: creating account`, + { + service, + handle, + }, + logger.DebugContext.session, + ) + + const agent = new BskyAgent({service}) + + await agent.createAccount({ + handle, + password, + email, + inviteCode, + }) + + if (!agent.session) { + throw new Error(`session: createAccount failed to establish a session`) + } + + const account: SessionAccount = { + service: agent.service.toString(), + did: agent.session.did, + handle: agent.session.handle, + email: agent.session.email!, // TODO this is always defined? + emailConfirmed: false, + refreshJwt: agent.session.refreshJwt, + accessJwt: agent.session.accessJwt, + } + + agent.setPersistSessionHandler( + createPersistSessionHandler(account, ({expired, refreshedAccount}) => { + upsertAccount(refreshedAccount, expired) + }), + ) + + __globalAgent = agent + queryClient.clear() + upsertAccount(account) + emitSessionLoaded(account, agent) + + logger.debug( + `session: created account`, + { + service, + handle, + }, + logger.DebugContext.session, + ) + }, + [upsertAccount, queryClient], + ) + + const login = React.useCallback<ApiContext['login']>( + async ({service, identifier, password}) => { + logger.debug( + `session: login`, + { + service, + identifier, + }, + logger.DebugContext.session, + ) + + const agent = new BskyAgent({service}) + + await agent.login({identifier, password}) + + if (!agent.session) { + throw new Error(`session: login failed to establish a session`) + } + + const account: SessionAccount = { + service: agent.service.toString(), + did: agent.session.did, + handle: agent.session.handle, + email: agent.session.email!, // TODO this is always defined? + emailConfirmed: agent.session.emailConfirmed || false, + refreshJwt: agent.session.refreshJwt, + accessJwt: agent.session.accessJwt, + } + + agent.setPersistSessionHandler( + createPersistSessionHandler(account, ({expired, refreshedAccount}) => { + upsertAccount(refreshedAccount, expired) + }), + ) + + __globalAgent = agent + queryClient.clear() + upsertAccount(account) + emitSessionLoaded(account, agent) + + logger.debug( + `session: logged in`, + { + service, + identifier, + }, + logger.DebugContext.session, + ) + }, + [upsertAccount, queryClient], + ) + + const clearCurrentAccount = React.useCallback(() => { + logger.debug( + `session: clear current account`, + {}, + logger.DebugContext.session, + ) + __globalAgent = PUBLIC_BSKY_AGENT + queryClient.clear() + setStateAndPersist(s => ({ + ...s, + currentAccount: undefined, + })) + }, [setStateAndPersist, queryClient]) + + const logout = React.useCallback<ApiContext['logout']>(async () => { + clearCurrentAccount() + logger.debug(`session: logout`, {}, logger.DebugContext.session) + setStateAndPersist(s => { + return { + ...s, + accounts: s.accounts.map(a => ({ + ...a, + refreshJwt: undefined, + accessJwt: undefined, + })), + } + }) + }, [clearCurrentAccount, setStateAndPersist]) + + const initSession = React.useCallback<ApiContext['initSession']>( + async account => { + logger.debug( + `session: initSession`, + { + did: account.did, + handle: account.handle, + }, + logger.DebugContext.session, + ) + + const agent = new BskyAgent({ + service: account.service, + persistSession: createPersistSessionHandler( + account, + ({expired, refreshedAccount}) => { + upsertAccount(refreshedAccount, expired) + }, + ), + }) + + await networkRetry(3, () => + agent.resumeSession({ + accessJwt: account.accessJwt || '', + refreshJwt: account.refreshJwt || '', + did: account.did, + handle: account.handle, + }), + ) + + if (!agent.session) { + throw new Error(`session: initSession failed to establish a session`) + } + + // ensure changes in handle/email etc are captured on reload + const freshAccount: SessionAccount = { + service: agent.service.toString(), + did: agent.session.did, + handle: agent.session.handle, + email: agent.session.email!, // TODO this is always defined? + emailConfirmed: agent.session.emailConfirmed || false, + refreshJwt: agent.session.refreshJwt, + accessJwt: agent.session.accessJwt, + } + + __globalAgent = agent + queryClient.clear() + upsertAccount(freshAccount) + emitSessionLoaded(freshAccount, agent) + }, + [upsertAccount, queryClient], + ) + + const resumeSession = React.useCallback<ApiContext['resumeSession']>( + async account => { + try { + if (account) { + await initSession(account) + } + } catch (e) { + logger.error(`session: resumeSession failed`, {error: e}) + } finally { + setState(s => ({ + ...s, + isInitialLoad: false, + })) + } + }, + [initSession], + ) + + const removeAccount = React.useCallback<ApiContext['removeAccount']>( + account => { + setStateAndPersist(s => { + return { + ...s, + accounts: s.accounts.filter(a => a.did !== account.did), + } + }) + }, + [setStateAndPersist], + ) + + const updateCurrentAccount = React.useCallback< + ApiContext['updateCurrentAccount'] + >( + account => { + setStateAndPersist(s => { + const currentAccount = s.currentAccount + + // ignore, should never happen + if (!currentAccount) return s + + const updatedAccount = { + ...currentAccount, + handle: account.handle || currentAccount.handle, + email: account.email || currentAccount.email, + emailConfirmed: + account.emailConfirmed !== undefined + ? account.emailConfirmed + : currentAccount.emailConfirmed, + } + + return { + ...s, + currentAccount: updatedAccount, + accounts: [ + updatedAccount, + ...s.accounts.filter(a => a.did !== currentAccount.did), + ], + } + }) + }, + [setStateAndPersist], + ) + + const selectAccount = React.useCallback<ApiContext['selectAccount']>( + async account => { + setState(s => ({...s, isSwitchingAccounts: true})) + try { + await initSession(account) + setState(s => ({...s, isSwitchingAccounts: false})) + } catch (e) { + // reset this in case of error + setState(s => ({...s, isSwitchingAccounts: false})) + // but other listeners need a throw + throw e + } + }, + [setState, initSession], + ) + + React.useEffect(() => { + if (isDirty.current) { + isDirty.current = false + persisted.write('session', { + accounts: state.accounts, + currentAccount: state.currentAccount, + }) + } + }, [state]) + + React.useEffect(() => { + return persisted.onUpdate(() => { + const session = persisted.get('session') + + logger.debug(`session: onUpdate`, {}, logger.DebugContext.session) + + if (session.currentAccount) { + if (session.currentAccount?.did !== state.currentAccount?.did) { + logger.debug( + `session: switching account`, + { + from: { + did: state.currentAccount?.did, + handle: state.currentAccount?.handle, + }, + to: { + did: session.currentAccount.did, + handle: session.currentAccount.handle, + }, + }, + logger.DebugContext.session, + ) + + initSession(session.currentAccount) + } + } else if (!session.currentAccount && state.currentAccount) { + logger.debug( + `session: logging out`, + { + did: state.currentAccount?.did, + handle: state.currentAccount?.handle, + }, + logger.DebugContext.session, + ) + + clearCurrentAccount() + } + }) + }, [state, clearCurrentAccount, initSession]) + + const stateContext = React.useMemo( + () => ({ + ...state, + hasSession: !!state.currentAccount, + isSandbox: state.currentAccount + ? !IS_PROD(state.currentAccount?.service) + : false, + }), + [state], + ) + + const api = React.useMemo( + () => ({ + createAccount, + login, + logout, + initSession, + resumeSession, + removeAccount, + selectAccount, + updateCurrentAccount, + clearCurrentAccount, + }), + [ + createAccount, + login, + logout, + initSession, + resumeSession, + removeAccount, + selectAccount, + updateCurrentAccount, + clearCurrentAccount, + ], + ) + + return ( + <StateContext.Provider value={stateContext}> + <ApiContext.Provider value={api}>{children}</ApiContext.Provider> + </StateContext.Provider> + ) +} + +export function useSession() { + return React.useContext(StateContext) +} + +export function useSessionApi() { + return React.useContext(ApiContext) +} + +export function useRequireAuth() { + const {hasSession} = useSession() + const {setShowLoggedOut} = useLoggedOutViewControls() + const closeAll = useCloseAllActiveElements() + + return React.useCallback( + (fn: () => void) => { + if (hasSession) { + fn() + } else { + closeAll() + setShowLoggedOut(true) + } + }, + [hasSession, setShowLoggedOut, closeAll], + ) +} diff --git a/src/state/shell/color-mode.tsx b/src/state/shell/color-mode.tsx index 74379da37..c6a4b8a18 100644 --- a/src/state/shell/color-mode.tsx +++ b/src/state/shell/color-mode.tsx @@ -27,7 +27,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { setState(persisted.get('colorMode')) updateDocument(persisted.get('colorMode')) }) - }, [setStateWrapped]) + }, [setState]) return ( <stateContext.Provider value={state}> diff --git a/src/state/shell/composer.tsx b/src/state/shell/composer.tsx new file mode 100644 index 000000000..bdf5e4a7a --- /dev/null +++ b/src/state/shell/composer.tsx @@ -0,0 +1,85 @@ +import React from 'react' +import {AppBskyEmbedRecord} from '@atproto/api' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' + +export interface ComposerOptsPostRef { + uri: string + cid: string + text: string + author: { + handle: string + displayName?: string + avatar?: string + } +} +export interface ComposerOptsQuote { + uri: string + cid: string + text: string + indexedAt: string + author: { + did: string + handle: string + displayName?: string + avatar?: string + } + embeds?: AppBskyEmbedRecord.ViewRecord['embeds'] +} +export interface ComposerOpts { + replyTo?: ComposerOptsPostRef + onPost?: () => void + quote?: ComposerOptsQuote + mention?: string // handle of user to mention +} + +type StateContext = ComposerOpts | undefined +type ControlsContext = { + openComposer: (opts: ComposerOpts) => void + closeComposer: () => boolean +} + +const stateContext = React.createContext<StateContext>(undefined) +const controlsContext = React.createContext<ControlsContext>({ + openComposer(_opts: ComposerOpts) {}, + closeComposer() { + return false + }, +}) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState<StateContext>() + + const openComposer = useNonReactiveCallback((opts: ComposerOpts) => { + setState(opts) + }) + + const closeComposer = useNonReactiveCallback(() => { + let wasOpen = !!state + setState(undefined) + return wasOpen + }) + + const api = React.useMemo( + () => ({ + openComposer, + closeComposer, + }), + [openComposer, closeComposer], + ) + + return ( + <stateContext.Provider value={state}> + <controlsContext.Provider value={api}> + {children} + </controlsContext.Provider> + </stateContext.Provider> + ) +} + +export function useComposerState() { + return React.useContext(stateContext) +} + +export function useComposerControls() { + return React.useContext(controlsContext) +} diff --git a/src/state/shell/drawer-open.tsx b/src/state/shell/drawer-open.tsx index a2322f680..061ff53d7 100644 --- a/src/state/shell/drawer-open.tsx +++ b/src/state/shell/drawer-open.tsx @@ -8,6 +8,7 @@ const setContext = React.createContext<SetContext>((_: boolean) => {}) export function Provider({children}: React.PropsWithChildren<{}>) { const [state, setState] = React.useState(false) + return ( <stateContext.Provider value={state}> <setContext.Provider value={setState}>{children}</setContext.Provider> diff --git a/src/state/shell/index.tsx b/src/state/shell/index.tsx index 1e01a4e7d..53f05055c 100644 --- a/src/state/shell/index.tsx +++ b/src/state/shell/index.tsx @@ -1,8 +1,12 @@ import React from 'react' +import {Provider as ShellLayoutProvder} from './shell-layout' import {Provider as DrawerOpenProvider} from './drawer-open' import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled' import {Provider as MinimalModeProvider} from './minimal-mode' import {Provider as ColorModeProvider} from './color-mode' +import {Provider as OnboardingProvider} from './onboarding' +import {Provider as ComposerProvider} from './composer' +import {Provider as TickEveryMinuteProvider} from './tick-every-minute' export {useIsDrawerOpen, useSetDrawerOpen} from './drawer-open' export { @@ -11,15 +15,26 @@ export { } from './drawer-swipe-disabled' export {useMinimalShellMode, useSetMinimalShellMode} from './minimal-mode' export {useColorMode, useSetColorMode} from './color-mode' +export {useOnboardingState, useOnboardingDispatch} from './onboarding' +export {useComposerState, useComposerControls} from './composer' +export {useTickEveryMinute} from './tick-every-minute' export function Provider({children}: React.PropsWithChildren<{}>) { return ( - <DrawerOpenProvider> - <DrawerSwipableProvider> - <MinimalModeProvider> - <ColorModeProvider>{children}</ColorModeProvider> - </MinimalModeProvider> - </DrawerSwipableProvider> - </DrawerOpenProvider> + <ShellLayoutProvder> + <DrawerOpenProvider> + <DrawerSwipableProvider> + <MinimalModeProvider> + <ColorModeProvider> + <OnboardingProvider> + <ComposerProvider> + <TickEveryMinuteProvider>{children}</TickEveryMinuteProvider> + </ComposerProvider> + </OnboardingProvider> + </ColorModeProvider> + </MinimalModeProvider> + </DrawerSwipableProvider> + </DrawerOpenProvider> + </ShellLayoutProvder> ) } diff --git a/src/state/shell/logged-out.tsx b/src/state/shell/logged-out.tsx new file mode 100644 index 000000000..19eaee76b --- /dev/null +++ b/src/state/shell/logged-out.tsx @@ -0,0 +1,37 @@ +import React from 'react' + +type StateContext = { + showLoggedOut: boolean +} + +const StateContext = React.createContext<StateContext>({ + showLoggedOut: false, +}) +const ControlsContext = React.createContext<{ + setShowLoggedOut: (show: boolean) => void +}>({ + setShowLoggedOut: () => {}, +}) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [showLoggedOut, setShowLoggedOut] = React.useState(false) + + const state = React.useMemo(() => ({showLoggedOut}), [showLoggedOut]) + const controls = React.useMemo(() => ({setShowLoggedOut}), [setShowLoggedOut]) + + return ( + <StateContext.Provider value={state}> + <ControlsContext.Provider value={controls}> + {children} + </ControlsContext.Provider> + </StateContext.Provider> + ) +} + +export function useLoggedOutView() { + return React.useContext(StateContext) +} + +export function useLoggedOutViewControls() { + return React.useContext(ControlsContext) +} diff --git a/src/state/shell/minimal-mode.tsx b/src/state/shell/minimal-mode.tsx index 4909a9a65..2c2f60b52 100644 --- a/src/state/shell/minimal-mode.tsx +++ b/src/state/shell/minimal-mode.tsx @@ -1,16 +1,37 @@ import React from 'react' +import { + Easing, + SharedValue, + useSharedValue, + withTiming, +} from 'react-native-reanimated' -type StateContext = boolean +type StateContext = SharedValue<number> type SetContext = (v: boolean) => void -const stateContext = React.createContext<StateContext>(false) +const stateContext = React.createContext<StateContext>({ + value: 0, + addListener() {}, + removeListener() {}, + modify() {}, +}) const setContext = React.createContext<SetContext>((_: boolean) => {}) export function Provider({children}: React.PropsWithChildren<{}>) { - const [state, setState] = React.useState(false) + const mode = useSharedValue(0) + const setMode = React.useCallback( + (v: boolean) => { + 'worklet' + mode.value = withTiming(v ? 1 : 0, { + duration: 400, + easing: Easing.bezier(0.25, 0.1, 0.25, 1), + }) + }, + [mode], + ) return ( - <stateContext.Provider value={state}> - <setContext.Provider value={setState}>{children}</setContext.Provider> + <stateContext.Provider value={mode}> + <setContext.Provider value={setMode}>{children}</setContext.Provider> </stateContext.Provider> ) } diff --git a/src/state/shell/onboarding.tsx b/src/state/shell/onboarding.tsx new file mode 100644 index 000000000..6a18b461f --- /dev/null +++ b/src/state/shell/onboarding.tsx @@ -0,0 +1,123 @@ +import React from 'react' +import * as persisted from '#/state/persisted' +import {track} from '#/lib/analytics/analytics' + +export const OnboardingScreenSteps = { + Welcome: 'Welcome', + RecommendedFeeds: 'RecommendedFeeds', + RecommendedFollows: 'RecommendedFollows', + Home: 'Home', +} as const + +type OnboardingStep = + (typeof OnboardingScreenSteps)[keyof typeof OnboardingScreenSteps] +const OnboardingStepsArray = Object.values(OnboardingScreenSteps) + +type Action = + | {type: 'set'; step: OnboardingStep} + | {type: 'next'; currentStep?: OnboardingStep} + | {type: 'start'} + | {type: 'finish'} + | {type: 'skip'} + +export type StateContext = persisted.Schema['onboarding'] & { + isComplete: boolean + isActive: boolean +} +export type DispatchContext = (action: Action) => void + +const stateContext = React.createContext<StateContext>( + compute(persisted.defaults.onboarding), +) +const dispatchContext = React.createContext<DispatchContext>((_: Action) => {}) + +function reducer(state: StateContext, action: Action): StateContext { + switch (action.type) { + case 'set': { + if (OnboardingStepsArray.includes(action.step)) { + persisted.write('onboarding', {step: action.step}) + return compute({...state, step: action.step}) + } + return state + } + case 'next': { + const currentStep = action.currentStep || state.step + let nextStep = 'Home' + if (currentStep === 'Welcome') { + nextStep = 'RecommendedFeeds' + } else if (currentStep === 'RecommendedFeeds') { + nextStep = 'RecommendedFollows' + } else if (currentStep === 'RecommendedFollows') { + nextStep = 'Home' + } + persisted.write('onboarding', {step: nextStep}) + return compute({...state, step: nextStep}) + } + case 'start': { + track('Onboarding:Begin') + persisted.write('onboarding', {step: 'Welcome'}) + return compute({...state, step: 'Welcome'}) + } + case 'finish': { + track('Onboarding:Complete') + persisted.write('onboarding', {step: 'Home'}) + return compute({...state, step: 'Home'}) + } + case 'skip': { + track('Onboarding:Skipped') + persisted.write('onboarding', {step: 'Home'}) + return compute({...state, step: 'Home'}) + } + default: { + throw new Error('Invalid action') + } + } +} + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, dispatch] = React.useReducer( + reducer, + compute(persisted.get('onboarding')), + ) + + React.useEffect(() => { + return persisted.onUpdate(() => { + const next = persisted.get('onboarding').step + // TODO we've introduced a footgun + if (state.step !== next) { + dispatch({ + type: 'set', + step: persisted.get('onboarding').step as OnboardingStep, + }) + } + }) + }, [state, dispatch]) + + return ( + <stateContext.Provider value={state}> + <dispatchContext.Provider value={dispatch}> + {children} + </dispatchContext.Provider> + </stateContext.Provider> + ) +} + +export function useOnboardingState() { + return React.useContext(stateContext) +} + +export function useOnboardingDispatch() { + return React.useContext(dispatchContext) +} + +export function isOnboardingActive() { + return compute(persisted.get('onboarding')).isActive +} + +function compute(state: persisted.Schema['onboarding']): StateContext { + return { + ...state, + isActive: state.step !== 'Home', + isComplete: state.step === 'Home', + } +} diff --git a/src/state/shell/reminders.e2e.ts b/src/state/shell/reminders.e2e.ts index 6238ffa29..e8c12792a 100644 --- a/src/state/shell/reminders.e2e.ts +++ b/src/state/shell/reminders.e2e.ts @@ -1,10 +1,6 @@ -import {OnboardingModel} from '../models/discovery/onboarding' -import {SessionModel} from '../models/session' +export function init() {} -export function shouldRequestEmailConfirmation( - _session: SessionModel, - _onboarding: OnboardingModel, -) { +export function shouldRequestEmailConfirmation() { return false } diff --git a/src/state/shell/reminders.ts b/src/state/shell/reminders.ts index d68a272ac..88d0a5d85 100644 --- a/src/state/shell/reminders.ts +++ b/src/state/shell/reminders.ts @@ -1,20 +1,27 @@ import * as persisted from '#/state/persisted' -import {OnboardingModel} from '../models/discovery/onboarding' -import {SessionModel} from '../models/session' import {toHashCode} from 'lib/strings/helpers' +import {isOnboardingActive} from './onboarding' +import {SessionAccount} from '../session' +import {listenSessionLoaded} from '../events' +import {unstable__openModal} from '../modals' -export function shouldRequestEmailConfirmation( - session: SessionModel, - onboarding: OnboardingModel, -) { - const sess = session.currentSession - if (!sess) { +export function init() { + listenSessionLoaded(account => { + if (shouldRequestEmailConfirmation(account)) { + unstable__openModal({name: 'verify-email', showReminder: true}) + setEmailConfirmationRequested() + } + }) +} + +export function shouldRequestEmailConfirmation(account: SessionAccount) { + if (!account) { return false } - if (sess.emailConfirmed) { + if (account.emailConfirmed) { return false } - if (onboarding.isActive) { + if (isOnboardingActive()) { return false } // only prompt once @@ -25,7 +32,7 @@ export function shouldRequestEmailConfirmation( // shard the users into 2 day of the week buckets // (this is to avoid a sudden influx of email updates when // this feature rolls out) - const code = toHashCode(sess.did) % 7 + const code = toHashCode(account.did) % 7 if (code !== today.getDay() && code !== (today.getDay() + 1) % 7) { return false } diff --git a/src/state/shell/shell-layout.tsx b/src/state/shell/shell-layout.tsx new file mode 100644 index 000000000..a58ba851c --- /dev/null +++ b/src/state/shell/shell-layout.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import {SharedValue, useSharedValue} from 'react-native-reanimated' + +type StateContext = { + headerHeight: SharedValue<number> + footerHeight: SharedValue<number> +} + +const stateContext = React.createContext<StateContext>({ + headerHeight: { + value: 0, + addListener() {}, + removeListener() {}, + modify() {}, + }, + footerHeight: { + value: 0, + addListener() {}, + removeListener() {}, + modify() {}, + }, +}) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const headerHeight = useSharedValue(0) + const footerHeight = useSharedValue(0) + + const value = React.useMemo( + () => ({ + headerHeight, + footerHeight, + }), + [headerHeight, footerHeight], + ) + + return <stateContext.Provider value={value}>{children}</stateContext.Provider> +} + +export function useShellLayout() { + return React.useContext(stateContext) +} diff --git a/src/state/shell/tick-every-minute.tsx b/src/state/shell/tick-every-minute.tsx new file mode 100644 index 000000000..c37221c90 --- /dev/null +++ b/src/state/shell/tick-every-minute.tsx @@ -0,0 +1,20 @@ +import React from 'react' + +type StateContext = number + +const stateContext = React.createContext<StateContext>(0) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [tick, setTick] = React.useState(Date.now()) + React.useEffect(() => { + const i = setInterval(() => { + setTick(Date.now()) + }, 60_000) + return () => clearInterval(i) + }, []) + return <stateContext.Provider value={tick}>{children}</stateContext.Provider> +} + +export function useTickEveryMinute() { + return React.useContext(stateContext) +} diff --git a/src/state/util.ts b/src/state/util.ts new file mode 100644 index 000000000..57f4331b0 --- /dev/null +++ b/src/state/util.ts @@ -0,0 +1,45 @@ +import {useCallback} from 'react' +import {useLightboxControls} from './lightbox' +import {useModalControls} from './modals' +import {useComposerControls} from './shell/composer' +import {useSetDrawerOpen} from './shell/drawer-open' + +/** + * returns true if something was closed + * (used by the android hardware back btn) + */ +export function useCloseAnyActiveElement() { + const {closeLightbox} = useLightboxControls() + const {closeModal} = useModalControls() + const {closeComposer} = useComposerControls() + const setDrawerOpen = useSetDrawerOpen() + return useCallback(() => { + if (closeLightbox()) { + return true + } + if (closeModal()) { + return true + } + if (closeComposer()) { + return true + } + setDrawerOpen(false) + return false + }, [closeLightbox, closeModal, closeComposer, setDrawerOpen]) +} + +/** + * used to clear out any modals, eg for a navigation + */ +export function useCloseAllActiveElements() { + const {closeLightbox} = useLightboxControls() + const {closeAllModals} = useModalControls() + const {closeComposer} = useComposerControls() + const setDrawerOpen = useSetDrawerOpen() + return useCallback(() => { + closeLightbox() + closeAllModals() + closeComposer() + setDrawerOpen(false) + }, [closeLightbox, closeAllModals, closeComposer, setDrawerOpen]) +} diff --git a/src/view/com/auth/LoggedOut.tsx b/src/view/com/auth/LoggedOut.tsx index 3e2c9c1bf..030ae68b1 100644 --- a/src/view/com/auth/LoggedOut.tsx +++ b/src/view/com/auth/LoggedOut.tsx @@ -1,15 +1,19 @@ import React from 'react' -import {SafeAreaView} from 'react-native' -import {observer} from 'mobx-react-lite' +import {View, Pressable} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' + +import {isIOS} from 'platform/detection' import {Login} from 'view/com/auth/login/Login' import {CreateAccount} from 'view/com/auth/create/CreateAccount' import {ErrorBoundary} from 'view/com/util/ErrorBoundary' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {useAnalytics} from 'lib/analytics/analytics' import {SplashScreen} from './SplashScreen' import {useSetMinimalShellMode} from '#/state/shell/minimal-mode' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' enum ScreenState { S_LoginOrCreateAccount, @@ -17,35 +21,66 @@ enum ScreenState { S_CreateAccount, } -export const LoggedOut = observer(function LoggedOutImpl() { +export function LoggedOut({onDismiss}: {onDismiss?: () => void}) { + const {_} = useLingui() const pal = usePalette('default') - const store = useStores() const setMinimalShellMode = useSetMinimalShellMode() const {screen} = useAnalytics() const [screenState, setScreenState] = React.useState<ScreenState>( ScreenState.S_LoginOrCreateAccount, ) + const {isMobile} = useWebMediaQueries() React.useEffect(() => { screen('Login') setMinimalShellMode(true) }, [screen, setMinimalShellMode]) - if ( - store.session.isResumingSession || - screenState === ScreenState.S_LoginOrCreateAccount - ) { - return ( - <SplashScreen - onPressSignin={() => setScreenState(ScreenState.S_Login)} - onPressCreateAccount={() => setScreenState(ScreenState.S_CreateAccount)} - /> - ) - } - return ( - <SafeAreaView testID="noSessionView" style={[s.hContentRegion, pal.view]}> + <View + testID="noSessionView" + style={[ + s.hContentRegion, + pal.view, + { + // only needed if dismiss button is present + paddingTop: onDismiss && isMobile ? 40 : 0, + }, + ]}> <ErrorBoundary> + {onDismiss && ( + <Pressable + accessibilityHint={_(msg`Go back`)} + accessibilityLabel={_(msg`Go back`)} + accessibilityRole="button" + style={{ + position: 'absolute', + top: isIOS ? 0 : 20, + right: 20, + padding: 10, + zIndex: 100, + backgroundColor: pal.text.color, + borderRadius: 100, + }} + onPress={onDismiss}> + <FontAwesomeIcon + icon="x" + size={12} + style={{ + color: String(pal.textInverted.color), + }} + /> + </Pressable> + )} + + {screenState === ScreenState.S_LoginOrCreateAccount ? ( + <SplashScreen + onPressSignin={() => setScreenState(ScreenState.S_Login)} + onPressCreateAccount={() => + setScreenState(ScreenState.S_CreateAccount) + } + /> + ) : undefined} {screenState === ScreenState.S_Login ? ( <Login onPressBack={() => @@ -61,6 +96,6 @@ export const LoggedOut = observer(function LoggedOutImpl() { /> ) : undefined} </ErrorBoundary> - </SafeAreaView> + </View> ) -}) +} diff --git a/src/view/com/auth/Onboarding.tsx b/src/view/com/auth/Onboarding.tsx index bec1dc236..bdb7f27c8 100644 --- a/src/view/com/auth/Onboarding.tsx +++ b/src/view/com/auth/Onboarding.tsx @@ -1,40 +1,51 @@ import React from 'react' -import {SafeAreaView} from 'react-native' -import {observer} from 'mobx-react-lite' +import {SafeAreaView, Platform} from 'react-native' import {ErrorBoundary} from 'view/com/util/ErrorBoundary' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {Welcome} from './onboarding/Welcome' import {RecommendedFeeds} from './onboarding/RecommendedFeeds' import {RecommendedFollows} from './onboarding/RecommendedFollows' import {useSetMinimalShellMode} from '#/state/shell/minimal-mode' +import {useOnboardingState, useOnboardingDispatch} from '#/state/shell' -export const Onboarding = observer(function OnboardingImpl() { +export function Onboarding() { const pal = usePalette('default') - const store = useStores() const setMinimalShellMode = useSetMinimalShellMode() + const onboardingState = useOnboardingState() + const onboardingDispatch = useOnboardingDispatch() React.useEffect(() => { setMinimalShellMode(true) }, [setMinimalShellMode]) - const next = () => store.onboarding.next() - const skip = () => store.onboarding.skip() + const next = () => onboardingDispatch({type: 'next'}) + const skip = () => onboardingDispatch({type: 'skip'}) return ( - <SafeAreaView testID="onboardingView" style={[s.hContentRegion, pal.view]}> + <SafeAreaView + testID="onboardingView" + style={[ + s.hContentRegion, + pal.view, + // @ts-ignore web only -esb + Platform.select({ + web: { + height: '100vh', + }, + }), + ]}> <ErrorBoundary> - {store.onboarding.step === 'Welcome' && ( + {onboardingState.step === 'Welcome' && ( <Welcome skip={skip} next={next} /> )} - {store.onboarding.step === 'RecommendedFeeds' && ( + {onboardingState.step === 'RecommendedFeeds' && ( <RecommendedFeeds next={next} /> )} - {store.onboarding.step === 'RecommendedFollows' && ( + {onboardingState.step === 'RecommendedFollows' && ( <RecommendedFollows next={next} /> )} </ErrorBoundary> </SafeAreaView> ) -}) +} diff --git a/src/view/com/auth/SplashScreen.tsx b/src/view/com/auth/SplashScreen.tsx index 67453f111..d88627f65 100644 --- a/src/view/com/auth/SplashScreen.tsx +++ b/src/view/com/auth/SplashScreen.tsx @@ -1,10 +1,12 @@ import React from 'react' -import {SafeAreaView, StyleSheet, TouchableOpacity, View} from 'react-native' +import {StyleSheet, TouchableOpacity, View} from 'react-native' import {Text} from 'view/com/util/text/Text' import {ErrorBoundary} from 'view/com/util/ErrorBoundary' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {CenteredView} from '../util/Views' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' export const SplashScreen = ({ onPressSignin, @@ -14,40 +16,44 @@ export const SplashScreen = ({ onPressCreateAccount: () => void }) => { const pal = usePalette('default') + const {_} = useLingui() + return ( <CenteredView style={[styles.container, pal.view]}> - <SafeAreaView testID="noSessionView" style={styles.container}> - <ErrorBoundary> - <View style={styles.hero}> - <Text style={[styles.title, pal.link]}>Bluesky</Text> - <Text style={[styles.subtitle, pal.textLight]}> - See what's next + <ErrorBoundary> + <View style={styles.hero}> + <Text style={[styles.title, pal.link]}> + <Trans>Bluesky</Trans> + </Text> + <Text style={[styles.subtitle, pal.textLight]}> + <Trans>See what's next</Trans> + </Text> + </View> + <View testID="signinOrCreateAccount" style={styles.btns}> + <TouchableOpacity + testID="createAccountButton" + style={[styles.btn, {backgroundColor: colors.blue3}]} + onPress={onPressCreateAccount} + accessibilityRole="button" + accessibilityLabel={_(msg`Create new account`)} + accessibilityHint="Opens flow to create a new Bluesky account"> + <Text style={[s.white, styles.btnLabel]}> + <Trans>Create a new account</Trans> + </Text> + </TouchableOpacity> + <TouchableOpacity + testID="signInButton" + style={[styles.btn, pal.btn]} + onPress={onPressSignin} + accessibilityRole="button" + accessibilityLabel={_(msg`Sign in`)} + accessibilityHint="Opens flow to sign into your existing Bluesky account"> + <Text style={[pal.text, styles.btnLabel]}> + <Trans>Sign In</Trans> </Text> - </View> - <View testID="signinOrCreateAccount" style={styles.btns}> - <TouchableOpacity - testID="createAccountButton" - style={[styles.btn, {backgroundColor: colors.blue3}]} - onPress={onPressCreateAccount} - accessibilityRole="button" - accessibilityLabel="Create new account" - accessibilityHint="Opens flow to create a new Bluesky account"> - <Text style={[s.white, styles.btnLabel]}> - Create a new account - </Text> - </TouchableOpacity> - <TouchableOpacity - testID="signInButton" - style={[styles.btn, pal.btn]} - onPress={onPressSignin} - accessibilityRole="button" - accessibilityLabel="Sign in" - accessibilityHint="Opens flow to sign into your existing Bluesky account"> - <Text style={[pal.text, styles.btnLabel]}>Sign In</Text> - </TouchableOpacity> - </View> - </ErrorBoundary> - </SafeAreaView> + </TouchableOpacity> + </View> + </ErrorBoundary> </CenteredView> ) } diff --git a/src/view/com/auth/SplashScreen.web.tsx b/src/view/com/auth/SplashScreen.web.tsx index cef9618ef..08cf701da 100644 --- a/src/view/com/auth/SplashScreen.web.tsx +++ b/src/view/com/auth/SplashScreen.web.tsx @@ -1,5 +1,6 @@ import React from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {StyleSheet, TouchableOpacity, View, Pressable} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Text} from 'view/com/util/text/Text' import {TextLink} from '../util/Link' import {ErrorBoundary} from 'view/com/util/ErrorBoundary' @@ -8,11 +9,14 @@ import {usePalette} from 'lib/hooks/usePalette' import {CenteredView} from '../util/Views' import {isWeb} from 'platform/detection' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {Trans} from '@lingui/macro' export const SplashScreen = ({ + onDismiss, onPressSignin, onPressCreateAccount, }: { + onDismiss?: () => void onPressSignin: () => void onPressCreateAccount: () => void }) => { @@ -22,45 +26,70 @@ export const SplashScreen = ({ const isMobileWeb = isWeb && isTabletOrMobile return ( - <CenteredView style={[styles.container, pal.view]}> - <View - testID="noSessionView" - style={[ - styles.containerInner, - isMobileWeb && styles.containerInnerMobile, - pal.border, - ]}> - <ErrorBoundary> - <Text style={isMobileWeb ? styles.titleMobile : styles.title}> - Bluesky - </Text> - <Text style={isMobileWeb ? styles.subtitleMobile : styles.subtitle}> - See what's next - </Text> - <View testID="signinOrCreateAccount" style={styles.btns}> - <TouchableOpacity - testID="createAccountButton" - style={[styles.btn, {backgroundColor: colors.blue3}]} - onPress={onPressCreateAccount} - // TODO: web accessibility - accessibilityRole="button"> - <Text style={[s.white, styles.btnLabel]}> - Create a new account - </Text> - </TouchableOpacity> - <TouchableOpacity - testID="signInButton" - style={[styles.btn, pal.btn]} - onPress={onPressSignin} - // TODO: web accessibility - accessibilityRole="button"> - <Text style={[pal.text, styles.btnLabel]}>Sign In</Text> - </TouchableOpacity> - </View> - </ErrorBoundary> - </View> - <Footer styles={styles} /> - </CenteredView> + <> + {onDismiss && ( + <Pressable + accessibilityRole="button" + style={{ + position: 'absolute', + top: 20, + right: 20, + padding: 20, + zIndex: 100, + }} + onPress={onDismiss}> + <FontAwesomeIcon + icon="x" + size={24} + style={{ + color: String(pal.text.color), + }} + /> + </Pressable> + )} + + <CenteredView style={[styles.container, pal.view]}> + <View + testID="noSessionView" + style={[ + styles.containerInner, + isMobileWeb && styles.containerInnerMobile, + pal.border, + ]}> + <ErrorBoundary> + <Text style={isMobileWeb ? styles.titleMobile : styles.title}> + Bluesky + </Text> + <Text style={isMobileWeb ? styles.subtitleMobile : styles.subtitle}> + See what's next + </Text> + <View testID="signinOrCreateAccount" style={styles.btns}> + <TouchableOpacity + testID="createAccountButton" + style={[styles.btn, {backgroundColor: colors.blue3}]} + onPress={onPressCreateAccount} + // TODO: web accessibility + accessibilityRole="button"> + <Text style={[s.white, styles.btnLabel]}> + Create a new account + </Text> + </TouchableOpacity> + <TouchableOpacity + testID="signInButton" + style={[styles.btn, pal.btn]} + onPress={onPressSignin} + // TODO: web accessibility + accessibilityRole="button"> + <Text style={[pal.text, styles.btnLabel]}> + <Trans>Sign In</Trans> + </Text> + </TouchableOpacity> + </View> + </ErrorBoundary> + </View> + <Footer styles={styles} /> + </CenteredView> + </> ) } diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx index 1d64cc067..ab6d34584 100644 --- a/src/view/com/auth/create/CreateAccount.tsx +++ b/src/view/com/auth/create/CreateAccount.tsx @@ -7,78 +7,134 @@ import { TouchableOpacity, View, } from 'react-native' -import {observer} from 'mobx-react-lite' import {useAnalytics} from 'lib/analytics/analytics' import {Text} from '../../util/text/Text' import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout' import {s} from 'lib/styles' -import {useStores} from 'state/index' -import {CreateAccountModel} from 'state/models/ui/create-account' import {usePalette} from 'lib/hooks/usePalette' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useOnboardingDispatch} from '#/state/shell' +import {useSessionApi} from '#/state/session' +import {useCreateAccount, submit} from './state' +import {useServiceQuery} from '#/state/queries/service' +import { + usePreferencesSetBirthDateMutation, + useSetSaveFeedsMutation, + DEFAULT_PROD_FEEDS, +} from '#/state/queries/preferences' +import {IS_PROD} from '#/lib/constants' import {Step1} from './Step1' import {Step2} from './Step2' import {Step3} from './Step3' -export const CreateAccount = observer(function CreateAccountImpl({ - onPressBack, -}: { - onPressBack: () => void -}) { +export function CreateAccount({onPressBack}: {onPressBack: () => void}) { const {track, screen} = useAnalytics() const pal = usePalette('default') - const store = useStores() - const model = React.useMemo(() => new CreateAccountModel(store), [store]) + const {_} = useLingui() + const [uiState, uiDispatch] = useCreateAccount() + const onboardingDispatch = useOnboardingDispatch() + const {createAccount} = useSessionApi() + const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation() + const {mutate: setSavedFeeds} = useSetSaveFeedsMutation() React.useEffect(() => { screen('CreateAccount') }, [screen]) + // fetch service info + // = + + const { + data: serviceInfo, + isFetching: serviceInfoIsFetching, + error: serviceInfoError, + refetch: refetchServiceInfo, + } = useServiceQuery(uiState.serviceUrl) + React.useEffect(() => { - model.fetchServiceDescription() - }, [model]) + if (serviceInfo) { + uiDispatch({type: 'set-service-description', value: serviceInfo}) + uiDispatch({type: 'set-error', value: ''}) + } else if (serviceInfoError) { + uiDispatch({ + type: 'set-error', + value: _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), + }) + } + }, [_, uiDispatch, serviceInfo, serviceInfoError]) - const onPressRetryConnect = React.useCallback( - () => model.fetchServiceDescription(), - [model], - ) + // event handlers + // = const onPressBackInner = React.useCallback(() => { - if (model.canBack) { - model.back() + if (uiState.canBack) { + uiDispatch({type: 'back'}) } else { onPressBack() } - }, [model, onPressBack]) + }, [uiState, uiDispatch, onPressBack]) const onPressNext = React.useCallback(async () => { - if (!model.canNext) { + if (!uiState.canNext) { return } - if (model.step < 3) { - model.next() + if (uiState.step < 3) { + uiDispatch({type: 'next'}) } else { try { - await model.submit() + await submit({ + onboardingDispatch, + createAccount, + uiState, + uiDispatch, + _, + }) + track('Create Account') + setBirthDate({birthDate: uiState.birthDate}) + if (IS_PROD(uiState.serviceUrl)) { + setSavedFeeds(DEFAULT_PROD_FEEDS) + } } catch { // dont need to handle here } finally { track('Try Create Account') } } - }, [model, track]) + }, [ + uiState, + uiDispatch, + track, + onboardingDispatch, + createAccount, + setBirthDate, + setSavedFeeds, + _, + ]) + + // rendering + // = return ( <LoggedOutLayout - leadin={`Step ${model.step}`} - title="Create Account" - description="We're so excited to have you join us!"> + leadin={`Step ${uiState.step}`} + title={_(msg`Create Account`)} + description={_(msg`We're so excited to have you join us!`)}> <ScrollView testID="createAccount" style={pal.view}> <KeyboardAvoidingView behavior="padding"> <View style={styles.stepContainer}> - {model.step === 1 && <Step1 model={model} />} - {model.step === 2 && <Step2 model={model} />} - {model.step === 3 && <Step3 model={model} />} + {uiState.step === 1 && ( + <Step1 uiState={uiState} uiDispatch={uiDispatch} /> + )} + {uiState.step === 2 && ( + <Step2 uiState={uiState} uiDispatch={uiDispatch} /> + )} + {uiState.step === 3 && ( + <Step3 uiState={uiState} uiDispatch={uiDispatch} /> + )} </View> <View style={[s.flexRow, s.pl20, s.pr20]}> <TouchableOpacity @@ -86,40 +142,40 @@ export const CreateAccount = observer(function CreateAccountImpl({ testID="backBtn" accessibilityRole="button"> <Text type="xl" style={pal.link}> - Back + <Trans>Back</Trans> </Text> </TouchableOpacity> <View style={s.flex1} /> - {model.canNext ? ( + {uiState.canNext ? ( <TouchableOpacity testID="nextBtn" onPress={onPressNext} accessibilityRole="button"> - {model.isProcessing ? ( + {uiState.isProcessing ? ( <ActivityIndicator /> ) : ( <Text type="xl-bold" style={[pal.link, s.pr5]}> - Next + <Trans>Next</Trans> </Text> )} </TouchableOpacity> - ) : model.didServiceDescriptionFetchFail ? ( + ) : serviceInfoError ? ( <TouchableOpacity testID="retryConnectBtn" - onPress={onPressRetryConnect} + onPress={() => refetchServiceInfo()} accessibilityRole="button" - accessibilityLabel="Retry" - accessibilityHint="Retries account creation" + accessibilityLabel={_(msg`Retry`)} + accessibilityHint="" accessibilityLiveRegion="polite"> <Text type="xl-bold" style={[pal.link, s.pr5]}> - Retry + <Trans>Retry</Trans> </Text> </TouchableOpacity> - ) : model.isFetchingServiceDescription ? ( + ) : serviceInfoIsFetching ? ( <> <ActivityIndicator color="#fff" /> <Text type="xl" style={[pal.text, s.pr5]}> - Connecting... + <Trans>Connecting...</Trans> </Text> </> ) : undefined} @@ -129,7 +185,7 @@ export const CreateAccount = observer(function CreateAccountImpl({ </ScrollView> </LoggedOutLayout> ) -}) +} const styles = StyleSheet.create({ stepContainer: { diff --git a/src/view/com/auth/create/Policies.tsx b/src/view/com/auth/create/Policies.tsx index 8eb669bcf..a52f07531 100644 --- a/src/view/com/auth/create/Policies.tsx +++ b/src/view/com/auth/create/Policies.tsx @@ -4,12 +4,14 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' +import {ComAtprotoServerDescribeServer} from '@atproto/api' import {TextLink} from '../../util/Link' import {Text} from '../../util/text/Text' import {s, colors} from 'lib/styles' -import {ServiceDescription} from 'state/models/session' import {usePalette} from 'lib/hooks/usePalette' +type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema + export const Policies = ({ serviceDescription, needsGuardian, @@ -93,7 +95,7 @@ function validWebLink(url?: string): string | undefined { const styles = StyleSheet.create({ policies: { - flexDirection: 'row', + flexDirection: 'column', gap: 8, }, errorIcon: { diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx index cdd5cb21d..c9d19e868 100644 --- a/src/view/com/auth/create/Step1.tsx +++ b/src/view/com/auth/create/Step1.tsx @@ -1,10 +1,8 @@ import React from 'react' import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import debounce from 'lodash.debounce' import {Text} from 'view/com/util/text/Text' import {StepHeader} from './StepHeader' -import {CreateAccountModel} from 'state/models/ui/create-account' +import {CreateAccountState, CreateAccountDispatch} from './state' import {useTheme} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' @@ -12,60 +10,49 @@ import {HelpTip} from '../util/HelpTip' import {TextInput} from '../util/TextInput' import {Button} from 'view/com/util/forms/Button' import {ErrorMessage} from 'view/com/util/error/ErrorMessage' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' -import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'state/index' +import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'lib/constants' import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags' /** STEP 1: Your hosting provider * @field Bluesky (default) * @field Other (staging, local dev, your own PDS, etc.) */ -export const Step1 = observer(function Step1Impl({ - model, +export function Step1({ + uiState, + uiDispatch, }: { - model: CreateAccountModel + uiState: CreateAccountState + uiDispatch: CreateAccountDispatch }) { const pal = usePalette('default') const [isDefaultSelected, setIsDefaultSelected] = React.useState(true) + const {_} = useLingui() const onPressDefault = React.useCallback(() => { setIsDefaultSelected(true) - model.setServiceUrl(PROD_SERVICE) - model.fetchServiceDescription() - }, [setIsDefaultSelected, model]) + uiDispatch({type: 'set-service-url', value: PROD_SERVICE}) + }, [setIsDefaultSelected, uiDispatch]) const onPressOther = React.useCallback(() => { setIsDefaultSelected(false) - model.setServiceUrl('https://') - model.setServiceDescription(undefined) - }, [setIsDefaultSelected, model]) - - const fetchServiceDescription = React.useMemo( - () => debounce(() => model.fetchServiceDescription(), 1e3), // debouce for 1 second (1e3 = 1000ms) - [model], - ) + uiDispatch({type: 'set-service-url', value: 'https://'}) + }, [setIsDefaultSelected, uiDispatch]) const onChangeServiceUrl = React.useCallback( (v: string) => { - model.setServiceUrl(v) - fetchServiceDescription() - }, - [model, fetchServiceDescription], - ) - - const onDebugChangeServiceUrl = React.useCallback( - (v: string) => { - model.setServiceUrl(v) - model.fetchServiceDescription() + uiDispatch({type: 'set-service-url', value: v}) }, - [model], + [uiDispatch], ) return ( <View> - <StepHeader step="1" title="Your hosting provider" /> + <StepHeader step="1" title={_(msg`Your hosting provider`)} /> <Text style={[pal.text, s.mb10]}> - This is the service that keeps you online. + <Trans>This is the service that keeps you online.</Trans> </Text> <Option testID="blueskyServerBtn" @@ -81,17 +68,17 @@ export const Step1 = observer(function Step1Impl({ onPress={onPressOther}> <View style={styles.otherForm}> <Text nativeID="addressProvider" style={[pal.text, s.mb5]}> - Enter the address of your provider: + <Trans>Enter the address of your provider:</Trans> </Text> <TextInput testID="customServerInput" icon="globe" - placeholder="Hosting provider address" - value={model.serviceUrl} + placeholder={_(msg`Hosting provider address`)} + value={uiState.serviceUrl} editable onChange={onChangeServiceUrl} accessibilityHint="Input hosting provider address" - accessibilityLabel="Hosting provider address" + accessibilityLabel={_(msg`Hosting provider address`)} accessibilityLabelledBy="addressProvider" /> {LOGIN_INCLUDE_DEV_SERVERS && ( @@ -100,27 +87,27 @@ export const Step1 = observer(function Step1Impl({ testID="stagingServerBtn" type="default" style={s.mr5} - label="Staging" - onPress={() => onDebugChangeServiceUrl(STAGING_SERVICE)} + label={_(msg`Staging`)} + onPress={() => onChangeServiceUrl(STAGING_SERVICE)} /> <Button testID="localDevServerBtn" type="default" - label="Dev Server" - onPress={() => onDebugChangeServiceUrl(LOCAL_DEV_SERVICE)} + label={_(msg`Dev Server`)} + onPress={() => onChangeServiceUrl(LOCAL_DEV_SERVICE)} /> </View> )} </View> </Option> - {model.error ? ( - <ErrorMessage message={model.error} style={styles.error} /> + {uiState.error ? ( + <ErrorMessage message={uiState.error} style={styles.error} /> ) : ( - <HelpTip text="You can change hosting providers at any time." /> + <HelpTip text={_(msg`You can change hosting providers at any time.`)} /> )} </View> ) -}) +} function Option({ children, diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx index 60e197564..89fd070ad 100644 --- a/src/view/com/auth/create/Step2.tsx +++ b/src/view/com/auth/create/Step2.tsx @@ -1,7 +1,6 @@ import React from 'react' import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {CreateAccountModel} from 'state/models/ui/create-account' +import {CreateAccountState, CreateAccountDispatch, is18} from './state' import {Text} from 'view/com/util/text/Text' import {DateInput} from 'view/com/util/forms/DateInput' import {StepHeader} from './StepHeader' @@ -10,8 +9,10 @@ import {usePalette} from 'lib/hooks/usePalette' import {TextInput} from '../util/TextInput' import {Policies} from './Policies' import {ErrorMessage} from 'view/com/util/error/ErrorMessage' -import {useStores} from 'state/index' import {isWeb} from 'platform/detection' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' /** STEP 2: Your account * @field Invite code or waitlist @@ -22,23 +23,26 @@ import {isWeb} from 'platform/detection' * @field Birth date * @readonly Terms of service & privacy policy */ -export const Step2 = observer(function Step2Impl({ - model, +export function Step2({ + uiState, + uiDispatch, }: { - model: CreateAccountModel + uiState: CreateAccountState + uiDispatch: CreateAccountDispatch }) { const pal = usePalette('default') - const store = useStores() + const {_} = useLingui() + const {openModal} = useModalControls() const onPressWaitlist = React.useCallback(() => { - store.shell.openModal({name: 'waitlist'}) - }, [store]) + openModal({name: 'waitlist'}) + }, [openModal]) return ( <View> - <StepHeader step="2" title="Your account" /> + <StepHeader step="2" title={_(msg`Your account`)} /> - {model.isInviteCodeRequired && ( + {uiState.isInviteCodeRequired && ( <View style={s.pb20}> <Text type="md-medium" style={[pal.text, s.mb2]}> Invite code @@ -46,25 +50,27 @@ export const Step2 = observer(function Step2Impl({ <TextInput testID="inviteCodeInput" icon="ticket" - placeholder="Required for this provider" - value={model.inviteCode} + placeholder={_(msg`Required for this provider`)} + value={uiState.inviteCode} editable - onChange={model.setInviteCode} - accessibilityLabel="Invite code" + onChange={value => uiDispatch({type: 'set-invite-code', value})} + accessibilityLabel={_(msg`Invite code`)} accessibilityHint="Input invite code to proceed" /> </View> )} - {!model.inviteCode && model.isInviteCodeRequired ? ( + {!uiState.inviteCode && uiState.isInviteCodeRequired ? ( <Text style={[s.alignBaseline, pal.text]}> Don't have an invite code?{' '} <TouchableWithoutFeedback onPress={onPressWaitlist} - accessibilityLabel="Join the waitlist." + accessibilityLabel={_(msg`Join the waitlist.`)} accessibilityHint=""> <View style={styles.touchable}> - <Text style={pal.link}>Join the waitlist.</Text> + <Text style={pal.link}> + <Trans>Join the waitlist.</Trans> + </Text> </View> </TouchableWithoutFeedback> </Text> @@ -72,16 +78,16 @@ export const Step2 = observer(function Step2Impl({ <> <View style={s.pb20}> <Text type="md-medium" style={[pal.text, s.mb2]} nativeID="email"> - Email address + <Trans>Email address</Trans> </Text> <TextInput testID="emailInput" icon="envelope" - placeholder="Enter your email address" - value={model.email} + placeholder={_(msg`Enter your email address`)} + value={uiState.email} editable - onChange={model.setEmail} - accessibilityLabel="Email" + onChange={value => uiDispatch({type: 'set-email', value})} + accessibilityLabel={_(msg`Email`)} accessibilityHint="Input email for Bluesky waitlist" accessibilityLabelledBy="email" /> @@ -92,17 +98,17 @@ export const Step2 = observer(function Step2Impl({ type="md-medium" style={[pal.text, s.mb2]} nativeID="password"> - Password + <Trans>Password</Trans> </Text> <TextInput testID="passwordInput" icon="lock" - placeholder="Choose your password" - value={model.password} + placeholder={_(msg`Choose your password`)} + value={uiState.password} editable secureTextEntry - onChange={model.setPassword} - accessibilityLabel="Password" + onChange={value => uiDispatch({type: 'set-password', value})} + accessibilityLabel={_(msg`Password`)} accessibilityHint="Set password" accessibilityLabelledBy="password" /> @@ -113,35 +119,35 @@ export const Step2 = observer(function Step2Impl({ type="md-medium" style={[pal.text, s.mb2]} nativeID="birthDate"> - Your birth date + <Trans>Your birth date</Trans> </Text> <DateInput testID="birthdayInput" - value={model.birthDate} - onChange={model.setBirthDate} + value={uiState.birthDate} + onChange={value => uiDispatch({type: 'set-birth-date', value})} buttonType="default-light" buttonStyle={[pal.border, styles.dateInputButton]} buttonLabelType="lg" - accessibilityLabel="Birthday" + accessibilityLabel={_(msg`Birthday`)} accessibilityHint="Enter your birth date" accessibilityLabelledBy="birthDate" /> </View> - {model.serviceDescription && ( + {uiState.serviceDescription && ( <Policies - serviceDescription={model.serviceDescription} - needsGuardian={!model.isAge18} + serviceDescription={uiState.serviceDescription} + needsGuardian={!is18(uiState)} /> )} </> )} - {model.error ? ( - <ErrorMessage message={model.error} style={styles.error} /> + {uiState.error ? ( + <ErrorMessage message={uiState.error} style={styles.error} /> ) : undefined} </View> ) -}) +} const styles = StyleSheet.create({ error: { diff --git a/src/view/com/auth/create/Step3.tsx b/src/view/com/auth/create/Step3.tsx index beb756ac1..3b628b6b6 100644 --- a/src/view/com/auth/create/Step3.tsx +++ b/src/view/com/auth/create/Step3.tsx @@ -1,7 +1,6 @@ import React from 'react' import {StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {CreateAccountModel} from 'state/models/ui/create-account' +import {CreateAccountState, CreateAccountDispatch} from './state' import {Text} from 'view/com/util/text/Text' import {StepHeader} from './StepHeader' import {s} from 'lib/styles' @@ -9,44 +8,49 @@ import {TextInput} from '../util/TextInput' import {createFullHandle} from 'lib/strings/handles' import {usePalette} from 'lib/hooks/usePalette' import {ErrorMessage} from 'view/com/util/error/ErrorMessage' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' /** STEP 3: Your user handle * @field User handle */ -export const Step3 = observer(function Step3Impl({ - model, +export function Step3({ + uiState, + uiDispatch, }: { - model: CreateAccountModel + uiState: CreateAccountState + uiDispatch: CreateAccountDispatch }) { const pal = usePalette('default') + const {_} = useLingui() return ( <View> - <StepHeader step="3" title="Your user handle" /> + <StepHeader step="3" title={_(msg`Your user handle`)} /> <View style={s.pb10}> <TextInput testID="handleInput" icon="at" placeholder="e.g. alice" - value={model.handle} + value={uiState.handle} editable - onChange={model.setHandle} + onChange={value => uiDispatch({type: 'set-handle', value})} // TODO: Add explicit text label - accessibilityLabel="User handle" + accessibilityLabel={_(msg`User handle`)} accessibilityHint="Input your user handle" /> <Text type="lg" style={[pal.text, s.pl5, s.pt10]}> - Your full handle will be{' '} - <Text type="lg-bold" style={pal.text}> - @{createFullHandle(model.handle, model.userDomain)} + <Trans>Your full handle will be</Trans> + <Text type="lg-bold" style={[pal.text, s.ml5]}> + @{createFullHandle(uiState.handle, uiState.userDomain)} </Text> </Text> </View> - {model.error ? ( - <ErrorMessage message={model.error} style={styles.error} /> + {uiState.error ? ( + <ErrorMessage message={uiState.error} style={styles.error} /> ) : undefined} </View> ) -}) +} const styles = StyleSheet.create({ error: { diff --git a/src/view/com/auth/create/state.ts b/src/view/com/auth/create/state.ts new file mode 100644 index 000000000..4df82f8fc --- /dev/null +++ b/src/view/com/auth/create/state.ts @@ -0,0 +1,242 @@ +import {useReducer} from 'react' +import { + ComAtprotoServerDescribeServer, + ComAtprotoServerCreateAccount, +} from '@atproto/api' +import {I18nContext, useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import * as EmailValidator from 'email-validator' +import {getAge} from 'lib/strings/time' +import {logger} from '#/logger' +import {createFullHandle} from '#/lib/strings/handles' +import {cleanError} from '#/lib/strings/errors' +import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding' +import {ApiContext as SessionApiContext} from '#/state/session' +import {DEFAULT_SERVICE} from '#/lib/constants' + +export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema +const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago + +export type CreateAccountAction = + | {type: 'set-step'; value: number} + | {type: 'set-error'; value: string | undefined} + | {type: 'set-processing'; value: boolean} + | {type: 'set-service-url'; value: string} + | {type: 'set-service-description'; value: ServiceDescription | undefined} + | {type: 'set-user-domain'; value: string} + | {type: 'set-invite-code'; value: string} + | {type: 'set-email'; value: string} + | {type: 'set-password'; value: string} + | {type: 'set-handle'; value: string} + | {type: 'set-birth-date'; value: Date} + | {type: 'next'} + | {type: 'back'} + +export interface CreateAccountState { + // state + step: number + error: string | undefined + isProcessing: boolean + serviceUrl: string + serviceDescription: ServiceDescription | undefined + userDomain: string + inviteCode: string + email: string + password: string + handle: string + birthDate: Date + + // computed + canBack: boolean + canNext: boolean + isInviteCodeRequired: boolean +} + +export type CreateAccountDispatch = (action: CreateAccountAction) => void + +export function useCreateAccount() { + const {_} = useLingui() + return useReducer(createReducer({_}), { + step: 1, + error: undefined, + isProcessing: false, + serviceUrl: DEFAULT_SERVICE, + serviceDescription: undefined, + userDomain: '', + inviteCode: '', + email: '', + password: '', + handle: '', + birthDate: DEFAULT_DATE, + + canBack: false, + canNext: false, + isInviteCodeRequired: false, + }) +} + +export async function submit({ + createAccount, + onboardingDispatch, + uiState, + uiDispatch, + _, +}: { + createAccount: SessionApiContext['createAccount'] + onboardingDispatch: OnboardingDispatchContext + uiState: CreateAccountState + uiDispatch: CreateAccountDispatch + _: I18nContext['_'] +}) { + if (!uiState.email) { + uiDispatch({type: 'set-step', value: 2}) + return uiDispatch({ + type: 'set-error', + value: _(msg`Please enter your email.`), + }) + } + if (!EmailValidator.validate(uiState.email)) { + uiDispatch({type: 'set-step', value: 2}) + return uiDispatch({ + type: 'set-error', + value: _(msg`Your email appears to be invalid.`), + }) + } + if (!uiState.password) { + uiDispatch({type: 'set-step', value: 2}) + return uiDispatch({ + type: 'set-error', + value: _(msg`Please choose your password.`), + }) + } + if (!uiState.handle) { + uiDispatch({type: 'set-step', value: 3}) + return uiDispatch({ + type: 'set-error', + value: _(msg`Please choose your handle.`), + }) + } + uiDispatch({type: 'set-error', value: ''}) + uiDispatch({type: 'set-processing', value: true}) + + try { + onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view + await createAccount({ + service: uiState.serviceUrl, + email: uiState.email, + handle: createFullHandle(uiState.handle, uiState.userDomain), + password: uiState.password, + inviteCode: uiState.inviteCode.trim(), + }) + } catch (e: any) { + onboardingDispatch({type: 'skip'}) // undo starting the onboard + let errMsg = e.toString() + if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { + errMsg = _( + msg`Invite code not accepted. Check that you input it correctly and try again.`, + ) + } + logger.error('Failed to create account', {error: e}) + uiDispatch({type: 'set-processing', value: false}) + uiDispatch({type: 'set-error', value: cleanError(errMsg)}) + throw e + } +} + +export function is13(state: CreateAccountState) { + return getAge(state.birthDate) >= 18 +} + +export function is18(state: CreateAccountState) { + return getAge(state.birthDate) >= 18 +} + +function createReducer({_}: {_: I18nContext['_']}) { + return function reducer( + state: CreateAccountState, + action: CreateAccountAction, + ): CreateAccountState { + switch (action.type) { + case 'set-step': { + return compute({...state, step: action.value}) + } + case 'set-error': { + return compute({...state, error: action.value}) + } + case 'set-processing': { + return compute({...state, isProcessing: action.value}) + } + case 'set-service-url': { + return compute({ + ...state, + serviceUrl: action.value, + serviceDescription: + state.serviceUrl !== action.value + ? undefined + : state.serviceDescription, + }) + } + case 'set-service-description': { + return compute({ + ...state, + serviceDescription: action.value, + userDomain: action.value?.availableUserDomains[0] || '', + }) + } + case 'set-user-domain': { + return compute({...state, userDomain: action.value}) + } + case 'set-invite-code': { + return compute({...state, inviteCode: action.value}) + } + case 'set-email': { + return compute({...state, email: action.value}) + } + case 'set-password': { + return compute({...state, password: action.value}) + } + case 'set-handle': { + return compute({...state, handle: action.value}) + } + case 'set-birth-date': { + return compute({...state, birthDate: action.value}) + } + case 'next': { + if (state.step === 2) { + if (!is13(state)) { + return compute({ + ...state, + error: _( + msg`Unfortunately, you do not meet the requirements to create an account.`, + ), + }) + } + } + return compute({...state, error: '', step: state.step + 1}) + } + case 'back': { + return compute({...state, error: '', step: state.step - 1}) + } + } + } +} + +function compute(state: CreateAccountState): CreateAccountState { + let canNext = true + if (state.step === 1) { + canNext = !!state.serviceDescription + } else if (state.step === 2) { + canNext = + (!state.isInviteCodeRequired || !!state.inviteCode) && + !!state.email && + !!state.password + } else if (state.step === 3) { + canNext = !!state.handle + } + return { + ...state, + canBack: state.step > 1, + canNext, + isInviteCodeRequired: !!state.serviceDescription?.inviteCodeRequired, + } +} diff --git a/src/view/com/auth/login/ChooseAccountForm.tsx b/src/view/com/auth/login/ChooseAccountForm.tsx new file mode 100644 index 000000000..73ddfc9d6 --- /dev/null +++ b/src/view/com/auth/login/ChooseAccountForm.tsx @@ -0,0 +1,158 @@ +import React from 'react' +import {ScrollView, TouchableOpacity, View} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {useAnalytics} from 'lib/analytics/analytics' +import {Text} from '../../util/text/Text' +import {UserAvatar} from '../../util/UserAvatar' +import {s, colors} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {styles} from './styles' +import {useSession, useSessionApi, SessionAccount} from '#/state/session' +import {useProfileQuery} from '#/state/queries/profile' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' +import * as Toast from '#/view/com/util/Toast' + +function AccountItem({ + account, + onSelect, + isCurrentAccount, +}: { + account: SessionAccount + onSelect: (account: SessionAccount) => void + isCurrentAccount: boolean +}) { + const pal = usePalette('default') + const {_} = useLingui() + const {data: profile} = useProfileQuery({did: account.did}) + + const onPress = React.useCallback(() => { + onSelect(account) + }, [account, onSelect]) + + return ( + <TouchableOpacity + testID={`chooseAccountBtn-${account.handle}`} + key={account.did} + style={[pal.view, pal.border, styles.account]} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={_(msg`Sign in as ${account.handle}`)} + accessibilityHint="Double tap to sign in"> + <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> + <View style={s.p10}> + <UserAvatar avatar={profile?.avatar} size={30} /> + </View> + <Text style={styles.accountText}> + <Text type="lg-bold" style={pal.text}> + {profile?.displayName || account.handle}{' '} + </Text> + <Text type="lg" style={[pal.textLight]}> + {account.handle} + </Text> + </Text> + {isCurrentAccount ? ( + <FontAwesomeIcon + icon="check" + size={16} + style={[{color: colors.green3} as FontAwesomeIconStyle, s.mr10]} + /> + ) : ( + <FontAwesomeIcon + icon="angle-right" + size={16} + style={[pal.text, s.mr10]} + /> + )} + </View> + </TouchableOpacity> + ) +} +export const ChooseAccountForm = ({ + onSelectAccount, + onPressBack, +}: { + onSelectAccount: (account?: SessionAccount) => void + onPressBack: () => void +}) => { + const {track, screen} = useAnalytics() + const pal = usePalette('default') + const {_} = useLingui() + const {accounts, currentAccount} = useSession() + const {initSession} = useSessionApi() + const {setShowLoggedOut} = useLoggedOutViewControls() + + React.useEffect(() => { + screen('Choose Account') + }, [screen]) + + const onSelect = React.useCallback( + async (account: SessionAccount) => { + if (account.accessJwt) { + if (account.did === currentAccount?.did) { + setShowLoggedOut(false) + Toast.show(`Already signed in as @${account.handle}`) + } else { + await initSession(account) + track('Sign In', {resumedSession: true}) + setTimeout(() => { + Toast.show(`Signed in as @${account.handle}`) + }, 100) + } + } else { + onSelectAccount(account) + } + }, + [currentAccount, track, initSession, onSelectAccount, setShowLoggedOut], + ) + + return ( + <ScrollView testID="chooseAccountForm" style={styles.maxHeight}> + <Text + type="2xl-medium" + style={[pal.text, styles.groupLabel, s.mt5, s.mb10]}> + <Trans>Sign in as...</Trans> + </Text> + {accounts.map(account => ( + <AccountItem + key={account.did} + account={account} + onSelect={onSelect} + isCurrentAccount={account.did === currentAccount?.did} + /> + ))} + <TouchableOpacity + testID="chooseNewAccountBtn" + style={[pal.view, pal.border, styles.account, styles.accountLast]} + onPress={() => onSelectAccount(undefined)} + accessibilityRole="button" + accessibilityLabel={_(msg`Login to account that is not listed`)} + accessibilityHint=""> + <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> + <Text style={[styles.accountText, styles.accountTextOther]}> + <Text type="lg" style={pal.text}> + <Trans>Other account</Trans> + </Text> + </Text> + <FontAwesomeIcon + icon="angle-right" + size={16} + style={[pal.text, s.mr10]} + /> + </View> + </TouchableOpacity> + <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> + <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> + <Text type="xl" style={[pal.link, s.pl5]}> + <Trans>Back</Trans> + </Text> + </TouchableOpacity> + <View style={s.flex1} /> + </View> + </ScrollView> + ) +} diff --git a/src/view/com/auth/login/ForgotPasswordForm.tsx b/src/view/com/auth/login/ForgotPasswordForm.tsx new file mode 100644 index 000000000..215c393d9 --- /dev/null +++ b/src/view/com/auth/login/ForgotPasswordForm.tsx @@ -0,0 +1,197 @@ +import React, {useState, useEffect} from 'react' +import { + ActivityIndicator, + TextInput, + TouchableOpacity, + View, +} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {ComAtprotoServerDescribeServer} from '@atproto/api' +import * as EmailValidator from 'email-validator' +import {BskyAgent} from '@atproto/api' +import {useAnalytics} from 'lib/analytics/analytics' +import {Text} from '../../util/text/Text' +import {s} from 'lib/styles' +import {toNiceDomain} from 'lib/strings/url-helpers' +import {isNetworkError} from 'lib/strings/errors' +import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' +import {cleanError} from 'lib/strings/errors' +import {logger} from '#/logger' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {styles} from './styles' +import {useModalControls} from '#/state/modals' + +type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema + +export const ForgotPasswordForm = ({ + error, + serviceUrl, + serviceDescription, + setError, + setServiceUrl, + onPressBack, + onEmailSent, +}: { + error: string + serviceUrl: string + serviceDescription: ServiceDescription | undefined + setError: (v: string) => void + setServiceUrl: (v: string) => void + onPressBack: () => void + onEmailSent: () => void +}) => { + const pal = usePalette('default') + const theme = useTheme() + const [isProcessing, setIsProcessing] = useState<boolean>(false) + const [email, setEmail] = useState<string>('') + const {screen} = useAnalytics() + const {_} = useLingui() + const {openModal} = useModalControls() + + useEffect(() => { + screen('Signin:ForgotPassword') + }, [screen]) + + const onPressSelectService = () => { + openModal({ + name: 'server-input', + initialService: serviceUrl, + onSelect: setServiceUrl, + }) + } + + const onPressNext = async () => { + if (!EmailValidator.validate(email)) { + return setError('Your email appears to be invalid.') + } + + setError('') + setIsProcessing(true) + + try { + const agent = new BskyAgent({service: serviceUrl}) + await agent.com.atproto.server.requestPasswordReset({email}) + onEmailSent() + } catch (e: any) { + const errMsg = e.toString() + logger.warn('Failed to request password reset', {error: e}) + setIsProcessing(false) + if (isNetworkError(e)) { + setError( + 'Unable to contact your service. Please check your Internet connection.', + ) + } else { + setError(cleanError(errMsg)) + } + } + } + + return ( + <> + <View> + <Text type="title-lg" style={[pal.text, styles.screenTitle]}> + <Trans>Reset password</Trans> + </Text> + <Text type="md" style={[pal.text, styles.instructions]}> + <Trans> + Enter the email you used to create your account. We'll send you a + "reset code" so you can set a new password. + </Trans> + </Text> + <View + testID="forgotPasswordView" + style={[pal.borderDark, pal.view, styles.group]}> + <TouchableOpacity + testID="forgotPasswordSelectServiceButton" + style={[pal.borderDark, styles.groupContent, styles.noTopBorder]} + onPress={onPressSelectService} + accessibilityRole="button" + accessibilityLabel={_(msg`Hosting provider`)} + accessibilityHint="Sets hosting provider for password reset"> + <FontAwesomeIcon + icon="globe" + style={[pal.textLight, styles.groupContentIcon]} + /> + <Text style={[pal.text, styles.textInput]} numberOfLines={1}> + {toNiceDomain(serviceUrl)} + </Text> + <View style={[pal.btn, styles.textBtnFakeInnerBtn]}> + <FontAwesomeIcon + icon="pen" + size={12} + style={pal.text as FontAwesomeIconStyle} + /> + </View> + </TouchableOpacity> + <View style={[pal.borderDark, styles.groupContent]}> + <FontAwesomeIcon + icon="envelope" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="forgotPasswordEmail" + style={[pal.text, styles.textInput]} + placeholder="Email address" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoFocus + autoCorrect={false} + keyboardAppearance={theme.colorScheme} + value={email} + onChangeText={setEmail} + editable={!isProcessing} + accessibilityLabel={_(msg`Email`)} + accessibilityHint="Sets email for password reset" + /> + </View> + </View> + {error ? ( + <View style={styles.error}> + <View style={styles.errorIcon}> + <FontAwesomeIcon icon="exclamation" style={s.white} size={10} /> + </View> + <View style={s.flex1}> + <Text style={[s.white, s.bold]}>{error}</Text> + </View> + </View> + ) : undefined} + <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> + <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> + <Text type="xl" style={[pal.link, s.pl5]}> + <Trans>Back</Trans> + </Text> + </TouchableOpacity> + <View style={s.flex1} /> + {!serviceDescription || isProcessing ? ( + <ActivityIndicator /> + ) : !email ? ( + <Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}> + <Trans>Next</Trans> + </Text> + ) : ( + <TouchableOpacity + testID="newPasswordButton" + onPress={onPressNext} + accessibilityRole="button" + accessibilityLabel={_(msg`Go to next`)} + accessibilityHint="Navigates to the next screen"> + <Text type="xl-bold" style={[pal.link, s.pr5]}> + <Trans>Next</Trans> + </Text> + </TouchableOpacity> + )} + {!serviceDescription || isProcessing ? ( + <Text type="xl" style={[pal.textLight, s.pl10]}> + <Trans>Processing...</Trans> + </Text> + ) : undefined} + </View> + </View> + </> + ) +} diff --git a/src/view/com/auth/login/Login.tsx b/src/view/com/auth/login/Login.tsx index acc05b6ca..67d0afdf1 100644 --- a/src/view/com/auth/login/Login.tsx +++ b/src/view/com/auth/login/Login.tsx @@ -1,36 +1,19 @@ -import React, {useState, useEffect, useRef} from 'react' -import { - ActivityIndicator, - Keyboard, - KeyboardAvoidingView, - ScrollView, - StyleSheet, - TextInput, - TouchableOpacity, - View, -} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import * as EmailValidator from 'email-validator' -import {BskyAgent} from '@atproto/api' +import React, {useState, useEffect} from 'react' +import {KeyboardAvoidingView} from 'react-native' import {useAnalytics} from 'lib/analytics/analytics' -import {Text} from '../../util/text/Text' -import {UserAvatar} from '../../util/UserAvatar' import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout' -import {s, colors} from 'lib/styles' -import {createFullHandle} from 'lib/strings/handles' -import {toNiceDomain} from 'lib/strings/url-helpers' -import {useStores, RootStoreModel, DEFAULT_SERVICE} from 'state/index' -import {ServiceDescription} from 'state/models/session' -import {AccountData} from 'state/models/session' -import {isNetworkError} from 'lib/strings/errors' +import {DEFAULT_SERVICE} from '#/lib/constants' import {usePalette} from 'lib/hooks/usePalette' -import {useTheme} from 'lib/ThemeContext' -import {cleanError} from 'lib/strings/errors' -import {isWeb} from 'platform/detection' import {logger} from '#/logger' +import {ChooseAccountForm} from './ChooseAccountForm' +import {LoginForm} from './LoginForm' +import {ForgotPasswordForm} from './ForgotPasswordForm' +import {SetNewPasswordForm} from './SetNewPasswordForm' +import {PasswordUpdatedForm} from './PasswordUpdatedForm' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import {useSession, SessionAccount} from '#/state/session' +import {useServiceQuery} from '#/state/queries/service' enum Forms { Login, @@ -42,20 +25,22 @@ enum Forms { export const Login = ({onPressBack}: {onPressBack: () => void}) => { const pal = usePalette('default') - const store = useStores() + const {accounts} = useSession() const {track} = useAnalytics() + const {_} = useLingui() const [error, setError] = useState<string>('') - const [retryDescribeTrigger, setRetryDescribeTrigger] = useState<any>({}) const [serviceUrl, setServiceUrl] = useState<string>(DEFAULT_SERVICE) - const [serviceDescription, setServiceDescription] = useState< - ServiceDescription | undefined - >(undefined) const [initialHandle, setInitialHandle] = useState<string>('') const [currentForm, setCurrentForm] = useState<Forms>( - store.session.hasAccounts ? Forms.ChooseAccount : Forms.Login, + accounts.length ? Forms.ChooseAccount : Forms.Login, ) + const { + data: serviceDescription, + error: serviceError, + refetch: refetchService, + } = useServiceQuery(serviceUrl) - const onSelectAccount = (account?: AccountData) => { + const onSelectAccount = (account?: SessionAccount) => { if (account?.service) { setServiceUrl(account.service) } @@ -69,33 +54,21 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { } useEffect(() => { - let aborted = false - setError('') - store.session.describeService(serviceUrl).then( - desc => { - if (aborted) { - return - } - setServiceDescription(desc) - }, - err => { - if (aborted) { - return - } - logger.warn(`Failed to fetch service description for ${serviceUrl}`, { - error: err, - }) - setError( - 'Unable to contact your service. Please check your Internet connection.', - ) - }, - ) - return () => { - aborted = true + if (serviceError) { + setError( + _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), + ) + logger.warn(`Failed to fetch service description for ${serviceUrl}`, { + error: String(serviceError), + }) + } else { + setError('') } - }, [store.session, serviceUrl, retryDescribeTrigger]) + }, [serviceError, serviceUrl, _]) - const onPressRetryConnect = () => setRetryDescribeTrigger({}) + const onPressRetryConnect = () => refetchService() const onPressForgotPassword = () => { track('Signin:PressedForgotPassword') setCurrentForm(Forms.ForgotPassword) @@ -106,10 +79,9 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { {currentForm === Forms.Login ? ( <LoggedOutLayout leadin="" - title="Sign in" - description="Enter your username and password"> + title={_(msg`Sign in`)} + description={_(msg`Enter your username and password`)}> <LoginForm - store={store} error={error} serviceUrl={serviceUrl} serviceDescription={serviceDescription} @@ -125,10 +97,9 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { {currentForm === Forms.ChooseAccount ? ( <LoggedOutLayout leadin="" - title="Sign in as..." - description="Select from an existing account"> + title={_(msg`Sign in as...`)} + description={_(msg`Select from an existing account`)}> <ChooseAccountForm - store={store} onSelectAccount={onSelectAccount} onPressBack={onPressBack} /> @@ -137,10 +108,9 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { {currentForm === Forms.ForgotPassword ? ( <LoggedOutLayout leadin="" - title="Forgot Password" - description="Let's get your password reset!"> + title={_(msg`Forgot Password`)} + description={_(msg`Let's get your password reset!`)}> <ForgotPasswordForm - store={store} error={error} serviceUrl={serviceUrl} serviceDescription={serviceDescription} @@ -154,10 +124,9 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { {currentForm === Forms.SetNewPassword ? ( <LoggedOutLayout leadin="" - title="Forgot Password" - description="Let's get your password reset!"> + title={_(msg`Forgot Password`)} + description={_(msg`Let's get your password reset!`)}> <SetNewPasswordForm - store={store} error={error} serviceUrl={serviceUrl} setError={setError} @@ -167,834 +136,13 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => { </LoggedOutLayout> ) : undefined} {currentForm === Forms.PasswordUpdated ? ( - <PasswordUpdatedForm onPressNext={gotoForm(Forms.Login)} /> + <LoggedOutLayout + leadin="" + title={_(msg`Password updated`)} + description={_(msg`You can now sign in with your new password.`)}> + <PasswordUpdatedForm onPressNext={gotoForm(Forms.Login)} /> + </LoggedOutLayout> ) : undefined} </KeyboardAvoidingView> ) } - -const ChooseAccountForm = ({ - store, - onSelectAccount, - onPressBack, -}: { - store: RootStoreModel - onSelectAccount: (account?: AccountData) => void - onPressBack: () => void -}) => { - const {track, screen} = useAnalytics() - const pal = usePalette('default') - const [isProcessing, setIsProcessing] = React.useState(false) - - React.useEffect(() => { - screen('Choose Account') - }, [screen]) - - const onTryAccount = async (account: AccountData) => { - if (account.accessJwt && account.refreshJwt) { - setIsProcessing(true) - if (await store.session.resumeSession(account)) { - track('Sign In', {resumedSession: true}) - setIsProcessing(false) - return - } - setIsProcessing(false) - } - onSelectAccount(account) - } - - return ( - <ScrollView testID="chooseAccountForm" style={styles.maxHeight}> - <Text - type="2xl-medium" - style={[pal.text, styles.groupLabel, s.mt5, s.mb10]}> - Sign in as... - </Text> - {store.session.accounts.map(account => ( - <TouchableOpacity - testID={`chooseAccountBtn-${account.handle}`} - key={account.did} - style={[pal.view, pal.border, styles.account]} - onPress={() => onTryAccount(account)} - accessibilityRole="button" - accessibilityLabel={`Sign in as ${account.handle}`} - accessibilityHint="Double tap to sign in"> - <View - style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> - <View style={s.p10}> - <UserAvatar avatar={account.aviUrl} size={30} /> - </View> - <Text style={styles.accountText}> - <Text type="lg-bold" style={pal.text}> - {account.displayName || account.handle}{' '} - </Text> - <Text type="lg" style={[pal.textLight]}> - {account.handle} - </Text> - </Text> - <FontAwesomeIcon - icon="angle-right" - size={16} - style={[pal.text, s.mr10]} - /> - </View> - </TouchableOpacity> - ))} - <TouchableOpacity - testID="chooseNewAccountBtn" - style={[pal.view, pal.border, styles.account, styles.accountLast]} - onPress={() => onSelectAccount(undefined)} - accessibilityRole="button" - accessibilityLabel="Login to account that is not listed" - accessibilityHint=""> - <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> - <Text style={[styles.accountText, styles.accountTextOther]}> - <Text type="lg" style={pal.text}> - Other account - </Text> - </Text> - <FontAwesomeIcon - icon="angle-right" - size={16} - style={[pal.text, s.mr10]} - /> - </View> - </TouchableOpacity> - <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> - <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> - <Text type="xl" style={[pal.link, s.pl5]}> - Back - </Text> - </TouchableOpacity> - <View style={s.flex1} /> - {isProcessing && <ActivityIndicator />} - </View> - </ScrollView> - ) -} - -const LoginForm = ({ - store, - error, - serviceUrl, - serviceDescription, - initialHandle, - setError, - setServiceUrl, - onPressRetryConnect, - onPressBack, - onPressForgotPassword, -}: { - store: RootStoreModel - error: string - serviceUrl: string - serviceDescription: ServiceDescription | undefined - initialHandle: string - setError: (v: string) => void - setServiceUrl: (v: string) => void - onPressRetryConnect: () => void - onPressBack: () => void - onPressForgotPassword: () => void -}) => { - const {track} = useAnalytics() - const pal = usePalette('default') - const theme = useTheme() - const [isProcessing, setIsProcessing] = useState<boolean>(false) - const [identifier, setIdentifier] = useState<string>(initialHandle) - const [password, setPassword] = useState<string>('') - const passwordInputRef = useRef<TextInput>(null) - - const onPressSelectService = () => { - store.shell.openModal({ - name: 'server-input', - initialService: serviceUrl, - onSelect: setServiceUrl, - }) - Keyboard.dismiss() - track('Signin:PressedSelectService') - } - - const onPressNext = async () => { - Keyboard.dismiss() - setError('') - setIsProcessing(true) - - try { - // try to guess the handle if the user just gave their own username - let fullIdent = identifier - if ( - !identifier.includes('@') && // not an email - !identifier.includes('.') && // not a domain - serviceDescription && - serviceDescription.availableUserDomains.length > 0 - ) { - let matched = false - for (const domain of serviceDescription.availableUserDomains) { - if (fullIdent.endsWith(domain)) { - matched = true - } - } - if (!matched) { - fullIdent = createFullHandle( - identifier, - serviceDescription.availableUserDomains[0], - ) - } - } - - await store.session.login({ - service: serviceUrl, - identifier: fullIdent, - password, - }) - } catch (e: any) { - const errMsg = e.toString() - logger.warn('Failed to login', {error: e}) - setIsProcessing(false) - if (errMsg.includes('Authentication Required')) { - setError('Invalid username or password') - } else if (isNetworkError(e)) { - setError( - 'Unable to contact your service. Please check your Internet connection.', - ) - } else { - setError(cleanError(errMsg)) - } - } finally { - track('Sign In', {resumedSession: false}) - } - } - - const isReady = !!serviceDescription && !!identifier && !!password - return ( - <View testID="loginForm"> - <Text type="sm-bold" style={[pal.text, styles.groupLabel]}> - Sign into - </Text> - <View style={[pal.borderDark, styles.group]}> - <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> - <FontAwesomeIcon - icon="globe" - style={[pal.textLight, styles.groupContentIcon]} - /> - <TouchableOpacity - testID="loginSelectServiceButton" - style={styles.textBtn} - onPress={onPressSelectService} - accessibilityRole="button" - accessibilityLabel="Select service" - accessibilityHint="Sets server for the Bluesky client"> - <Text type="xl" style={[pal.text, styles.textBtnLabel]}> - {toNiceDomain(serviceUrl)} - </Text> - <View style={[pal.btn, styles.textBtnFakeInnerBtn]}> - <FontAwesomeIcon - icon="pen" - size={12} - style={pal.textLight as FontAwesomeIconStyle} - /> - </View> - </TouchableOpacity> - </View> - </View> - <Text type="sm-bold" style={[pal.text, styles.groupLabel]}> - Account - </Text> - <View style={[pal.borderDark, styles.group]}> - <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> - <FontAwesomeIcon - icon="at" - style={[pal.textLight, styles.groupContentIcon]} - /> - <TextInput - testID="loginUsernameInput" - style={[pal.text, styles.textInput]} - placeholder="Username or email address" - placeholderTextColor={pal.colors.textLight} - autoCapitalize="none" - autoFocus - autoCorrect={false} - autoComplete="username" - returnKeyType="next" - onSubmitEditing={() => { - passwordInputRef.current?.focus() - }} - blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field - keyboardAppearance={theme.colorScheme} - value={identifier} - onChangeText={str => - setIdentifier((str || '').toLowerCase().trim()) - } - editable={!isProcessing} - accessibilityLabel="Username or email address" - accessibilityHint="Input the username or email address you used at signup" - /> - </View> - <View style={[pal.borderDark, styles.groupContent]}> - <FontAwesomeIcon - icon="lock" - style={[pal.textLight, styles.groupContentIcon]} - /> - <TextInput - testID="loginPasswordInput" - ref={passwordInputRef} - style={[pal.text, styles.textInput]} - placeholder="Password" - placeholderTextColor={pal.colors.textLight} - autoCapitalize="none" - autoCorrect={false} - autoComplete="password" - returnKeyType="done" - enablesReturnKeyAutomatically={true} - keyboardAppearance={theme.colorScheme} - secureTextEntry={true} - textContentType="password" - clearButtonMode="while-editing" - value={password} - onChangeText={setPassword} - onSubmitEditing={onPressNext} - blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing - editable={!isProcessing} - accessibilityLabel="Password" - accessibilityHint={ - identifier === '' - ? 'Input your password' - : `Input the password tied to ${identifier}` - } - /> - <TouchableOpacity - testID="forgotPasswordButton" - style={styles.textInputInnerBtn} - onPress={onPressForgotPassword} - accessibilityRole="button" - accessibilityLabel="Forgot password" - accessibilityHint="Opens password reset form"> - <Text style={pal.link}>Forgot</Text> - </TouchableOpacity> - </View> - </View> - {error ? ( - <View style={styles.error}> - <View style={styles.errorIcon}> - <FontAwesomeIcon icon="exclamation" style={s.white} size={10} /> - </View> - <View style={s.flex1}> - <Text style={[s.white, s.bold]}>{error}</Text> - </View> - </View> - ) : undefined} - <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> - <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> - <Text type="xl" style={[pal.link, s.pl5]}> - Back - </Text> - </TouchableOpacity> - <View style={s.flex1} /> - {!serviceDescription && error ? ( - <TouchableOpacity - testID="loginRetryButton" - onPress={onPressRetryConnect} - accessibilityRole="button" - accessibilityLabel="Retry" - accessibilityHint="Retries login"> - <Text type="xl-bold" style={[pal.link, s.pr5]}> - Retry - </Text> - </TouchableOpacity> - ) : !serviceDescription ? ( - <> - <ActivityIndicator /> - <Text type="xl" style={[pal.textLight, s.pl10]}> - Connecting... - </Text> - </> - ) : isProcessing ? ( - <ActivityIndicator /> - ) : isReady ? ( - <TouchableOpacity - testID="loginNextButton" - onPress={onPressNext} - accessibilityRole="button" - accessibilityLabel="Go to next" - accessibilityHint="Navigates to the next screen"> - <Text type="xl-bold" style={[pal.link, s.pr5]}> - Next - </Text> - </TouchableOpacity> - ) : undefined} - </View> - </View> - ) -} - -const ForgotPasswordForm = ({ - store, - error, - serviceUrl, - serviceDescription, - setError, - setServiceUrl, - onPressBack, - onEmailSent, -}: { - store: RootStoreModel - error: string - serviceUrl: string - serviceDescription: ServiceDescription | undefined - setError: (v: string) => void - setServiceUrl: (v: string) => void - onPressBack: () => void - onEmailSent: () => void -}) => { - const pal = usePalette('default') - const theme = useTheme() - const [isProcessing, setIsProcessing] = useState<boolean>(false) - const [email, setEmail] = useState<string>('') - const {screen} = useAnalytics() - - useEffect(() => { - screen('Signin:ForgotPassword') - }, [screen]) - - const onPressSelectService = () => { - store.shell.openModal({ - name: 'server-input', - initialService: serviceUrl, - onSelect: setServiceUrl, - }) - } - - const onPressNext = async () => { - if (!EmailValidator.validate(email)) { - return setError('Your email appears to be invalid.') - } - - setError('') - setIsProcessing(true) - - try { - const agent = new BskyAgent({service: serviceUrl}) - await agent.com.atproto.server.requestPasswordReset({email}) - onEmailSent() - } catch (e: any) { - const errMsg = e.toString() - logger.warn('Failed to request password reset', {error: e}) - setIsProcessing(false) - if (isNetworkError(e)) { - setError( - 'Unable to contact your service. Please check your Internet connection.', - ) - } else { - setError(cleanError(errMsg)) - } - } - } - - return ( - <> - <View> - <Text type="title-lg" style={[pal.text, styles.screenTitle]}> - Reset password - </Text> - <Text type="md" style={[pal.text, styles.instructions]}> - Enter the email you used to create your account. We'll send you a - "reset code" so you can set a new password. - </Text> - <View - testID="forgotPasswordView" - style={[pal.borderDark, pal.view, styles.group]}> - <TouchableOpacity - testID="forgotPasswordSelectServiceButton" - style={[pal.borderDark, styles.groupContent, styles.noTopBorder]} - onPress={onPressSelectService} - accessibilityRole="button" - accessibilityLabel="Hosting provider" - accessibilityHint="Sets hosting provider for password reset"> - <FontAwesomeIcon - icon="globe" - style={[pal.textLight, styles.groupContentIcon]} - /> - <Text style={[pal.text, styles.textInput]} numberOfLines={1}> - {toNiceDomain(serviceUrl)} - </Text> - <View style={[pal.btn, styles.textBtnFakeInnerBtn]}> - <FontAwesomeIcon - icon="pen" - size={12} - style={pal.text as FontAwesomeIconStyle} - /> - </View> - </TouchableOpacity> - <View style={[pal.borderDark, styles.groupContent]}> - <FontAwesomeIcon - icon="envelope" - style={[pal.textLight, styles.groupContentIcon]} - /> - <TextInput - testID="forgotPasswordEmail" - style={[pal.text, styles.textInput]} - placeholder="Email address" - placeholderTextColor={pal.colors.textLight} - autoCapitalize="none" - autoFocus - autoCorrect={false} - keyboardAppearance={theme.colorScheme} - value={email} - onChangeText={setEmail} - editable={!isProcessing} - accessibilityLabel="Email" - accessibilityHint="Sets email for password reset" - /> - </View> - </View> - {error ? ( - <View style={styles.error}> - <View style={styles.errorIcon}> - <FontAwesomeIcon icon="exclamation" style={s.white} size={10} /> - </View> - <View style={s.flex1}> - <Text style={[s.white, s.bold]}>{error}</Text> - </View> - </View> - ) : undefined} - <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> - <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> - <Text type="xl" style={[pal.link, s.pl5]}> - Back - </Text> - </TouchableOpacity> - <View style={s.flex1} /> - {!serviceDescription || isProcessing ? ( - <ActivityIndicator /> - ) : !email ? ( - <Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}> - Next - </Text> - ) : ( - <TouchableOpacity - testID="newPasswordButton" - onPress={onPressNext} - accessibilityRole="button" - accessibilityLabel="Go to next" - accessibilityHint="Navigates to the next screen"> - <Text type="xl-bold" style={[pal.link, s.pr5]}> - Next - </Text> - </TouchableOpacity> - )} - {!serviceDescription || isProcessing ? ( - <Text type="xl" style={[pal.textLight, s.pl10]}> - Processing... - </Text> - ) : undefined} - </View> - </View> - </> - ) -} - -const SetNewPasswordForm = ({ - error, - serviceUrl, - setError, - onPressBack, - onPasswordSet, -}: { - store: RootStoreModel - error: string - serviceUrl: string - setError: (v: string) => void - onPressBack: () => void - onPasswordSet: () => void -}) => { - const pal = usePalette('default') - const theme = useTheme() - const {screen} = useAnalytics() - - useEffect(() => { - screen('Signin:SetNewPasswordForm') - }, [screen]) - - const [isProcessing, setIsProcessing] = useState<boolean>(false) - const [resetCode, setResetCode] = useState<string>('') - const [password, setPassword] = useState<string>('') - - const onPressNext = async () => { - setError('') - setIsProcessing(true) - - try { - const agent = new BskyAgent({service: serviceUrl}) - const token = resetCode.replace(/\s/g, '') - await agent.com.atproto.server.resetPassword({ - token, - password, - }) - onPasswordSet() - } catch (e: any) { - const errMsg = e.toString() - logger.warn('Failed to set new password', {error: e}) - setIsProcessing(false) - if (isNetworkError(e)) { - setError( - 'Unable to contact your service. Please check your Internet connection.', - ) - } else { - setError(cleanError(errMsg)) - } - } - } - - return ( - <> - <View> - <Text type="title-lg" style={[pal.text, styles.screenTitle]}> - Set new password - </Text> - <Text type="lg" style={[pal.text, styles.instructions]}> - You will receive an email with a "reset code." Enter that code here, - then enter your new password. - </Text> - <View - testID="newPasswordView" - style={[pal.view, pal.borderDark, styles.group]}> - <View - style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> - <FontAwesomeIcon - icon="ticket" - style={[pal.textLight, styles.groupContentIcon]} - /> - <TextInput - testID="resetCodeInput" - style={[pal.text, styles.textInput]} - placeholder="Reset code" - placeholderTextColor={pal.colors.textLight} - autoCapitalize="none" - autoCorrect={false} - keyboardAppearance={theme.colorScheme} - autoFocus - value={resetCode} - onChangeText={setResetCode} - editable={!isProcessing} - accessible={true} - accessibilityLabel="Reset code" - accessibilityHint="Input code sent to your email for password reset" - /> - </View> - <View style={[pal.borderDark, styles.groupContent]}> - <FontAwesomeIcon - icon="lock" - style={[pal.textLight, styles.groupContentIcon]} - /> - <TextInput - testID="newPasswordInput" - style={[pal.text, styles.textInput]} - placeholder="New password" - placeholderTextColor={pal.colors.textLight} - autoCapitalize="none" - autoCorrect={false} - keyboardAppearance={theme.colorScheme} - secureTextEntry - value={password} - onChangeText={setPassword} - editable={!isProcessing} - accessible={true} - accessibilityLabel="Password" - accessibilityHint="Input new password" - /> - </View> - </View> - {error ? ( - <View style={styles.error}> - <View style={styles.errorIcon}> - <FontAwesomeIcon icon="exclamation" style={s.white} size={10} /> - </View> - <View style={s.flex1}> - <Text style={[s.white, s.bold]}>{error}</Text> - </View> - </View> - ) : undefined} - <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> - <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> - <Text type="xl" style={[pal.link, s.pl5]}> - Back - </Text> - </TouchableOpacity> - <View style={s.flex1} /> - {isProcessing ? ( - <ActivityIndicator /> - ) : !resetCode || !password ? ( - <Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}> - Next - </Text> - ) : ( - <TouchableOpacity - testID="setNewPasswordButton" - onPress={onPressNext} - accessibilityRole="button" - accessibilityLabel="Go to next" - accessibilityHint="Navigates to the next screen"> - <Text type="xl-bold" style={[pal.link, s.pr5]}> - Next - </Text> - </TouchableOpacity> - )} - {isProcessing ? ( - <Text type="xl" style={[pal.textLight, s.pl10]}> - Updating... - </Text> - ) : undefined} - </View> - </View> - </> - ) -} - -const PasswordUpdatedForm = ({onPressNext}: {onPressNext: () => void}) => { - const {screen} = useAnalytics() - - useEffect(() => { - screen('Signin:PasswordUpdatedForm') - }, [screen]) - - const pal = usePalette('default') - return ( - <> - <View> - <Text type="title-lg" style={[pal.text, styles.screenTitle]}> - Password updated! - </Text> - <Text type="lg" style={[pal.text, styles.instructions]}> - You can now sign in with your new password. - </Text> - <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> - <View style={s.flex1} /> - <TouchableOpacity - onPress={onPressNext} - accessibilityRole="button" - accessibilityLabel="Close alert" - accessibilityHint="Closes password update alert"> - <Text type="xl-bold" style={[pal.link, s.pr5]}> - Okay - </Text> - </TouchableOpacity> - </View> - </View> - </> - ) -} - -const styles = StyleSheet.create({ - screenTitle: { - marginBottom: 10, - marginHorizontal: 20, - }, - instructions: { - marginBottom: 20, - marginHorizontal: 20, - }, - group: { - borderWidth: 1, - borderRadius: 10, - marginBottom: 20, - marginHorizontal: 20, - }, - groupLabel: { - paddingHorizontal: 20, - paddingBottom: 5, - }, - groupContent: { - borderTopWidth: 1, - flexDirection: 'row', - alignItems: 'center', - }, - noTopBorder: { - borderTopWidth: 0, - }, - groupContentIcon: { - marginLeft: 10, - }, - account: { - borderTopWidth: 1, - paddingHorizontal: 20, - paddingVertical: 4, - }, - accountLast: { - borderBottomWidth: 1, - marginBottom: 20, - paddingVertical: 8, - }, - textInput: { - flex: 1, - width: '100%', - paddingVertical: 10, - paddingHorizontal: 12, - fontSize: 17, - letterSpacing: 0.25, - fontWeight: '400', - borderRadius: 10, - }, - textInputInnerBtn: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 6, - paddingHorizontal: 8, - marginHorizontal: 6, - }, - textBtn: { - flexDirection: 'row', - flex: 1, - alignItems: 'center', - }, - textBtnLabel: { - flex: 1, - paddingVertical: 10, - paddingHorizontal: 12, - }, - textBtnFakeInnerBtn: { - flexDirection: 'row', - alignItems: 'center', - borderRadius: 6, - paddingVertical: 6, - paddingHorizontal: 8, - marginHorizontal: 6, - }, - accountText: { - flex: 1, - flexDirection: 'row', - alignItems: 'baseline', - paddingVertical: 10, - }, - accountTextOther: { - paddingLeft: 12, - }, - error: { - backgroundColor: colors.red4, - flexDirection: 'row', - alignItems: 'center', - marginTop: -5, - marginHorizontal: 20, - marginBottom: 15, - borderRadius: 8, - paddingHorizontal: 8, - paddingVertical: 8, - }, - errorIcon: { - borderWidth: 1, - borderColor: colors.white, - color: colors.white, - borderRadius: 30, - width: 16, - height: 16, - alignItems: 'center', - justifyContent: 'center', - marginRight: 5, - }, - dimmed: {opacity: 0.5}, - - maxHeight: { - // @ts-ignore web only -prf - maxHeight: isWeb ? '100vh' : undefined, - height: !isWeb ? '100%' : undefined, - }, -}) diff --git a/src/view/com/auth/login/LoginForm.tsx b/src/view/com/auth/login/LoginForm.tsx new file mode 100644 index 000000000..365f2e253 --- /dev/null +++ b/src/view/com/auth/login/LoginForm.tsx @@ -0,0 +1,290 @@ +import React, {useState, useRef} from 'react' +import { + ActivityIndicator, + Keyboard, + TextInput, + TouchableOpacity, + View, +} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {ComAtprotoServerDescribeServer} from '@atproto/api' +import {useAnalytics} from 'lib/analytics/analytics' +import {Text} from '../../util/text/Text' +import {s} from 'lib/styles' +import {createFullHandle} from 'lib/strings/handles' +import {toNiceDomain} from 'lib/strings/url-helpers' +import {isNetworkError} from 'lib/strings/errors' +import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' +import {useSessionApi} from '#/state/session' +import {cleanError} from 'lib/strings/errors' +import {logger} from '#/logger' +import {Trans, msg} from '@lingui/macro' +import {styles} from './styles' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' + +type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema + +export const LoginForm = ({ + error, + serviceUrl, + serviceDescription, + initialHandle, + setError, + setServiceUrl, + onPressRetryConnect, + onPressBack, + onPressForgotPassword, +}: { + error: string + serviceUrl: string + serviceDescription: ServiceDescription | undefined + initialHandle: string + setError: (v: string) => void + setServiceUrl: (v: string) => void + onPressRetryConnect: () => void + onPressBack: () => void + onPressForgotPassword: () => void +}) => { + const {track} = useAnalytics() + const pal = usePalette('default') + const theme = useTheme() + const [isProcessing, setIsProcessing] = useState<boolean>(false) + const [identifier, setIdentifier] = useState<string>(initialHandle) + const [password, setPassword] = useState<string>('') + const passwordInputRef = useRef<TextInput>(null) + const {_} = useLingui() + const {openModal} = useModalControls() + const {login} = useSessionApi() + + const onPressSelectService = () => { + openModal({ + name: 'server-input', + initialService: serviceUrl, + onSelect: setServiceUrl, + }) + Keyboard.dismiss() + track('Signin:PressedSelectService') + } + + const onPressNext = async () => { + Keyboard.dismiss() + setError('') + setIsProcessing(true) + + try { + // try to guess the handle if the user just gave their own username + let fullIdent = identifier + if ( + !identifier.includes('@') && // not an email + !identifier.includes('.') && // not a domain + serviceDescription && + serviceDescription.availableUserDomains.length > 0 + ) { + let matched = false + for (const domain of serviceDescription.availableUserDomains) { + if (fullIdent.endsWith(domain)) { + matched = true + } + } + if (!matched) { + fullIdent = createFullHandle( + identifier, + serviceDescription.availableUserDomains[0], + ) + } + } + + // TODO remove double login + await login({ + service: serviceUrl, + identifier: fullIdent, + password, + }) + } catch (e: any) { + const errMsg = e.toString() + logger.warn('Failed to login', {error: e}) + setIsProcessing(false) + if (errMsg.includes('Authentication Required')) { + setError(_(msg`Invalid username or password`)) + } else if (isNetworkError(e)) { + setError( + _( + msg`Unable to contact your service. Please check your Internet connection.`, + ), + ) + } else { + setError(cleanError(errMsg)) + } + } finally { + track('Sign In', {resumedSession: false}) + } + } + + const isReady = !!serviceDescription && !!identifier && !!password + return ( + <View testID="loginForm"> + <Text type="sm-bold" style={[pal.text, styles.groupLabel]}> + <Trans>Sign into</Trans> + </Text> + <View style={[pal.borderDark, styles.group]}> + <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> + <FontAwesomeIcon + icon="globe" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TouchableOpacity + testID="loginSelectServiceButton" + style={styles.textBtn} + onPress={onPressSelectService} + accessibilityRole="button" + accessibilityLabel={_(msg`Select service`)} + accessibilityHint="Sets server for the Bluesky client"> + <Text type="xl" style={[pal.text, styles.textBtnLabel]}> + {toNiceDomain(serviceUrl)} + </Text> + <View style={[pal.btn, styles.textBtnFakeInnerBtn]}> + <FontAwesomeIcon + icon="pen" + size={12} + style={pal.textLight as FontAwesomeIconStyle} + /> + </View> + </TouchableOpacity> + </View> + </View> + <Text type="sm-bold" style={[pal.text, styles.groupLabel]}> + <Trans>Account</Trans> + </Text> + <View style={[pal.borderDark, styles.group]}> + <View style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> + <FontAwesomeIcon + icon="at" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="loginUsernameInput" + style={[pal.text, styles.textInput]} + placeholder={_(msg`Username or email address`)} + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoFocus + autoCorrect={false} + autoComplete="username" + returnKeyType="next" + onSubmitEditing={() => { + passwordInputRef.current?.focus() + }} + blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field + keyboardAppearance={theme.colorScheme} + value={identifier} + onChangeText={str => + setIdentifier((str || '').toLowerCase().trim()) + } + editable={!isProcessing} + accessibilityLabel={_(msg`Username or email address`)} + accessibilityHint="Input the username or email address you used at signup" + /> + </View> + <View style={[pal.borderDark, styles.groupContent]}> + <FontAwesomeIcon + icon="lock" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="loginPasswordInput" + ref={passwordInputRef} + style={[pal.text, styles.textInput]} + placeholder="Password" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoCorrect={false} + autoComplete="password" + returnKeyType="done" + enablesReturnKeyAutomatically={true} + keyboardAppearance={theme.colorScheme} + secureTextEntry={true} + textContentType="password" + clearButtonMode="while-editing" + value={password} + onChangeText={setPassword} + onSubmitEditing={onPressNext} + blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing + editable={!isProcessing} + accessibilityLabel={_(msg`Password`)} + accessibilityHint={ + identifier === '' + ? 'Input your password' + : `Input the password tied to ${identifier}` + } + /> + <TouchableOpacity + testID="forgotPasswordButton" + style={styles.textInputInnerBtn} + onPress={onPressForgotPassword} + accessibilityRole="button" + accessibilityLabel={_(msg`Forgot password`)} + accessibilityHint="Opens password reset form"> + <Text style={pal.link}> + <Trans>Forgot</Trans> + </Text> + </TouchableOpacity> + </View> + </View> + {error ? ( + <View style={styles.error}> + <View style={styles.errorIcon}> + <FontAwesomeIcon icon="exclamation" style={s.white} size={10} /> + </View> + <View style={s.flex1}> + <Text style={[s.white, s.bold]}>{error}</Text> + </View> + </View> + ) : undefined} + <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> + <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> + <Text type="xl" style={[pal.link, s.pl5]}> + <Trans>Back</Trans> + </Text> + </TouchableOpacity> + <View style={s.flex1} /> + {!serviceDescription && error ? ( + <TouchableOpacity + testID="loginRetryButton" + onPress={onPressRetryConnect} + accessibilityRole="button" + accessibilityLabel={_(msg`Retry`)} + accessibilityHint="Retries login"> + <Text type="xl-bold" style={[pal.link, s.pr5]}> + <Trans>Retry</Trans> + </Text> + </TouchableOpacity> + ) : !serviceDescription ? ( + <> + <ActivityIndicator /> + <Text type="xl" style={[pal.textLight, s.pl10]}> + <Trans>Connecting...</Trans> + </Text> + </> + ) : isProcessing ? ( + <ActivityIndicator /> + ) : isReady ? ( + <TouchableOpacity + testID="loginNextButton" + onPress={onPressNext} + accessibilityRole="button" + accessibilityLabel={_(msg`Go to next`)} + accessibilityHint="Navigates to the next screen"> + <Text type="xl-bold" style={[pal.link, s.pr5]}> + <Trans>Next</Trans> + </Text> + </TouchableOpacity> + ) : undefined} + </View> + </View> + ) +} diff --git a/src/view/com/auth/login/PasswordUpdatedForm.tsx b/src/view/com/auth/login/PasswordUpdatedForm.tsx new file mode 100644 index 000000000..1e07588a9 --- /dev/null +++ b/src/view/com/auth/login/PasswordUpdatedForm.tsx @@ -0,0 +1,48 @@ +import React, {useEffect} from 'react' +import {TouchableOpacity, View} from 'react-native' +import {useAnalytics} from 'lib/analytics/analytics' +import {Text} from '../../util/text/Text' +import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {styles} from './styles' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +export const PasswordUpdatedForm = ({ + onPressNext, +}: { + onPressNext: () => void +}) => { + const {screen} = useAnalytics() + const pal = usePalette('default') + const {_} = useLingui() + + useEffect(() => { + screen('Signin:PasswordUpdatedForm') + }, [screen]) + + return ( + <> + <View> + <Text type="title-lg" style={[pal.text, styles.screenTitle]}> + <Trans>Password updated!</Trans> + </Text> + <Text type="lg" style={[pal.text, styles.instructions]}> + <Trans>You can now sign in with your new password.</Trans> + </Text> + <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> + <View style={s.flex1} /> + <TouchableOpacity + onPress={onPressNext} + accessibilityRole="button" + accessibilityLabel={_(msg`Close alert`)} + accessibilityHint="Closes password update alert"> + <Text type="xl-bold" style={[pal.link, s.pr5]}> + <Trans>Okay</Trans> + </Text> + </TouchableOpacity> + </View> + </View> + </> + ) +} diff --git a/src/view/com/auth/login/SetNewPasswordForm.tsx b/src/view/com/auth/login/SetNewPasswordForm.tsx new file mode 100644 index 000000000..2bb614df2 --- /dev/null +++ b/src/view/com/auth/login/SetNewPasswordForm.tsx @@ -0,0 +1,179 @@ +import React, {useState, useEffect} from 'react' +import { + ActivityIndicator, + TextInput, + TouchableOpacity, + View, +} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {BskyAgent} from '@atproto/api' +import {useAnalytics} from 'lib/analytics/analytics' +import {Text} from '../../util/text/Text' +import {s} from 'lib/styles' +import {isNetworkError} from 'lib/strings/errors' +import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' +import {cleanError} from 'lib/strings/errors' +import {logger} from '#/logger' +import {styles} from './styles' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +export const SetNewPasswordForm = ({ + error, + serviceUrl, + setError, + onPressBack, + onPasswordSet, +}: { + error: string + serviceUrl: string + setError: (v: string) => void + onPressBack: () => void + onPasswordSet: () => void +}) => { + const pal = usePalette('default') + const theme = useTheme() + const {screen} = useAnalytics() + const {_} = useLingui() + + useEffect(() => { + screen('Signin:SetNewPasswordForm') + }, [screen]) + + const [isProcessing, setIsProcessing] = useState<boolean>(false) + const [resetCode, setResetCode] = useState<string>('') + const [password, setPassword] = useState<string>('') + + const onPressNext = async () => { + setError('') + setIsProcessing(true) + + try { + const agent = new BskyAgent({service: serviceUrl}) + const token = resetCode.replace(/\s/g, '') + await agent.com.atproto.server.resetPassword({ + token, + password, + }) + onPasswordSet() + } catch (e: any) { + const errMsg = e.toString() + logger.warn('Failed to set new password', {error: e}) + setIsProcessing(false) + if (isNetworkError(e)) { + setError( + 'Unable to contact your service. Please check your Internet connection.', + ) + } else { + setError(cleanError(errMsg)) + } + } + } + + return ( + <> + <View> + <Text type="title-lg" style={[pal.text, styles.screenTitle]}> + <Trans>Set new password</Trans> + </Text> + <Text type="lg" style={[pal.text, styles.instructions]}> + <Trans> + You will receive an email with a "reset code." Enter that code here, + then enter your new password. + </Trans> + </Text> + <View + testID="newPasswordView" + style={[pal.view, pal.borderDark, styles.group]}> + <View + style={[pal.borderDark, styles.groupContent, styles.noTopBorder]}> + <FontAwesomeIcon + icon="ticket" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="resetCodeInput" + style={[pal.text, styles.textInput]} + placeholder="Reset code" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoCorrect={false} + keyboardAppearance={theme.colorScheme} + autoFocus + value={resetCode} + onChangeText={setResetCode} + editable={!isProcessing} + accessible={true} + accessibilityLabel={_(msg`Reset code`)} + accessibilityHint="Input code sent to your email for password reset" + /> + </View> + <View style={[pal.borderDark, styles.groupContent]}> + <FontAwesomeIcon + icon="lock" + style={[pal.textLight, styles.groupContentIcon]} + /> + <TextInput + testID="newPasswordInput" + style={[pal.text, styles.textInput]} + placeholder="New password" + placeholderTextColor={pal.colors.textLight} + autoCapitalize="none" + autoCorrect={false} + keyboardAppearance={theme.colorScheme} + secureTextEntry + value={password} + onChangeText={setPassword} + editable={!isProcessing} + accessible={true} + accessibilityLabel={_(msg`Password`)} + accessibilityHint="Input new password" + /> + </View> + </View> + {error ? ( + <View style={styles.error}> + <View style={styles.errorIcon}> + <FontAwesomeIcon icon="exclamation" style={s.white} size={10} /> + </View> + <View style={s.flex1}> + <Text style={[s.white, s.bold]}>{error}</Text> + </View> + </View> + ) : undefined} + <View style={[s.flexRow, s.alignCenter, s.pl20, s.pr20]}> + <TouchableOpacity onPress={onPressBack} accessibilityRole="button"> + <Text type="xl" style={[pal.link, s.pl5]}> + <Trans>Back</Trans> + </Text> + </TouchableOpacity> + <View style={s.flex1} /> + {isProcessing ? ( + <ActivityIndicator /> + ) : !resetCode || !password ? ( + <Text type="xl-bold" style={[pal.link, s.pr5, styles.dimmed]}> + <Trans>Next</Trans> + </Text> + ) : ( + <TouchableOpacity + testID="setNewPasswordButton" + onPress={onPressNext} + accessibilityRole="button" + accessibilityLabel={_(msg`Go to next`)} + accessibilityHint="Navigates to the next screen"> + <Text type="xl-bold" style={[pal.link, s.pr5]}> + <Trans>Next</Trans> + </Text> + </TouchableOpacity> + )} + {isProcessing ? ( + <Text type="xl" style={[pal.textLight, s.pl10]}> + <Trans>Updating...</Trans> + </Text> + ) : undefined} + </View> + </View> + </> + ) +} diff --git a/src/view/com/auth/login/styles.ts b/src/view/com/auth/login/styles.ts new file mode 100644 index 000000000..9dccc2803 --- /dev/null +++ b/src/view/com/auth/login/styles.ts @@ -0,0 +1,118 @@ +import {StyleSheet} from 'react-native' +import {colors} from 'lib/styles' +import {isWeb} from '#/platform/detection' + +export const styles = StyleSheet.create({ + screenTitle: { + marginBottom: 10, + marginHorizontal: 20, + }, + instructions: { + marginBottom: 20, + marginHorizontal: 20, + }, + group: { + borderWidth: 1, + borderRadius: 10, + marginBottom: 20, + marginHorizontal: 20, + }, + groupLabel: { + paddingHorizontal: 20, + paddingBottom: 5, + }, + groupContent: { + borderTopWidth: 1, + flexDirection: 'row', + alignItems: 'center', + }, + noTopBorder: { + borderTopWidth: 0, + }, + groupContentIcon: { + marginLeft: 10, + }, + account: { + borderTopWidth: 1, + paddingHorizontal: 20, + paddingVertical: 4, + }, + accountLast: { + borderBottomWidth: 1, + marginBottom: 20, + paddingVertical: 8, + }, + textInput: { + flex: 1, + width: '100%', + paddingVertical: 10, + paddingHorizontal: 12, + fontSize: 17, + letterSpacing: 0.25, + fontWeight: '400', + borderRadius: 10, + }, + textInputInnerBtn: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 6, + paddingHorizontal: 8, + marginHorizontal: 6, + }, + textBtn: { + flexDirection: 'row', + flex: 1, + alignItems: 'center', + }, + textBtnLabel: { + flex: 1, + paddingVertical: 10, + paddingHorizontal: 12, + }, + textBtnFakeInnerBtn: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 6, + paddingVertical: 6, + paddingHorizontal: 8, + marginHorizontal: 6, + }, + accountText: { + flex: 1, + flexDirection: 'row', + alignItems: 'baseline', + paddingVertical: 10, + }, + accountTextOther: { + paddingLeft: 12, + }, + error: { + backgroundColor: colors.red4, + flexDirection: 'row', + alignItems: 'center', + marginTop: -5, + marginHorizontal: 20, + marginBottom: 15, + borderRadius: 8, + paddingHorizontal: 8, + paddingVertical: 8, + }, + errorIcon: { + borderWidth: 1, + borderColor: colors.white, + color: colors.white, + borderRadius: 30, + width: 16, + height: 16, + alignItems: 'center', + justifyContent: 'center', + marginRight: 5, + }, + dimmed: {opacity: 0.5}, + + maxHeight: { + // @ts-ignore web only -prf + maxHeight: isWeb ? '100vh' : undefined, + height: !isWeb ? '100%' : undefined, + }, +}) diff --git a/src/view/com/auth/onboarding/RecommendedFeeds.tsx b/src/view/com/auth/onboarding/RecommendedFeeds.tsx index 400b836d0..d3318bffd 100644 --- a/src/view/com/auth/onboarding/RecommendedFeeds.tsx +++ b/src/view/com/auth/onboarding/RecommendedFeeds.tsx @@ -1,6 +1,5 @@ import React from 'react' import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {TabletOrDesktop, Mobile} from 'view/com/util/layouts/Breakpoints' import {Text} from 'view/com/util/text/Text' @@ -10,76 +9,55 @@ import {Button} from 'view/com/util/forms/Button' import {RecommendedFeedsItem} from './RecommendedFeedsItem' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {usePalette} from 'lib/hooks/usePalette' -import {useQuery} from '@tanstack/react-query' -import {useStores} from 'state/index' -import {FeedSourceModel} from 'state/models/content/feed-source' import {ErrorMessage} from 'view/com/util/error/ErrorMessage' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useSuggestedFeedsQuery} from '#/state/queries/suggested-feeds' type Props = { next: () => void } -export const RecommendedFeeds = observer(function RecommendedFeedsImpl({ - next, -}: Props) { - const store = useStores() +export function RecommendedFeeds({next}: Props) { const pal = usePalette('default') + const {_} = useLingui() const {isTabletOrMobile} = useWebMediaQueries() - const {isLoading, data: recommendedFeeds} = useQuery({ - staleTime: Infinity, // fixed list rn, never refetch - queryKey: ['onboarding', 'recommended_feeds'], - async queryFn() { - try { - const { - data: {feeds}, - success, - } = await store.agent.app.bsky.feed.getSuggestedFeeds() + const {isLoading, data} = useSuggestedFeedsQuery() - if (!success) { - return [] - } - - return (feeds.length ? feeds : []).map(feed => { - const model = new FeedSourceModel(store, feed.uri) - model.hydrateFeedGenerator(feed) - return model - }) - } catch (e) { - return [] - } - }, - }) - - const hasFeeds = recommendedFeeds && recommendedFeeds.length + const hasFeeds = data && data.pages[0].feeds.length const title = ( <> - <Text - style={[ - pal.textLight, - tdStyles.title1, - isTabletOrMobile && tdStyles.title1Small, - ]}> - Choose your - </Text> - <Text - style={[ - pal.link, - tdStyles.title2, - isTabletOrMobile && tdStyles.title2Small, - ]}> - Recommended - </Text> - <Text - style={[ - pal.link, - tdStyles.title2, - isTabletOrMobile && tdStyles.title2Small, - ]}> - Feeds - </Text> + <Trans> + <Text + style={[ + pal.textLight, + tdStyles.title1, + isTabletOrMobile && tdStyles.title1Small, + ]}> + Choose your + </Text> + <Text + style={[ + pal.link, + tdStyles.title2, + isTabletOrMobile && tdStyles.title2Small, + ]}> + Recommended + </Text> + <Text + style={[ + pal.link, + tdStyles.title2, + isTabletOrMobile && tdStyles.title2Small, + ]}> + Feeds + </Text> + </Trans> <Text type="2xl-medium" style={[pal.textLight, tdStyles.description]}> - Feeds are created by users to curate content. Choose some feeds that you - find interesting. + <Trans> + Feeds are created by users to curate content. Choose some feeds that + you find interesting. + </Trans> </Text> <View style={{ @@ -98,7 +76,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({ <Text type="2xl-medium" style={{color: '#fff', position: 'relative', top: -1}}> - Next + <Trans>Next</Trans> </Text> <FontAwesomeIcon icon="angle-right" color="#fff" size={14} /> </View> @@ -118,7 +96,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({ contentStyle={{paddingHorizontal: 0}}> {hasFeeds ? ( <FlatList - data={recommendedFeeds} + data={data.pages[0].feeds} renderItem={({item}) => <RecommendedFeedsItem item={item} />} keyExtractor={item => item.uri} style={{flex: 1}} @@ -128,25 +106,27 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({ <ActivityIndicator size="large" /> </View> ) : ( - <ErrorMessage message="Failed to load recommended feeds" /> + <ErrorMessage message={_(msg`Failed to load recommended feeds`)} /> )} </TitleColumnLayout> </TabletOrDesktop> <Mobile> <View style={[mStyles.container]} testID="recommendedFeedsOnboarding"> <ViewHeader - title="Recommended Feeds" + title={_(msg`Recommended Feeds`)} showBackButton={false} showOnDesktop /> <Text type="lg-medium" style={[pal.text, mStyles.header]}> - Check out some recommended feeds. Tap + to add them to your list of - pinned feeds. + <Trans> + Check out some recommended feeds. Tap + to add them to your list + of pinned feeds. + </Trans> </Text> {hasFeeds ? ( <FlatList - data={recommendedFeeds} + data={data.pages[0].feeds} renderItem={({item}) => <RecommendedFeedsItem item={item} />} keyExtractor={item => item.uri} style={{flex: 1}} @@ -157,13 +137,15 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({ </View> ) : ( <View style={{flex: 1}}> - <ErrorMessage message="Failed to load recommended feeds" /> + <ErrorMessage + message={_(msg`Failed to load recommended feeds`)} + /> </View> )} <Button onPress={next} - label="Continue" + label={_(msg`Continue`)} testID="continueBtn" style={mStyles.button} labelStyle={mStyles.buttonText} @@ -172,7 +154,7 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({ </Mobile> </> ) -}) +} const tdStyles = StyleSheet.create({ container: { diff --git a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx index bee23c953..7417e5b06 100644 --- a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx +++ b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx @@ -1,7 +1,7 @@ import React from 'react' import {View} from 'react-native' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {AppBskyFeedDefs, RichText as BskRichText} from '@atproto/api' import {Text} from 'view/com/util/text/Text' import {RichText} from 'view/com/util/text/RichText' import {Button} from 'view/com/util/forms/Button' @@ -11,33 +11,58 @@ import {HeartIcon} from 'lib/icons' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {sanitizeHandle} from 'lib/strings/handles' -import {FeedSourceModel} from 'state/models/content/feed-source' +import { + usePreferencesQuery, + usePinFeedMutation, + useRemoveFeedMutation, +} from '#/state/queries/preferences' +import {logger} from '#/logger' -export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({ +export function RecommendedFeedsItem({ item, }: { - item: FeedSourceModel + item: AppBskyFeedDefs.GeneratorView }) { const {isMobile} = useWebMediaQueries() const pal = usePalette('default') - if (!item) return null + const {data: preferences} = usePreferencesQuery() + const { + mutateAsync: pinFeed, + variables: pinnedFeed, + reset: resetPinFeed, + } = usePinFeedMutation() + const { + mutateAsync: removeFeed, + variables: removedFeed, + reset: resetRemoveFeed, + } = useRemoveFeedMutation() + + if (!item || !preferences) return null + + const isPinned = + !removedFeed?.uri && + (pinnedFeed?.uri || preferences.feeds.saved.includes(item.uri)) + const onToggle = async () => { - if (item.isSaved) { + if (isPinned) { try { - await item.unsave() + await removeFeed({uri: item.uri}) + resetRemoveFeed() } catch (e) { Toast.show('There was an issue contacting your server') - console.error('Failed to unsave feed', {e}) + logger.error('Failed to unsave feed', {error: e}) } } else { try { - await item.pin() + await pinFeed({uri: item.uri}) + resetPinFeed() } catch (e) { Toast.show('There was an issue contacting your server') - console.error('Failed to pin feed', {e}) + logger.error('Failed to pin feed', {error: e}) } } } + return ( <View testID={`feed-${item.displayName}`}> <View @@ -66,10 +91,10 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({ </Text> <Text style={[pal.textLight, {marginBottom: 8}]} numberOfLines={1}> - by {sanitizeHandle(item.creatorHandle, '@')} + by {sanitizeHandle(item.creator.handle, '@')} </Text> - {item.descriptionRT ? ( + {item.description ? ( <RichText type="xl" style={[ @@ -80,7 +105,7 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({ marginBottom: 18, }, ]} - richText={item.descriptionRT} + richText={new BskRichText({text: item.description || ''})} numberOfLines={6} /> ) : null} @@ -97,7 +122,7 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({ paddingRight: 2, gap: 6, }}> - {item.isSaved ? ( + {isPinned ? ( <> <FontAwesomeIcon icon="check" @@ -138,4 +163,4 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({ </View> </View> ) -}) +} diff --git a/src/view/com/auth/onboarding/RecommendedFollows.tsx b/src/view/com/auth/onboarding/RecommendedFollows.tsx index f2710d2ac..372bbec6a 100644 --- a/src/view/com/auth/onboarding/RecommendedFollows.tsx +++ b/src/view/com/auth/onboarding/RecommendedFollows.tsx @@ -1,7 +1,7 @@ import React from 'react' import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {AppBskyActorDefs, moderateProfile} from '@atproto/api' import {TabletOrDesktop, Mobile} from 'view/com/util/layouts/Breakpoints' import {Text} from 'view/com/util/text/Text' import {ViewHeader} from 'view/com/util/ViewHeader' @@ -9,59 +9,62 @@ import {TitleColumnLayout} from 'view/com/util/layouts/TitleColumnLayout' import {Button} from 'view/com/util/forms/Button' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {RecommendedFollowsItem} from './RecommendedFollowsItem' +import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows' +import {useGetSuggestedFollowersByActor} from '#/state/queries/suggested-follows' +import {useModerationOpts} from '#/state/queries/preferences' +import {logger} from '#/logger' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type Props = { next: () => void } -export const RecommendedFollows = observer(function RecommendedFollowsImpl({ - next, -}: Props) { - const store = useStores() +export function RecommendedFollows({next}: Props) { const pal = usePalette('default') + const {_} = useLingui() const {isTabletOrMobile} = useWebMediaQueries() - - React.useEffect(() => { - // Load suggested actors if not already loaded - // prefetch should happen in the onboarding model - if ( - !store.onboarding.suggestedActors.hasLoaded || - store.onboarding.suggestedActors.isEmpty - ) { - store.onboarding.suggestedActors.loadMore(true) - } - }, [store]) + const {data: suggestedFollows} = useSuggestedFollowsQuery() + const getSuggestedFollowsByActor = useGetSuggestedFollowersByActor() + const [additionalSuggestions, setAdditionalSuggestions] = React.useState<{ + [did: string]: AppBskyActorDefs.ProfileView[] + }>({}) + const existingDids = React.useRef<string[]>([]) + const moderationOpts = useModerationOpts() const title = ( <> - <Text - style={[ - pal.textLight, - tdStyles.title1, - isTabletOrMobile && tdStyles.title1Small, - ]}> - Follow some - </Text> - <Text - style={[ - pal.link, - tdStyles.title2, - isTabletOrMobile && tdStyles.title2Small, - ]}> - Recommended - </Text> - <Text - style={[ - pal.link, - tdStyles.title2, - isTabletOrMobile && tdStyles.title2Small, - ]}> - Users - </Text> + <Trans> + <Text + style={[ + pal.textLight, + tdStyles.title1, + isTabletOrMobile && tdStyles.title1Small, + ]}> + Follow some + </Text> + <Text + style={[ + pal.link, + tdStyles.title2, + isTabletOrMobile && tdStyles.title2Small, + ]}> + Recommended + </Text> + <Text + style={[ + pal.link, + tdStyles.title2, + isTabletOrMobile && tdStyles.title2Small, + ]}> + Users + </Text> + </Trans> <Text type="2xl-medium" style={[pal.textLight, tdStyles.description]}> - Follow some users to get started. We can recommend you more users based - on who you find interesting. + <Trans> + Follow some users to get started. We can recommend you more users + based on who you find interesting. + </Trans> </Text> <View style={{ @@ -80,7 +83,7 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({ <Text type="2xl-medium" style={{color: '#fff', position: 'relative', top: -1}}> - Done + <Trans>Done</Trans> </Text> <FontAwesomeIcon icon="angle-right" color="#fff" size={14} /> </View> @@ -89,6 +92,59 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({ </> ) + const suggestions = React.useMemo(() => { + if (!suggestedFollows) return [] + + const additional = Object.entries(additionalSuggestions) + const items = suggestedFollows.pages.flatMap(page => page.actors) + + outer: while (additional.length) { + const additionalAccount = additional.shift() + + if (!additionalAccount) break + + const [followedUser, relatedAccounts] = additionalAccount + + for (let i = 0; i < items.length; i++) { + if (items[i].did === followedUser) { + items.splice(i + 1, 0, ...relatedAccounts) + continue outer + } + } + } + + existingDids.current = items.map(i => i.did) + + return items + }, [suggestedFollows, additionalSuggestions]) + + const onFollowStateChange = React.useCallback( + async ({following, did}: {following: boolean; did: string}) => { + if (following) { + try { + const {suggestions: results} = await getSuggestedFollowsByActor(did) + + if (results.length) { + const deduped = results.filter( + r => !existingDids.current.find(did => did === r.did), + ) + setAdditionalSuggestions(s => ({ + ...s, + [did]: deduped.slice(0, 3), + })) + } + } catch (e) { + logger.error('RecommendedFollows: failed to get suggestions', { + error: e, + }) + } + } + + // not handling the unfollow case + }, + [existingDids, getSuggestedFollowsByActor, setAdditionalSuggestions], + ) + return ( <> <TabletOrDesktop> @@ -98,15 +154,19 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({ horizontal titleStyle={isTabletOrMobile ? undefined : {minWidth: 470}} contentStyle={{paddingHorizontal: 0}}> - {store.onboarding.suggestedActors.isLoading ? ( + {!suggestedFollows || !moderationOpts ? ( <ActivityIndicator size="large" /> ) : ( <FlatList - data={store.onboarding.suggestedActors.suggestions} - renderItem={({item, index}) => ( - <RecommendedFollowsItem item={item} index={index} /> + data={suggestions} + renderItem={({item}) => ( + <RecommendedFollowsItem + profile={item} + onFollowStateChange={onFollowStateChange} + moderation={moderateProfile(item, moderationOpts)} + /> )} - keyExtractor={(item, index) => item.did + index.toString()} + keyExtractor={item => item.did} style={{flex: 1}} /> )} @@ -117,30 +177,36 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({ <View style={[mStyles.container]} testID="recommendedFollowsOnboarding"> <View> <ViewHeader - title="Recommended Follows" + title={_(msg`Recommended Users`)} showBackButton={false} showOnDesktop /> <Text type="lg-medium" style={[pal.text, mStyles.header]}> - Check out some recommended users. Follow them to see similar - users. + <Trans> + Check out some recommended users. Follow them to see similar + users. + </Trans> </Text> </View> - {store.onboarding.suggestedActors.isLoading ? ( + {!suggestedFollows || !moderationOpts ? ( <ActivityIndicator size="large" /> ) : ( <FlatList - data={store.onboarding.suggestedActors.suggestions} - renderItem={({item, index}) => ( - <RecommendedFollowsItem item={item} index={index} /> + data={suggestions} + renderItem={({item}) => ( + <RecommendedFollowsItem + profile={item} + onFollowStateChange={onFollowStateChange} + moderation={moderateProfile(item, moderationOpts)} + /> )} - keyExtractor={(item, index) => item.did + index.toString()} + keyExtractor={item => item.did} style={{flex: 1}} /> )} <Button onPress={next} - label="Continue" + label={_(msg`Continue`)} testID="continueBtn" style={mStyles.button} labelStyle={mStyles.buttonText} @@ -149,7 +215,7 @@ export const RecommendedFollows = observer(function RecommendedFollowsImpl({ </Mobile> </> ) -}) +} const tdStyles = StyleSheet.create({ container: { diff --git a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx index 2b26918d0..93c515f38 100644 --- a/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx +++ b/src/view/com/auth/onboarding/RecommendedFollowsItem.tsx @@ -1,11 +1,8 @@ -import React, {useMemo} from 'react' +import React from 'react' import {View, StyleSheet, ActivityIndicator} from 'react-native' -import {AppBskyActorDefs, moderateProfile} from '@atproto/api' -import {observer} from 'mobx-react-lite' -import {useStores} from 'state/index' -import {FollowButton} from 'view/com/profile/FollowButton' +import {ProfileModeration, AppBskyActorDefs} from '@atproto/api' +import {Button} from '#/view/com/util/forms/Button' import {usePalette} from 'lib/hooks/usePalette' -import {SuggestedActor} from 'state/models/discovery/suggested-actors' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {s} from 'lib/styles' @@ -14,26 +11,32 @@ import {Text} from 'view/com/util/text/Text' import Animated, {FadeInRight} from 'react-native-reanimated' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useAnalytics} from 'lib/analytics/analytics' +import {Trans} from '@lingui/macro' +import {Shadow, useProfileShadow} from '#/state/cache/profile-shadow' +import {useProfileFollowMutationQueue} from '#/state/queries/profile' +import {logger} from '#/logger' type Props = { - item: SuggestedActor - index: number + profile: AppBskyActorDefs.ProfileViewBasic + moderation: ProfileModeration + onFollowStateChange: (props: { + did: string + following: boolean + }) => Promise<void> } -export const RecommendedFollowsItem: React.FC<Props> = ({item, index}) => { + +export function RecommendedFollowsItem({ + profile, + moderation, + onFollowStateChange, +}: React.PropsWithChildren<Props>) { const pal = usePalette('default') - const store = useStores() const {isMobile} = useWebMediaQueries() - const delay = useMemo(() => { - return ( - 50 * - (Math.abs(store.onboarding.suggestedActors.lastInsertedAtIndex - index) % - 5) - ) - }, [index, store.onboarding.suggestedActors.lastInsertedAtIndex]) + const shadowedProfile = useProfileShadow(profile) return ( <Animated.View - entering={FadeInRight.delay(delay).springify()} + entering={FadeInRight} style={[ styles.cardContainer, pal.view, @@ -43,24 +46,62 @@ export const RecommendedFollowsItem: React.FC<Props> = ({item, index}) => { borderRightWidth: isMobile ? undefined : 1, }, ]}> - <ProfileCard key={item.did} profile={item} index={index} /> + <ProfileCard + key={profile.did} + profile={shadowedProfile} + onFollowStateChange={onFollowStateChange} + moderation={moderation} + /> </Animated.View> ) } -export const ProfileCard = observer(function ProfileCardImpl({ +export function ProfileCard({ profile, - index, + onFollowStateChange, + moderation, }: { - profile: AppBskyActorDefs.ProfileViewBasic - index: number + profile: Shadow<AppBskyActorDefs.ProfileViewBasic> + moderation: ProfileModeration + onFollowStateChange: (props: { + did: string + following: boolean + }) => Promise<void> }) { const {track} = useAnalytics() - const store = useStores() const pal = usePalette('default') - const moderation = moderateProfile(profile, store.preferences.moderationOpts) const [addingMoreSuggestions, setAddingMoreSuggestions] = React.useState(false) + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) + + const onToggleFollow = React.useCallback(async () => { + try { + if (profile.viewer?.following) { + await queueUnfollow() + } else { + setAddingMoreSuggestions(true) + await queueFollow() + await onFollowStateChange({did: profile.did, following: true}) + setAddingMoreSuggestions(false) + track('Onboarding:SuggestedFollowFollowed') + } + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('RecommendedFollows: failed to toggle following', { + error: e, + }) + } + } finally { + setAddingMoreSuggestions(false) + } + }, [ + profile, + queueFollow, + queueUnfollow, + setAddingMoreSuggestions, + track, + onFollowStateChange, + ]) return ( <View style={styles.card}> @@ -88,20 +129,11 @@ export const ProfileCard = observer(function ProfileCardImpl({ </Text> </View> - <FollowButton - profile={profile} + <Button + type={profile.viewer?.following ? 'default' : 'inverted'} labelStyle={styles.followButton} - onToggleFollow={async isFollow => { - if (isFollow) { - setAddingMoreSuggestions(true) - await store.onboarding.suggestedActors.insertSuggestionsByActor( - profile.did, - index, - ) - setAddingMoreSuggestions(false) - track('Onboarding:SuggestedFollowFollowed') - } - }} + onPress={onToggleFollow} + label={profile.viewer?.following ? 'Unfollow' : 'Follow'} /> </View> {profile.description ? ( @@ -114,12 +146,14 @@ export const ProfileCard = observer(function ProfileCardImpl({ {addingMoreSuggestions ? ( <View style={styles.addingMoreContainer}> <ActivityIndicator size="small" color={pal.colors.text} /> - <Text style={[pal.text]}>Finding similar accounts...</Text> + <Text style={[pal.text]}> + <Trans>Finding similar accounts...</Trans> + </Text> </View> ) : null} </View> ) -}) +} const styles = StyleSheet.create({ cardContainer: { diff --git a/src/view/com/auth/onboarding/WelcomeDesktop.tsx b/src/view/com/auth/onboarding/WelcomeDesktop.tsx index c066e9bd5..1a30c17f9 100644 --- a/src/view/com/auth/onboarding/WelcomeDesktop.tsx +++ b/src/view/com/auth/onboarding/WelcomeDesktop.tsx @@ -7,16 +7,13 @@ import {usePalette} from 'lib/hooks/usePalette' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {TitleColumnLayout} from 'view/com/util/layouts/TitleColumnLayout' import {Button} from 'view/com/util/forms/Button' -import {observer} from 'mobx-react-lite' type Props = { next: () => void skip: () => void } -export const WelcomeDesktop = observer(function WelcomeDesktopImpl({ - next, -}: Props) { +export function WelcomeDesktop({next}: Props) { const pal = usePalette('default') const horizontal = useMediaQuery({minWidth: 1300}) const title = ( @@ -105,7 +102,7 @@ export const WelcomeDesktop = observer(function WelcomeDesktopImpl({ </View> </TitleColumnLayout> ) -}) +} const styles = StyleSheet.create({ row: { diff --git a/src/view/com/auth/onboarding/WelcomeMobile.tsx b/src/view/com/auth/onboarding/WelcomeMobile.tsx index 1f0a64370..5de1a7817 100644 --- a/src/view/com/auth/onboarding/WelcomeMobile.tsx +++ b/src/view/com/auth/onboarding/WelcomeMobile.tsx @@ -5,18 +5,15 @@ import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Button} from 'view/com/util/forms/Button' -import {observer} from 'mobx-react-lite' import {ViewHeader} from 'view/com/util/ViewHeader' +import {Trans} from '@lingui/macro' type Props = { next: () => void skip: () => void } -export const WelcomeMobile = observer(function WelcomeMobileImpl({ - next, - skip, -}: Props) { +export function WelcomeMobile({next, skip}: Props) { const pal = usePalette('default') return ( @@ -32,7 +29,9 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({ accessibilityRole="button" style={[s.flexRow, s.alignCenter]} onPress={skip}> - <Text style={[pal.link]}>Skip</Text> + <Text style={[pal.link]}> + <Trans>Skip</Trans> + </Text> <FontAwesomeIcon icon={'chevron-right'} size={14} @@ -44,18 +43,22 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({ /> <View> <Text style={[pal.text, styles.title]}> - Welcome to{' '} - <Text style={[pal.text, pal.link, styles.title]}>Bluesky</Text> + <Trans> + Welcome to{' '} + <Text style={[pal.text, pal.link, styles.title]}>Bluesky</Text> + </Trans> </Text> <View style={styles.spacer} /> <View style={[styles.row]}> <FontAwesomeIcon icon={'globe'} size={36} color={pal.colors.link} /> <View style={[styles.rowText]}> <Text type="lg-bold" style={[pal.text]}> - Bluesky is public. + <Trans>Bluesky is public.</Trans> </Text> <Text type="lg-thin" style={[pal.text, s.pt2]}> - Your posts, likes, and blocks are public. Mutes are private. + <Trans> + Your posts, likes, and blocks are public. Mutes are private. + </Trans> </Text> </View> </View> @@ -63,10 +66,10 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({ <FontAwesomeIcon icon={'at'} size={36} color={pal.colors.link} /> <View style={[styles.rowText]}> <Text type="lg-bold" style={[pal.text]}> - Bluesky is open. + <Trans>Bluesky is open.</Trans> </Text> <Text type="lg-thin" style={[pal.text, s.pt2]}> - Never lose access to your followers and data. + <Trans>Never lose access to your followers and data.</Trans> </Text> </View> </View> @@ -74,11 +77,13 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({ <FontAwesomeIcon icon={'gear'} size={36} color={pal.colors.link} /> <View style={[styles.rowText]}> <Text type="lg-bold" style={[pal.text]}> - Bluesky is flexible. + <Trans>Bluesky is flexible.</Trans> </Text> <Text type="lg-thin" style={[pal.text, s.pt2]}> - Choose the algorithms that power your experience with custom - feeds. + <Trans> + Choose the algorithms that power your experience with custom + feeds. + </Trans> </Text> </View> </View> @@ -93,7 +98,7 @@ export const WelcomeMobile = observer(function WelcomeMobileImpl({ /> </View> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/com/auth/withAuthRequired.tsx b/src/view/com/auth/withAuthRequired.tsx deleted file mode 100644 index 25d12165f..000000000 --- a/src/view/com/auth/withAuthRequired.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React from 'react' -import { - ActivityIndicator, - Linking, - StyleSheet, - TouchableOpacity, -} from 'react-native' -import {observer} from 'mobx-react-lite' -import {useStores} from 'state/index' -import {CenteredView} from '../util/Views' -import {LoggedOut} from './LoggedOut' -import {Onboarding} from './Onboarding' -import {Text} from '../util/text/Text' -import {usePalette} from 'lib/hooks/usePalette' -import {STATUS_PAGE_URL} from 'lib/constants' - -export const withAuthRequired = <P extends object>( - Component: React.ComponentType<P>, -): React.FC<P> => - observer(function AuthRequired(props: P) { - const store = useStores() - if (store.session.isResumingSession) { - return <Loading /> - } - if (!store.session.hasSession) { - return <LoggedOut /> - } - if (store.onboarding.isActive) { - return <Onboarding /> - } - return <Component {...props} /> - }) - -function Loading() { - const pal = usePalette('default') - - const [isTakingTooLong, setIsTakingTooLong] = React.useState(false) - React.useEffect(() => { - const t = setTimeout(() => setIsTakingTooLong(true), 15e3) // 15 seconds - return () => clearTimeout(t) - }, [setIsTakingTooLong]) - - return ( - <CenteredView style={[styles.loading, pal.view]}> - <ActivityIndicator size="large" /> - <Text type="2xl" style={[styles.loadingText, pal.textLight]}> - {isTakingTooLong - ? "This is taking too long. There may be a problem with your internet or with the service, but we're going to try a couple more times..." - : 'Connecting...'} - </Text> - {isTakingTooLong ? ( - <TouchableOpacity - onPress={() => { - Linking.openURL(STATUS_PAGE_URL) - }} - accessibilityRole="button"> - <Text type="2xl" style={[styles.loadingText, pal.link]}> - Check Bluesky status page - </Text> - </TouchableOpacity> - ) : null} - </CenteredView> - ) -} - -const styles = StyleSheet.create({ - loading: { - height: '100%', - alignContent: 'center', - justifyContent: 'center', - paddingBottom: 100, - }, - loadingText: { - paddingVertical: 20, - paddingHorizontal: 20, - textAlign: 'center', - }, -}) diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index e44a0ce01..6f058d39e 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -16,7 +16,6 @@ import LinearGradient from 'react-native-linear-gradient' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {RichText} from '@atproto/api' import {useAnalytics} from 'lib/analytics/analytics' -import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible' import {ExternalEmbed} from './ExternalEmbed' import {Text} from '../util/text/Text' @@ -26,9 +25,8 @@ import * as Toast from '../util/Toast' import {TextInput, TextInputRef} from './text-input/TextInput' import {CharProgress} from './char-progress/CharProgress' import {UserAvatar} from '../util/UserAvatar' -import {useStores} from 'state/index' import * as apilib from 'lib/api/index' -import {ComposerOpts} from 'state/models/ui/shell' +import {ComposerOpts} from 'state/shell/composer' import {s, colors, gradients} from 'lib/styles' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' @@ -49,6 +47,18 @@ import {LabelsBtn} from './labels/LabelsBtn' import {SelectLangBtn} from './select-language/SelectLangBtn' import {EmojiPickerButton} from './text-input/web/EmojiPicker.web' import {insertMentionAt} from 'lib/strings/mention-manip' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModals, useModalControls} from '#/state/modals' +import {useRequireAltTextEnabled} from '#/state/preferences' +import { + useLanguagePrefs, + useLanguagePrefsApi, + toPostLanguages, +} from '#/state/preferences/languages' +import {useSession, getAgent} from '#/state/session' +import {useProfileQuery} from '#/state/queries/profile' +import {useComposerControls} from '#/state/shell/composer' type Props = ComposerOpts export const ComposePost = observer(function ComposePost({ @@ -57,10 +67,18 @@ export const ComposePost = observer(function ComposePost({ quote: initQuote, mention: initMention, }: Props) { + const {currentAccount} = useSession() + const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) + const {activeModals} = useModals() + const {openModal, closeModal} = useModalControls() + const {closeComposer} = useComposerControls() const {track} = useAnalytics() const pal = usePalette('default') const {isDesktop, isMobile} = useWebMediaQueries() - const store = useStores() + const {_} = useLingui() + const requireAltTextEnabled = useRequireAltTextEnabled() + const langPrefs = useLanguagePrefs() + const setLangPrefs = useLanguagePrefsApi() const textInput = useRef<TextInputRef>(null) const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true}) const [isProcessing, setIsProcessing] = useState(false) @@ -86,15 +104,10 @@ export const ComposePost = observer(function ComposePost({ const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) const [labels, setLabels] = useState<string[]>([]) const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set()) - const gallery = useMemo(() => new GalleryModel(store), [store]) + const gallery = useMemo(() => new GalleryModel(), []) const onClose = useCallback(() => { - store.shell.closeComposer() - }, [store]) - - const autocompleteView = useMemo<UserAutocompleteModel>( - () => new UserAutocompleteModel(store), - [store], - ) + closeComposer() + }, [closeComposer]) const insets = useSafeAreaInsets() const viewStyles = useMemo( @@ -108,27 +121,27 @@ export const ComposePost = observer(function ComposePost({ const onPressCancel = useCallback(() => { if (graphemeLength > 0 || !gallery.isEmpty) { - if (store.shell.activeModals.some(modal => modal.name === 'confirm')) { - store.shell.closeModal() + if (activeModals.some(modal => modal.name === 'confirm')) { + closeModal() } if (Keyboard) { Keyboard.dismiss() } - store.shell.openModal({ + openModal({ name: 'confirm', - title: 'Discard draft', + title: _(msg`Discard draft`), onPressConfirm: onClose, onPressCancel: () => { - store.shell.closeModal() + closeModal() }, - message: "Are you sure you'd like to discard this draft?", - confirmBtnText: 'Discard', + message: _(msg`Are you sure you'd like to discard this draft?`), + confirmBtnText: _(msg`Discard`), confirmBtnStyle: {backgroundColor: colors.red4}, }) } else { onClose() } - }, [store, onClose, graphemeLength, gallery]) + }, [openModal, closeModal, activeModals, onClose, graphemeLength, gallery, _]) // android back button useEffect(() => { if (!isAndroid) { @@ -147,11 +160,6 @@ export const ComposePost = observer(function ComposePost({ } }, [onPressCancel]) - // initial setup - useEffect(() => { - autocompleteView.setup() - }, [autocompleteView]) - // listen to escape key on desktop web const onEscape = useCallback( (e: KeyboardEvent) => { @@ -187,7 +195,7 @@ export const ComposePost = observer(function ComposePost({ if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) { return } - if (store.preferences.requireAltTextEnabled && gallery.needsAltText) { + if (requireAltTextEnabled && gallery.needsAltText) { return } @@ -201,7 +209,7 @@ export const ComposePost = observer(function ComposePost({ setIsProcessing(true) try { - await apilib.post(store, { + await apilib.post(getAgent(), { rawText: richtext.text, replyTo: replyTo?.uri, images: gallery.images, @@ -209,8 +217,7 @@ export const ComposePost = observer(function ComposePost({ extLink, labels, onStateChange: setProcessingState, - knownHandles: autocompleteView.knownHandles, - langs: store.preferences.postLanguages, + langs: toPostLanguages(langPrefs.postLanguage), }) } catch (e: any) { if (extLink) { @@ -230,9 +237,9 @@ export const ComposePost = observer(function ComposePost({ if (replyTo && replyTo.uri) track('Post:Reply') } if (!replyTo) { - store.me.mainFeed.onPostCreated() + // TODO onPostCreated } - store.preferences.savePostLanguageToHistory() + setLangPrefs.savePostLanguageToHistory() onPost?.() onClose() Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`) @@ -241,12 +248,8 @@ export const ComposePost = observer(function ComposePost({ const canPost = useMemo( () => graphemeLength <= MAX_GRAPHEME_LENGTH && - (!store.preferences.requireAltTextEnabled || !gallery.needsAltText), - [ - graphemeLength, - store.preferences.requireAltTextEnabled, - gallery.needsAltText, - ], + (!requireAltTextEnabled || !gallery.needsAltText), + [graphemeLength, requireAltTextEnabled, gallery.needsAltText], ) const selectTextInputPlaceholder = replyTo ? 'Write your reply' : `What's up?` @@ -265,9 +268,11 @@ export const ComposePost = observer(function ComposePost({ onPress={onPressCancel} onAccessibilityEscape={onPressCancel} accessibilityRole="button" - accessibilityLabel="Cancel" + accessibilityLabel={_(msg`Cancel`)} accessibilityHint="Closes post composer and discards post draft"> - <Text style={[pal.link, s.f18]}>Cancel</Text> + <Text style={[pal.link, s.f18]}> + <Trans>Cancel</Trans> + </Text> </TouchableOpacity> <View style={s.flex1} /> {isProcessing ? ( @@ -308,13 +313,15 @@ export const ComposePost = observer(function ComposePost({ </TouchableOpacity> ) : ( <View style={[styles.postBtn, pal.btn]}> - <Text style={[pal.textLight, s.f16, s.bold]}>Post</Text> + <Text style={[pal.textLight, s.f16, s.bold]}> + <Trans>Post</Trans> + </Text> </View> )} </> )} </View> - {store.preferences.requireAltTextEnabled && gallery.needsAltText && ( + {requireAltTextEnabled && gallery.needsAltText && ( <View style={[styles.reminderLine, pal.viewLight]}> <View style={styles.errorIcon}> <FontAwesomeIcon @@ -324,7 +331,7 @@ export const ComposePost = observer(function ComposePost({ /> </View> <Text style={[pal.text, s.flex1]}> - One or more images is missing alt text. + <Trans>One or more images is missing alt text.</Trans> </Text> </View> )} @@ -366,13 +373,12 @@ export const ComposePost = observer(function ComposePost({ styles.textInputLayout, isNative && styles.textInputLayoutMobile, ]}> - <UserAvatar avatar={store.me.avatar} size={50} /> + <UserAvatar avatar={currentProfile?.avatar} size={50} /> <TextInput ref={textInput} richtext={richtext} placeholder={selectTextInputPlaceholder} suggestedLinks={suggestedLinks} - autocompleteView={autocompleteView} autoFocus={true} setRichText={setRichText} onPhotoPasted={onPhotoPasted} @@ -380,7 +386,7 @@ export const ComposePost = observer(function ComposePost({ onSuggestedLinksChanged={setSuggestedLinks} onError={setError} accessible={true} - accessibilityLabel="Write post" + accessibilityLabel={_(msg`Write post`)} accessibilityHint={`Compose posts up to ${MAX_GRAPHEME_LENGTH} characters in length`} /> </View> @@ -409,11 +415,11 @@ export const ComposePost = observer(function ComposePost({ style={[pal.borderDark, styles.addExtLinkBtn]} onPress={() => onPressAddLinkCard(url)} accessibilityRole="button" - accessibilityLabel="Add link card" + accessibilityLabel={_(msg`Add link card`)} accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}> <Text style={pal.text}> - Add link card:{' '} - <Text style={pal.link}>{toShortUrl(url)}</Text> + <Trans>Add link card:</Trans> + <Text style={[pal.link, s.ml5]}>{toShortUrl(url)}</Text> </Text> </TouchableOpacity> ))} diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index c9200ec63..502e4b4d2 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -11,6 +11,8 @@ import {Text} from '../util/text/Text' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {ExternalEmbedDraft} from 'lib/api/index' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' export const ExternalEmbed = ({ link, @@ -21,6 +23,7 @@ export const ExternalEmbed = ({ }) => { const pal = usePalette('default') const palError = usePalette('error') + const {_} = useLingui() if (!link) { return <View /> } @@ -64,7 +67,7 @@ export const ExternalEmbed = ({ style={styles.removeBtn} onPress={onRemove} accessibilityRole="button" - accessibilityLabel="Remove image preview" + accessibilityLabel={_(msg`Remove image preview`)} accessibilityHint={`Removes default thumbnail from ${link.uri}`} onAccessibilityEscape={onRemove}> <FontAwesomeIcon size={18} icon="xmark" style={s.white} /> diff --git a/src/view/com/composer/Prompt.tsx b/src/view/com/composer/Prompt.tsx index e54404f52..ae055f9ac 100644 --- a/src/view/com/composer/Prompt.tsx +++ b/src/view/com/composer/Prompt.tsx @@ -3,12 +3,17 @@ import {StyleSheet, TouchableOpacity} from 'react-native' import {UserAvatar} from '../util/UserAvatar' import {Text} from '../util/text/Text' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useSession} from '#/state/session' +import {useProfileQuery} from '#/state/queries/profile' export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) { - const store = useStores() + const {currentAccount} = useSession() + const {data: profile} = useProfileQuery({did: currentAccount?.did}) const pal = usePalette('default') + const {_} = useLingui() const {isDesktop} = useWebMediaQueries() return ( <TouchableOpacity @@ -16,16 +21,16 @@ export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) { style={[pal.view, pal.border, styles.prompt]} onPress={() => onPressCompose()} accessibilityRole="button" - accessibilityLabel="Compose reply" + accessibilityLabel={_(msg`Compose reply`)} accessibilityHint="Opens composer"> - <UserAvatar avatar={store.me.avatar} size={38} /> + <UserAvatar avatar={profile?.avatar} size={38} /> <Text type="xl" style={[ pal.text, isDesktop ? styles.labelDesktopWeb : styles.labelMobile, ]}> - Write your reply + <Trans>Write your reply</Trans> </Text> </TouchableOpacity> ) diff --git a/src/view/com/composer/labels/LabelsBtn.tsx b/src/view/com/composer/labels/LabelsBtn.tsx index 96908d47f..a10684691 100644 --- a/src/view/com/composer/labels/LabelsBtn.tsx +++ b/src/view/com/composer/labels/LabelsBtn.tsx @@ -1,15 +1,16 @@ import React from 'react' import {Keyboard, StyleSheet} from 'react-native' -import {observer} from 'mobx-react-lite' import {Button} from 'view/com/util/forms/Button' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {ShieldExclamation} from 'lib/icons' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' import {isNative} from 'platform/detection' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import {useModalControls} from '#/state/modals' -export const LabelsBtn = observer(function LabelsBtn({ +export function LabelsBtn({ labels, hasMedia, onChange, @@ -19,14 +20,15 @@ export const LabelsBtn = observer(function LabelsBtn({ onChange: (v: string[]) => void }) { const pal = usePalette('default') - const store = useStores() + const {_} = useLingui() + const {openModal} = useModalControls() return ( <Button type="default-light" testID="labelsBtn" style={[styles.button, !hasMedia && styles.dimmed]} - accessibilityLabel="Content warnings" + accessibilityLabel={_(msg`Content warnings`)} accessibilityHint="" onPress={() => { if (isNative) { @@ -34,7 +36,7 @@ export const LabelsBtn = observer(function LabelsBtn({ Keyboard.dismiss() } } - store.shell.openModal({name: 'self-label', labels, hasMedia, onChange}) + openModal({name: 'self-label', labels, hasMedia, onChange}) }}> <ShieldExclamation style={pal.link} size={26} /> {labels.length > 0 ? ( @@ -46,7 +48,7 @@ export const LabelsBtn = observer(function LabelsBtn({ ) : null} </Button> ) -}) +} const styles = StyleSheet.create({ button: { diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index fcd99842a..69c8debb0 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -7,11 +7,13 @@ import {s, colors} from 'lib/styles' import {StyleSheet, TouchableOpacity, View} from 'react-native' import {Image} from 'expo-image' import {Text} from 'view/com/util/text/Text' -import {openAltTextModal} from 'lib/media/alt-text' import {Dimensions} from 'lib/media/types' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import {isNative} from 'platform/detection' const IMAGE_GAP = 8 @@ -47,9 +49,10 @@ const GalleryInner = observer(function GalleryImpl({ gallery, containerInfo, }: GalleryInnerProps) { - const store = useStores() const pal = usePalette('default') + const {_} = useLingui() const {isMobile} = useWebMediaQueries() + const {openModal} = useModalControls() let side: number @@ -113,15 +116,18 @@ const GalleryInner = observer(function GalleryImpl({ <TouchableOpacity testID="altTextButton" accessibilityRole="button" - accessibilityLabel="Add alt text" + accessibilityLabel={_(msg`Add alt text`)} accessibilityHint="" onPress={() => { Keyboard.dismiss() - openAltTextModal(store, image) + openModal({ + name: 'alt-text-image', + image, + }) }} style={[styles.altTextControl, altTextControlStyle]}> <Text style={styles.altTextControlLabel} accessible={false}> - ALT + <Trans>ALT</Trans> </Text> {image.altText.length > 0 ? ( <FontAwesomeIcon @@ -135,9 +141,19 @@ const GalleryInner = observer(function GalleryImpl({ <TouchableOpacity testID="editPhotoButton" accessibilityRole="button" - accessibilityLabel="Edit image" + accessibilityLabel={_(msg`Edit image`)} accessibilityHint="" - onPress={() => gallery.edit(image)} + onPress={() => { + if (isNative) { + gallery.crop(image) + } else { + openModal({ + name: 'edit-image', + image, + gallery, + }) + } + }} style={styles.imageControl}> <FontAwesomeIcon icon="pen" @@ -148,7 +164,7 @@ const GalleryInner = observer(function GalleryImpl({ <TouchableOpacity testID="removePhotoButton" accessibilityRole="button" - accessibilityLabel="Remove image" + accessibilityLabel={_(msg`Remove image`)} accessibilityHint="" onPress={() => gallery.remove(image)} style={styles.imageControl}> @@ -161,11 +177,14 @@ const GalleryInner = observer(function GalleryImpl({ </View> <TouchableOpacity accessibilityRole="button" - accessibilityLabel="Add alt text" + accessibilityLabel={_(msg`Add alt text`)} accessibilityHint="" onPress={() => { Keyboard.dismiss() - openAltTextModal(store, image) + openModal({ + name: 'alt-text-image', + image, + }) }} style={styles.altTextHiddenRegion} /> @@ -187,8 +206,10 @@ const GalleryInner = observer(function GalleryImpl({ <FontAwesomeIcon icon="info" size={12} color={pal.colors.text} /> </View> <Text type="sm" style={[pal.textLight, s.flex1]}> - Alt text describes images for blind and low-vision users, and helps - give context to everyone. + <Trans> + Alt text describes images for blind and low-vision users, and helps + give context to everyone. + </Trans> </Text> </View> </> diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx index 99e820d51..69f63c55f 100644 --- a/src/view/com/composer/photos/OpenCameraBtn.tsx +++ b/src/view/com/composer/photos/OpenCameraBtn.tsx @@ -6,13 +6,14 @@ import { } from '@fortawesome/react-native-fontawesome' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics/analytics' -import {useStores} from 'state/index' import {openCamera} from 'lib/media/picker' import {useCameraPermission} from 'lib/hooks/usePermissions' import {HITSLOP_10, POST_IMG_MAX} from 'lib/constants' import {GalleryModel} from 'state/models/media/gallery' import {isMobileWeb, isNative} from 'platform/detection' import {logger} from '#/logger' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' type Props = { gallery: GalleryModel @@ -21,7 +22,7 @@ type Props = { export function OpenCameraBtn({gallery}: Props) { const pal = usePalette('default') const {track} = useAnalytics() - const store = useStores() + const {_} = useLingui() const {requestCameraAccessIfNeeded} = useCameraPermission() const onPressTakePicture = useCallback(async () => { @@ -31,7 +32,7 @@ export function OpenCameraBtn({gallery}: Props) { return } - const img = await openCamera(store, { + const img = await openCamera({ width: POST_IMG_MAX.width, height: POST_IMG_MAX.height, freeStyleCropEnabled: true, @@ -42,7 +43,7 @@ export function OpenCameraBtn({gallery}: Props) { // ignore logger.warn('Error using camera', {error: err}) } - }, [gallery, track, store, requestCameraAccessIfNeeded]) + }, [gallery, track, requestCameraAccessIfNeeded]) const shouldShowCameraButton = isNative || isMobileWeb if (!shouldShowCameraButton) { @@ -56,7 +57,7 @@ export function OpenCameraBtn({gallery}: Props) { style={styles.button} hitSlop={HITSLOP_10} accessibilityRole="button" - accessibilityLabel="Camera" + accessibilityLabel={_(msg`Camera`)} accessibilityHint="Opens camera on device"> <FontAwesomeIcon icon="camera" diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx index a6826eb98..af0a22b01 100644 --- a/src/view/com/composer/photos/SelectPhotoBtn.tsx +++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx @@ -10,6 +10,8 @@ import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions' import {GalleryModel} from 'state/models/media/gallery' import {HITSLOP_10} from 'lib/constants' import {isNative} from 'platform/detection' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' type Props = { gallery: GalleryModel @@ -18,6 +20,7 @@ type Props = { export function SelectPhotoBtn({gallery}: Props) { const pal = usePalette('default') const {track} = useAnalytics() + const {_} = useLingui() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() const onPressSelectPhotos = useCallback(async () => { @@ -37,7 +40,7 @@ export function SelectPhotoBtn({gallery}: Props) { style={styles.button} hitSlop={HITSLOP_10} accessibilityRole="button" - accessibilityLabel="Gallery" + accessibilityLabel={_(msg`Gallery`)} accessibilityHint="Opens device photo gallery"> <FontAwesomeIcon icon={['far', 'image']} diff --git a/src/view/com/composer/select-language/SelectLangBtn.tsx b/src/view/com/composer/select-language/SelectLangBtn.tsx index 4faac3750..78b1e9ba2 100644 --- a/src/view/com/composer/select-language/SelectLangBtn.tsx +++ b/src/view/com/composer/select-language/SelectLangBtn.tsx @@ -1,6 +1,5 @@ import React, {useCallback, useMemo} from 'react' import {StyleSheet, Keyboard} from 'react-native' -import {observer} from 'mobx-react-lite' import { FontAwesomeIcon, FontAwesomeIconStyle, @@ -12,13 +11,24 @@ import { DropdownItemButton, } from 'view/com/util/forms/DropdownButton' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {isNative} from 'platform/detection' import {codeToLanguageName} from '../../../../locale/helpers' +import {useModalControls} from '#/state/modals' +import { + useLanguagePrefs, + useLanguagePrefsApi, + toPostLanguages, + hasPostLanguage, +} from '#/state/preferences/languages' +import {t, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' -export const SelectLangBtn = observer(function SelectLangBtn() { +export function SelectLangBtn() { const pal = usePalette('default') - const store = useStores() + const {_} = useLingui() + const {openModal} = useModalControls() + const langPrefs = useLanguagePrefs() + const setLangPrefs = useLanguagePrefsApi() const onPressMore = useCallback(async () => { if (isNative) { @@ -26,11 +36,10 @@ export const SelectLangBtn = observer(function SelectLangBtn() { Keyboard.dismiss() } } - store.shell.openModal({name: 'post-languages-settings'}) - }, [store]) + openModal({name: 'post-languages-settings'}) + }, [openModal]) - const postLanguagesPref = store.preferences.postLanguages - const postLanguagePref = store.preferences.postLanguage + const postLanguagesPref = toPostLanguages(langPrefs.postLanguage) const items: DropdownItem[] = useMemo(() => { let arr: DropdownItemButton[] = [] @@ -49,13 +58,14 @@ export const SelectLangBtn = observer(function SelectLangBtn() { arr.push({ icon: - langCodes.every(code => store.preferences.hasPostLanguage(code)) && - langCodes.length === postLanguagesPref.length + langCodes.every(code => + hasPostLanguage(langPrefs.postLanguage, code), + ) && langCodes.length === postLanguagesPref.length ? ['fas', 'circle-dot'] : ['far', 'circle'], label: langName, onPress() { - store.preferences.setPostLanguage(commaSeparatedLangCodes) + setLangPrefs.setPostLanguage(commaSeparatedLangCodes) }, }) } @@ -65,24 +75,24 @@ export const SelectLangBtn = observer(function SelectLangBtn() { * Re-join here after sanitization bc postLanguageHistory is an array of * comma-separated strings too */ - add(postLanguagePref) + add(langPrefs.postLanguage) } // comma-separted strings of lang codes that have been used in the past - for (const lang of store.preferences.postLanguageHistory) { + for (const lang of langPrefs.postLanguageHistory) { add(lang) } return [ - {heading: true, label: 'Post language'}, + {heading: true, label: t`Post language`}, ...arr.slice(0, 6), {sep: true}, { - label: 'Other...', + label: t`Other...`, onPress: onPressMore, }, ] - }, [store.preferences, onPressMore, postLanguagePref, postLanguagesPref]) + }, [onPressMore, langPrefs, setLangPrefs, postLanguagesPref]) return ( <DropdownButton @@ -91,7 +101,7 @@ export const SelectLangBtn = observer(function SelectLangBtn() { items={items} openUpwards style={styles.button} - accessibilityLabel="Language selection" + accessibilityLabel={_(msg`Language selection`)} accessibilityHint=""> {postLanguagesPref.length > 0 ? ( <Text type="lg-bold" style={[pal.link, styles.label]} numberOfLines={1}> @@ -106,7 +116,7 @@ export const SelectLangBtn = observer(function SelectLangBtn() { )} </DropdownButton> ) -}) +} const styles = StyleSheet.create({ button: { diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 2810129f6..13fe3a0b3 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useRef, useMemo, + useState, ComponentProps, } from 'react' import { @@ -18,7 +19,6 @@ import PasteInput, { } from '@mattermost/react-native-paste-input' import {AppBskyRichtextFacet, RichText} from '@atproto/api' import isEqual from 'lodash.isequal' -import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' import {Autocomplete} from './mobile/Autocomplete' import {Text} from 'view/com/util/text/Text' import {cleanError} from 'lib/strings/errors' @@ -38,7 +38,6 @@ interface TextInputProps extends ComponentProps<typeof RNTextInput> { richtext: RichText placeholder: string suggestedLinks: Set<string> - autocompleteView: UserAutocompleteModel setRichText: (v: RichText | ((v: RichText) => RichText)) => void onPhotoPasted: (uri: string) => void onPressPublish: (richtext: RichText) => Promise<void> @@ -56,7 +55,6 @@ export const TextInput = forwardRef(function TextInputImpl( richtext, placeholder, suggestedLinks, - autocompleteView, setRichText, onPhotoPasted, onSuggestedLinksChanged, @@ -69,6 +67,7 @@ export const TextInput = forwardRef(function TextInputImpl( const textInput = useRef<PasteInputRef>(null) const textInputSelection = useRef<Selection>({start: 0, end: 0}) const theme = useTheme() + const [autocompletePrefix, setAutocompletePrefix] = useState('') React.useImperativeHandle(ref, () => ({ focus: () => textInput.current?.focus(), @@ -99,10 +98,9 @@ export const TextInput = forwardRef(function TextInputImpl( textInputSelection.current?.start || 0, ) if (prefix) { - autocompleteView.setActive(true) - autocompleteView.setPrefix(prefix.value) - } else { - autocompleteView.setActive(false) + setAutocompletePrefix(prefix.value) + } else if (autocompletePrefix) { + setAutocompletePrefix('') } const set: Set<string> = new Set() @@ -139,7 +137,8 @@ export const TextInput = forwardRef(function TextInputImpl( }, [ setRichText, - autocompleteView, + autocompletePrefix, + setAutocompletePrefix, suggestedLinks, onSuggestedLinksChanged, onPhotoPasted, @@ -179,9 +178,9 @@ export const TextInput = forwardRef(function TextInputImpl( item, ), ) - autocompleteView.setActive(false) + setAutocompletePrefix('') }, - [onChangeText, richtext, autocompleteView], + [onChangeText, richtext, setAutocompletePrefix], ) const textDecorated = useMemo(() => { @@ -221,7 +220,7 @@ export const TextInput = forwardRef(function TextInputImpl( {textDecorated} </PasteInput> <Autocomplete - view={autocompleteView} + prefix={autocompletePrefix} onSelect={onSelectAutocompleteItem} /> </View> diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 35482bc70..4c31da338 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -11,13 +11,13 @@ import {Paragraph} from '@tiptap/extension-paragraph' import {Placeholder} from '@tiptap/extension-placeholder' import {Text} from '@tiptap/extension-text' import isEqual from 'lodash.isequal' -import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' import {createSuggestion} from './web/Autocomplete' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {isUriImage, blobToDataUri} from 'lib/media/util' import {Emoji} from './web/EmojiPicker.web' import {LinkDecorator} from './web/LinkDecorator' import {generateJSON} from '@tiptap/html' +import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' export interface TextInputRef { focus: () => void @@ -28,7 +28,6 @@ interface TextInputProps { richtext: RichText placeholder: string suggestedLinks: Set<string> - autocompleteView: UserAutocompleteModel setRichText: (v: RichText | ((v: RichText) => RichText)) => void onPhotoPasted: (uri: string) => void onPressPublish: (richtext: RichText) => Promise<void> @@ -43,7 +42,6 @@ export const TextInput = React.forwardRef(function TextInputImpl( richtext, placeholder, suggestedLinks, - autocompleteView, setRichText, onPhotoPasted, onPressPublish, @@ -52,6 +50,8 @@ export const TextInput = React.forwardRef(function TextInputImpl( TextInputProps, ref, ) { + const autocomplete = useActorAutocompleteFn() + const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark') const extensions = React.useMemo( () => [ @@ -61,7 +61,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( HTMLAttributes: { class: 'mention', }, - suggestion: createSuggestion({autocompleteView}), + suggestion: createSuggestion({autocomplete}), }), Paragraph, Placeholder.configure({ @@ -71,7 +71,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( History, Hardbreak, ], - [autocompleteView, placeholder], + [autocomplete, placeholder], ) React.useEffect(() => { diff --git a/src/view/com/composer/text-input/mobile/Autocomplete.tsx b/src/view/com/composer/text-input/mobile/Autocomplete.tsx index f8335d4b9..c400aa48d 100644 --- a/src/view/com/composer/text-input/mobile/Autocomplete.tsx +++ b/src/view/com/composer/text-input/mobile/Autocomplete.tsx @@ -1,31 +1,40 @@ -import React, {useEffect} from 'react' +import React, {useEffect, useRef} from 'react' import {Animated, TouchableOpacity, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {usePalette} from 'lib/hooks/usePalette' import {Text} from 'view/com/util/text/Text' import {UserAvatar} from 'view/com/util/UserAvatar' import {useGrapheme} from '../hooks/useGrapheme' +import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' +import {Trans} from '@lingui/macro' +import {AppBskyActorDefs} from '@atproto/api' -export const Autocomplete = observer(function AutocompleteImpl({ - view, +export function Autocomplete({ + prefix, onSelect, }: { - view: UserAutocompleteModel + prefix: string onSelect: (item: string) => void }) { const pal = usePalette('default') const positionInterp = useAnimatedValue(0) const {getGraphemeString} = useGrapheme() + const isActive = !!prefix + const {data: suggestions, isFetching} = useActorAutocompleteQuery(prefix) + const suggestionsRef = useRef< + AppBskyActorDefs.ProfileViewBasic[] | undefined + >(undefined) + if (suggestions) { + suggestionsRef.current = suggestions + } useEffect(() => { Animated.timing(positionInterp, { - toValue: view.isActive ? 1 : 0, + toValue: isActive ? 1 : 0, duration: 200, useNativeDriver: true, }).start() - }, [positionInterp, view.isActive]) + }, [positionInterp, isActive]) const topAnimStyle = { transform: [ @@ -40,10 +49,10 @@ export const Autocomplete = observer(function AutocompleteImpl({ return ( <Animated.View style={topAnimStyle}> - {view.isActive ? ( + {isActive ? ( <View style={[pal.view, styles.container, pal.border]}> - {view.suggestions.length > 0 ? ( - view.suggestions.slice(0, 5).map(item => { + {suggestionsRef.current?.length ? ( + suggestionsRef.current.slice(0, 5).map(item => { // Eventually use an average length const MAX_CHARS = 40 const MAX_HANDLE_CHARS = 20 @@ -82,14 +91,18 @@ export const Autocomplete = observer(function AutocompleteImpl({ }) ) : ( <Text type="sm" style={[pal.text, pal.border, styles.noResults]}> - No result + {isFetching ? ( + <Trans>Loading...</Trans> + ) : ( + <Trans>No result</Trans> + )} </Text> )} </View> ) : null} </Animated.View> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx index bbed26d48..1f7412561 100644 --- a/src/view/com/composer/text-input/web/Autocomplete.tsx +++ b/src/view/com/composer/text-input/web/Autocomplete.tsx @@ -12,7 +12,7 @@ import { SuggestionProps, SuggestionKeyDownProps, } from '@tiptap/suggestion' -import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' +import {ActorAutocompleteFn} from '#/state/queries/actor-autocomplete' import {usePalette} from 'lib/hooks/usePalette' import {Text} from 'view/com/util/text/Text' import {UserAvatar} from 'view/com/util/UserAvatar' @@ -23,15 +23,14 @@ interface MentionListRef { } export function createSuggestion({ - autocompleteView, + autocomplete, }: { - autocompleteView: UserAutocompleteModel + autocomplete: ActorAutocompleteFn }): Omit<SuggestionOptions, 'editor'> { return { async items({query}) { - autocompleteView.setActive(true) - await autocompleteView.setPrefix(query) - return autocompleteView.suggestions.slice(0, 8) + const suggestions = await autocomplete({query}) + return suggestions.slice(0, 8) }, render: () => { diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts index eda1a6704..ef3958c9d 100644 --- a/src/view/com/composer/useExternalLinkFetch.ts +++ b/src/view/com/composer/useExternalLinkFetch.ts @@ -1,5 +1,4 @@ import {useState, useEffect} from 'react' -import {useStores} from 'state/index' import {ImageModel} from 'state/models/media/image' import * as apilib from 'lib/api/index' import {getLinkMeta} from 'lib/link-meta/link-meta' @@ -14,19 +13,21 @@ import { isBskyCustomFeedUrl, isBskyListUrl, } from 'lib/strings/url-helpers' -import {ComposerOpts} from 'state/models/ui/shell' +import {ComposerOpts} from 'state/shell/composer' import {POST_IMG_MAX} from 'lib/constants' import {logger} from '#/logger' +import {getAgent} from '#/state/session' +import {useGetPost} from '#/state/queries/post' export function useExternalLinkFetch({ setQuote, }: { setQuote: (opts: ComposerOpts['quote']) => void }) { - const store = useStores() const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>( undefined, ) + const getPost = useGetPost() useEffect(() => { let aborted = false @@ -38,7 +39,7 @@ export function useExternalLinkFetch({ } if (!extLink.meta) { if (isBskyPostUrl(extLink.uri)) { - getPostAsQuote(store, extLink.uri).then( + getPostAsQuote(getPost, extLink.uri).then( newQuote => { if (aborted) { return @@ -48,13 +49,13 @@ export function useExternalLinkFetch({ }, err => { logger.error('Failed to fetch post for quote embedding', { - error: err, + error: err.toString(), }) setExtLink(undefined) }, ) } else if (isBskyCustomFeedUrl(extLink.uri)) { - getFeedAsEmbed(store, extLink.uri).then( + getFeedAsEmbed(getAgent(), extLink.uri).then( ({embed, meta}) => { if (aborted) { return @@ -72,7 +73,7 @@ export function useExternalLinkFetch({ }, ) } else if (isBskyListUrl(extLink.uri)) { - getListAsEmbed(store, extLink.uri).then( + getListAsEmbed(getAgent(), extLink.uri).then( ({embed, meta}) => { if (aborted) { return @@ -90,7 +91,7 @@ export function useExternalLinkFetch({ }, ) } else { - getLinkMeta(store, extLink.uri).then(meta => { + getLinkMeta(getAgent(), extLink.uri).then(meta => { if (aborted) { return } @@ -120,9 +121,7 @@ export function useExternalLinkFetch({ setExtLink({ ...extLink, isLoading: false, // done - localThumb: localThumb - ? new ImageModel(store, localThumb) - : undefined, + localThumb: localThumb ? new ImageModel(localThumb) : undefined, }) }) return cleanup @@ -134,7 +133,7 @@ export function useExternalLinkFetch({ }) } return cleanup - }, [store, extLink, setQuote]) + }, [extLink, setQuote, getPost]) return {extLink, setExtLink} } diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index 1037007b7..f3f07a8bd 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -4,74 +4,56 @@ import { } from '@fortawesome/react-native-fontawesome' import {useIsFocused} from '@react-navigation/native' import {useAnalytics} from '@segment/analytics-react-native' +import {useQueryClient} from '@tanstack/react-query' +import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' import {ComposeIcon2} from 'lib/icons' import {colors, s} from 'lib/styles' -import {observer} from 'mobx-react-lite' import React from 'react' -import {FlatList, View} from 'react-native' -import {useStores} from 'state/index' -import {PostsFeedModel} from 'state/models/feeds/posts' -import {useHeaderOffset, POLL_FREQ} from 'view/screens/Home' +import {FlatList, View, useWindowDimensions} from 'react-native' import {Feed} from '../posts/Feed' import {TextLink} from '../util/Link' import {FAB} from '../util/fab/FAB' import {LoadLatestBtn} from '../util/load-latest/LoadLatestBtn' -import useAppState from 'react-native-appstate-hook' -import {logger} from '#/logger' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useSession} from '#/state/session' +import {useComposerControls} from '#/state/shell/composer' +import {listenSoftReset, emitSoftReset} from '#/state/events' +import {truncateAndInvalidate} from '#/state/queries/util' -export const FeedPage = observer(function FeedPageImpl({ +const POLL_FREQ = 30e3 // 30sec + +export function FeedPage({ testID, isPageFocused, feed, + feedParams, renderEmptyState, renderEndOfFeed, }: { testID?: string - feed: PostsFeedModel + feed: FeedDescriptor + feedParams?: FeedParams isPageFocused: boolean renderEmptyState: () => JSX.Element renderEndOfFeed?: () => JSX.Element }) { - const store = useStores() + const {isSandbox, hasSession} = useSession() const pal = usePalette('default') + const {_} = useLingui() const {isDesktop} = useWebMediaQueries() + const queryClient = useQueryClient() + const {openComposer} = useComposerControls() const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll() const {screen, track} = useAnalytics() const headerOffset = useHeaderOffset() const scrollElRef = React.useRef<FlatList>(null) - const {appState} = useAppState({ - onForeground: () => doPoll(true), - }) const isScreenFocused = useIsFocused() - const hasNew = feed.hasNewLatest && !feed.isRefreshing - - React.useEffect(() => { - // called on first load - if (!feed.hasLoaded && isPageFocused) { - feed.setup() - } - }, [isPageFocused, feed]) - - const doPoll = React.useCallback( - (knownActive = false) => { - if ( - (!knownActive && appState !== 'active') || - !isScreenFocused || - !isPageFocused - ) { - return - } - if (feed.isLoading) { - return - } - logger.debug('HomeScreen: Polling for new posts') - feed.checkForLatest() - }, - [appState, isScreenFocused, isPageFocused, feed], - ) + const [hasNew, setHasNew] = React.useState(false) const scrollToTop = React.useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerOffset}) @@ -81,41 +63,30 @@ export const FeedPage = observer(function FeedPageImpl({ const onSoftReset = React.useCallback(() => { if (isPageFocused) { scrollToTop() - feed.refresh() + truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) + setHasNew(false) } - }, [isPageFocused, scrollToTop, feed]) + }, [isPageFocused, scrollToTop, queryClient, feed, setHasNew]) // fires when page within screen is activated/deactivated - // - check for latest React.useEffect(() => { if (!isPageFocused || !isScreenFocused) { return } - - const softResetSub = store.onScreenSoftReset(onSoftReset) - const feedCleanup = feed.registerListeners() - const pollInterval = setInterval(doPoll, POLL_FREQ) - screen('Feed') - logger.debug('HomeScreen: Updating feed') - feed.checkForLatest() - - return () => { - clearInterval(pollInterval) - softResetSub.remove() - feedCleanup() - } - }, [store, doPoll, onSoftReset, screen, feed, isPageFocused, isScreenFocused]) + return listenSoftReset(onSoftReset) + }, [onSoftReset, screen, isPageFocused, isScreenFocused]) const onPressCompose = React.useCallback(() => { track('HomeScreen:PressCompose') - store.shell.openComposer({}) - }, [store, track]) + openComposer({}) + }, [openComposer, track]) const onPressLoadLatest = React.useCallback(() => { scrollToTop() - feed.refresh() - }, [feed, scrollToTop]) + truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) + setHasNew(false) + }, [scrollToTop, feed, queryClient, setHasNew]) const ListHeaderComponent = React.useCallback(() => { if (isDesktop) { @@ -137,7 +108,7 @@ export const FeedPage = observer(function FeedPageImpl({ style={[pal.text, {fontWeight: 'bold'}]} text={ <> - {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}{' '} + {isSandbox ? 'SANDBOX' : 'Bluesky'}{' '} {hasNew && ( <View style={{ @@ -151,35 +122,50 @@ export const FeedPage = observer(function FeedPageImpl({ )} </> } - onPress={() => store.emitScreenSoftReset()} - /> - <TextLink - type="title-lg" - href="/settings/home-feed" - style={{fontWeight: 'bold'}} - accessibilityLabel="Feed Preferences" - accessibilityHint="" - text={ - <FontAwesomeIcon - icon="sliders" - style={pal.textLight as FontAwesomeIconStyle} - /> - } + onPress={emitSoftReset} /> + {hasSession && ( + <TextLink + type="title-lg" + href="/settings/home-feed" + style={{fontWeight: 'bold'}} + accessibilityLabel={_(msg`Feed Preferences`)} + accessibilityHint="" + text={ + <FontAwesomeIcon + icon="sliders" + style={pal.textLight as FontAwesomeIconStyle} + /> + } + /> + )} </View> ) } return <></> - }, [isDesktop, pal, store, hasNew]) + }, [ + isDesktop, + pal.view, + pal.text, + pal.textLight, + hasNew, + _, + isSandbox, + hasSession, + ]) return ( <View testID={testID} style={s.h100pct}> <Feed testID={testID ? `${testID}-feed` : undefined} + enabled={isPageFocused} feed={feed} + feedParams={feedParams} + pollInterval={POLL_FREQ} scrollElRef={scrollElRef} onScroll={onMainScroll} - scrollEventThrottle={100} + onHasNew={setHasNew} + scrollEventThrottle={1} renderEmptyState={renderEmptyState} renderEndOfFeed={renderEndOfFeed} ListHeaderComponent={ListHeaderComponent} @@ -188,18 +174,52 @@ export const FeedPage = observer(function FeedPageImpl({ {(isScrolledDown || hasNew) && ( <LoadLatestBtn onPress={onPressLoadLatest} - label="Load new posts" + label={_(msg`Load new posts`)} showIndicator={hasNew} /> )} - <FAB - testID="composeFAB" - onPress={onPressCompose} - icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} - accessibilityRole="button" - accessibilityLabel="New post" - accessibilityHint="" - /> + + {hasSession && ( + <FAB + testID="composeFAB" + onPress={onPressCompose} + icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} + accessibilityRole="button" + accessibilityLabel={_(msg`New post`)} + accessibilityHint="" + /> + )} </View> ) -}) +} + +function useHeaderOffset() { + const {isDesktop, isTablet} = useWebMediaQueries() + const {fontScale} = useWindowDimensions() + const {hasSession} = useSession() + + if (isDesktop) { + return 0 + } + if (isTablet) { + if (hasSession) { + return 50 + } else { + return 0 + } + } + + if (hasSession) { + const navBarPad = 16 + const navBarText = 21 * fontScale + const tabBarPad = 20 + 3 // nav bar padding + border + const tabBarText = 16 * fontScale + const magic = 7 * fontScale + return navBarPad + navBarText + tabBarPad + tabBarText + magic + } else { + const navBarPad = 16 + const navBarText = 21 * fontScale + const magic = 4 * fontScale + return navBarPad + navBarText + magic + } +} diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx index 2c4335dc1..1f2af069b 100644 --- a/src/view/com/feeds/FeedSourceCard.tsx +++ b/src/view/com/feeds/FeedSourceCard.tsx @@ -6,43 +6,110 @@ import {RichText} from '../util/text/RichText' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {UserAvatar} from '../util/UserAvatar' -import {observer} from 'mobx-react-lite' -import {FeedSourceModel} from 'state/models/content/feed-source' import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' -import {useStores} from 'state/index' import {pluralize} from 'lib/strings/helpers' import {AtUri} from '@atproto/api' import * as Toast from 'view/com/util/Toast' import {sanitizeHandle} from 'lib/strings/handles' import {logger} from '#/logger' +import {useModalControls} from '#/state/modals' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import { + usePinFeedMutation, + UsePreferencesQueryResponse, + usePreferencesQuery, + useSaveFeedMutation, + useRemoveFeedMutation, +} from '#/state/queries/preferences' +import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed' +import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' -export const FeedSourceCard = observer(function FeedSourceCardImpl({ - item, +export function FeedSourceCard({ + feedUri, style, showSaveBtn = false, showDescription = false, showLikes = false, + LoadingComponent, + pinOnSave = false, }: { - item: FeedSourceModel + feedUri: string style?: StyleProp<ViewStyle> showSaveBtn?: boolean showDescription?: boolean showLikes?: boolean + LoadingComponent?: JSX.Element + pinOnSave?: boolean +}) { + const {data: preferences} = usePreferencesQuery() + const {data: feed} = useFeedSourceInfoQuery({uri: feedUri}) + + if (!feed || !preferences) { + return LoadingComponent ? ( + LoadingComponent + ) : ( + <FeedLoadingPlaceholder style={{flex: 1}} /> + ) + } + + return ( + <FeedSourceCardLoaded + feed={feed} + preferences={preferences} + style={style} + showSaveBtn={showSaveBtn} + showDescription={showDescription} + showLikes={showLikes} + pinOnSave={pinOnSave} + /> + ) +} + +export function FeedSourceCardLoaded({ + feed, + preferences, + style, + showSaveBtn = false, + showDescription = false, + showLikes = false, + pinOnSave = false, +}: { + feed: FeedSourceInfo + preferences: UsePreferencesQueryResponse + style?: StyleProp<ViewStyle> + showSaveBtn?: boolean + showDescription?: boolean + showLikes?: boolean + pinOnSave?: boolean }) { - const store = useStores() const pal = usePalette('default') + const {_} = useLingui() const navigation = useNavigation<NavigationProp>() + const {openModal} = useModalControls() + + const {isPending: isSavePending, mutateAsync: saveFeed} = + useSaveFeedMutation() + const {isPending: isRemovePending, mutateAsync: removeFeed} = + useRemoveFeedMutation() + const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation() + + const isSaved = Boolean(preferences?.feeds?.saved?.includes(feed.uri)) const onToggleSaved = React.useCallback(async () => { - if (item.isSaved) { - store.shell.openModal({ + // Only feeds can be un/saved, lists are handled elsewhere + if (feed?.type !== 'feed') return + + if (isSaved) { + openModal({ name: 'confirm', - title: 'Remove from my feeds', - message: `Remove ${item.displayName} from my feeds?`, + title: _(msg`Remove from my feeds`), + message: _(msg`Remove ${feed.displayName} from my feeds?`), onPressConfirm: async () => { try { - await item.unsave() + await removeFeed({uri: feed.uri}) + // await item.unsave() Toast.show('Removed from my feeds') } catch (e) { Toast.show('There was an issue contacting your server') @@ -52,58 +119,67 @@ export const FeedSourceCard = observer(function FeedSourceCardImpl({ }) } else { try { - await item.save() + if (pinOnSave) { + await pinFeed({uri: feed.uri}) + } else { + await saveFeed({uri: feed.uri}) + } Toast.show('Added to my feeds') } catch (e) { Toast.show('There was an issue contacting your server') logger.error('Failed to save feed', {error: e}) } } - }, [store, item]) + }, [isSaved, openModal, feed, removeFeed, saveFeed, _, pinOnSave, pinFeed]) + + if (!feed || !preferences) return null return ( <Pressable - testID={`feed-${item.displayName}`} + testID={`feed-${feed.displayName}`} accessibilityRole="button" style={[styles.container, pal.border, style]} onPress={() => { - if (item.type === 'feed-generator') { + if (feed.type === 'feed') { navigation.push('ProfileFeed', { - name: item.creatorDid, - rkey: new AtUri(item.uri).rkey, + name: feed.creatorDid, + rkey: new AtUri(feed.uri).rkey, }) - } else if (item.type === 'list') { + } else if (feed.type === 'list') { navigation.push('ProfileList', { - name: item.creatorDid, - rkey: new AtUri(item.uri).rkey, + name: feed.creatorDid, + rkey: new AtUri(feed.uri).rkey, }) } }} - key={item.uri}> + key={feed.uri}> <View style={[styles.headerContainer]}> <View style={[s.mr10]}> - <UserAvatar type="algo" size={36} avatar={item.avatar} /> + <UserAvatar type="algo" size={36} avatar={feed.avatar} /> </View> <View style={[styles.headerTextContainer]}> <Text style={[pal.text, s.bold]} numberOfLines={3}> - {item.displayName} + {feed.displayName} </Text> <Text style={[pal.textLight]} numberOfLines={3}> - by {sanitizeHandle(item.creatorHandle, '@')} + {feed.type === 'feed' ? 'Feed' : 'List'} by{' '} + {sanitizeHandle(feed.creatorHandle, '@')} </Text> </View> - {showSaveBtn && ( + + {showSaveBtn && feed.type === 'feed' && ( <View> <Pressable + disabled={isSavePending || isPinPending || isRemovePending} accessibilityRole="button" accessibilityLabel={ - item.isSaved ? 'Remove from my feeds' : 'Add to my feeds' + isSaved ? 'Remove from my feeds' : 'Add to my feeds' } accessibilityHint="" onPress={onToggleSaved} hitSlop={15} style={styles.btn}> - {item.isSaved ? ( + {isSaved ? ( <FontAwesomeIcon icon={['far', 'trash-can']} size={19} @@ -121,23 +197,23 @@ export const FeedSourceCard = observer(function FeedSourceCardImpl({ )} </View> - {showDescription && item.descriptionRT ? ( + {showDescription && feed.description ? ( <RichText style={[pal.textLight, styles.description]} - richText={item.descriptionRT} + richText={feed.description} numberOfLines={3} /> ) : null} - {showLikes ? ( + {showLikes && feed.type === 'feed' ? ( <Text type="sm-medium" style={[pal.text, pal.textLight]}> - Liked by {item.likeCount || 0}{' '} - {pluralize(item.likeCount || 0, 'user')} + Liked by {feed.likeCount || 0}{' '} + {pluralize(feed.likeCount || 0, 'user')} </Text> ) : null} </Pressable> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx new file mode 100644 index 000000000..618f4e5cd --- /dev/null +++ b/src/view/com/feeds/ProfileFeedgens.tsx @@ -0,0 +1,222 @@ +import React, {MutableRefObject} from 'react' +import { + Dimensions, + RefreshControl, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native' +import {useQueryClient} from '@tanstack/react-query' +import {FlatList} from '../util/Views' +import {FeedSourceCardLoaded} from './FeedSourceCard' +import {ErrorMessage} from '../util/error/ErrorMessage' +import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' +import {Text} from '../util/text/Text' +import {usePalette} from 'lib/hooks/usePalette' +import {useProfileFeedgensQuery, RQKEY} from '#/state/queries/profile-feedgens' +import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll' +import {logger} from '#/logger' +import {Trans} from '@lingui/macro' +import {cleanError} from '#/lib/strings/errors' +import {useAnimatedScrollHandler} from 'react-native-reanimated' +import {useTheme} from '#/lib/ThemeContext' +import {usePreferencesQuery} from '#/state/queries/preferences' +import {hydrateFeedGenerator} from '#/state/queries/feed' +import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' + +const LOADING = {_reactKey: '__loading__'} +const EMPTY = {_reactKey: '__empty__'} +const ERROR_ITEM = {_reactKey: '__error__'} +const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} + +interface SectionRef { + scrollToTop: () => void +} + +interface ProfileFeedgensProps { + did: string + scrollElRef: MutableRefObject<FlatList<any> | null> + onScroll?: OnScrollHandler + scrollEventThrottle?: number + headerOffset: number + enabled?: boolean + style?: StyleProp<ViewStyle> + testID?: string +} + +export const ProfileFeedgens = React.forwardRef< + SectionRef, + ProfileFeedgensProps +>(function ProfileFeedgensImpl( + { + did, + scrollElRef, + onScroll, + scrollEventThrottle, + headerOffset, + enabled, + style, + testID, + }, + ref, +) { + const pal = usePalette('default') + const theme = useTheme() + const [isPTRing, setIsPTRing] = React.useState(false) + const opts = React.useMemo(() => ({enabled}), [enabled]) + const { + data, + isFetching, + isFetched, + hasNextPage, + fetchNextPage, + isError, + error, + refetch, + } = useProfileFeedgensQuery(did, opts) + const isEmpty = !isFetching && !data?.pages[0]?.feeds.length + const {data: preferences} = usePreferencesQuery() + + const items = React.useMemo(() => { + let items: any[] = [] + if (isError && isEmpty) { + items = items.concat([ERROR_ITEM]) + } + if (!isFetched && isFetching) { + items = items.concat([LOADING]) + } else if (isEmpty) { + items = items.concat([EMPTY]) + } else if (data?.pages) { + for (const page of data?.pages) { + items = items.concat(page.feeds.map(feed => hydrateFeedGenerator(feed))) + } + } + if (isError && !isEmpty) { + items = items.concat([LOAD_MORE_ERROR_ITEM]) + } + return items + }, [isError, isEmpty, isFetched, isFetching, data]) + + // events + // = + + const queryClient = useQueryClient() + + const onScrollToTop = React.useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: -headerOffset}) + queryClient.invalidateQueries({queryKey: RQKEY(did)}) + }, [scrollElRef, queryClient, headerOffset, did]) + + React.useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + const onRefresh = React.useCallback(async () => { + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh feeds', {error: err}) + } + setIsPTRing(false) + }, [refetch, setIsPTRing]) + + const onEndReached = React.useCallback(async () => { + if (isFetching || !hasNextPage || isError) return + + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more feeds', {error: err}) + } + }, [isFetching, hasNextPage, isError, fetchNextPage]) + + const onPressRetryLoadMore = React.useCallback(() => { + fetchNextPage() + }, [fetchNextPage]) + + // rendering + // = + + const renderItemInner = React.useCallback( + ({item}: {item: any}) => { + if (item === EMPTY) { + return ( + <View + testID="listsEmpty" + style={[{padding: 18, borderTopWidth: 1}, pal.border]}> + <Text style={pal.textLight}> + <Trans>You have no feeds.</Trans> + </Text> + </View> + ) + } else if (item === ERROR_ITEM) { + return ( + <ErrorMessage message={cleanError(error)} onPressTryAgain={refetch} /> + ) + } else if (item === LOAD_MORE_ERROR_ITEM) { + return ( + <LoadMoreRetryBtn + label="There was an issue fetching your lists. Tap here to try again." + onPress={onPressRetryLoadMore} + /> + ) + } else if (item === LOADING) { + return <FeedLoadingPlaceholder /> + } + if (preferences) { + return ( + <FeedSourceCardLoaded + feed={item} + preferences={preferences} + style={styles.item} + showLikes + /> + ) + } + return null + }, + [error, refetch, onPressRetryLoadMore, pal, preferences], + ) + + const scrollHandler = useAnimatedScrollHandler(onScroll || {}) + return ( + <View testID={testID} style={style}> + <FlatList + testID={testID ? `${testID}-flatlist` : undefined} + ref={scrollElRef} + data={items} + keyExtractor={(item: any) => item._reactKey || item.uri} + renderItem={renderItemInner} + refreshControl={ + <RefreshControl + refreshing={isPTRing} + onRefresh={onRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + progressViewOffset={headerOffset} + /> + } + contentContainerStyle={{ + minHeight: Dimensions.get('window').height * 1.5, + }} + style={{paddingTop: headerOffset}} + onScroll={onScroll != null ? scrollHandler : undefined} + scrollEventThrottle={scrollEventThrottle} + indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'} + removeClippedSubviews={true} + contentOffset={{x: 0, y: headerOffset * -1}} + // @ts-ignore our .web version only -prf + desktopFixedHeight + onEndReached={onEndReached} + /> + </View> + ) +}) + +const styles = StyleSheet.create({ + item: { + paddingHorizontal: 18, + }, +}) diff --git a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx index bb006d506..c806bc6a6 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx @@ -5,10 +5,10 @@ * LICENSE file in the root directory of this source tree. * */ - -import {createHitslop} from 'lib/constants' import React from 'react' +import {createHitslop} from 'lib/constants' import {SafeAreaView, Text, TouchableOpacity, StyleSheet} from 'react-native' +import {t} from '@lingui/macro' type Props = { onRequestClose: () => void @@ -23,7 +23,7 @@ const ImageDefaultHeader = ({onRequestClose}: Props) => ( onPress={onRequestClose} hitSlop={HIT_SLOP} accessibilityRole="button" - accessibilityLabel="Close image" + accessibilityLabel={t`Close image`} accessibilityHint="Closes viewer for header image" onAccessibilityEscape={onRequestClose}> <Text style={styles.closeText}>✕</Text> diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx index 7c7ad0616..ea740ec91 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -315,7 +315,6 @@ const ImageItem = ({ <GestureDetector gesture={composedGesture}> <AnimatedImage contentFit="contain" - // NOTE: Don't pass imageSrc={imageSrc} or MobX will break. source={{uri: imageSrc.uri}} style={[styles.image, animatedStyle]} accessibilityLabel={imageSrc.alt} diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx index f73f355ac..2b0b0b149 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx @@ -139,7 +139,6 @@ const ImageItem = ({imageSrc, onTap, onZoom, onRequestClose}: Props) => { {(!loaded || !imageDimensions) && <ImageLoading />} <AnimatedImage contentFit="contain" - // NOTE: Don't pass imageSrc={imageSrc} or MobX will break. source={{uri: imageSrc.uri}} style={[styles.image, animatedStyle]} accessibilityLabel={imageSrc.alt} diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx index 92c30f491..8a18df33f 100644 --- a/src/view/com/lightbox/Lightbox.tsx +++ b/src/view/com/lightbox/Lightbox.tsx @@ -1,10 +1,7 @@ import React from 'react' import {Pressable, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import ImageView from './ImageViewing' -import {useStores} from 'state/index' -import * as models from 'state/models/ui/shell' import {shareImageModal, saveImageToMediaLibrary} from 'lib/media/manip' import * as Toast from '../util/Toast' import {Text} from '../util/text/Text' @@ -12,28 +9,35 @@ import {s, colors} from 'lib/styles' import {Button} from '../util/forms/Button' import {isIOS} from 'platform/detection' import * as MediaLibrary from 'expo-media-library' +import { + useLightbox, + useLightboxControls, + ProfileImageLightbox, + ImagesLightbox, +} from '#/state/lightbox' -export const Lightbox = observer(function Lightbox() { - const store = useStores() +export function Lightbox() { + const {activeLightbox} = useLightbox() + const {closeLightbox} = useLightboxControls() const onClose = React.useCallback(() => { - store.shell.closeLightbox() - }, [store]) + closeLightbox() + }, [closeLightbox]) - if (!store.shell.activeLightbox) { + if (!activeLightbox) { return null - } else if (store.shell.activeLightbox.name === 'profile-image') { - const opts = store.shell.activeLightbox as models.ProfileImageLightbox + } else if (activeLightbox.name === 'profile-image') { + const opts = activeLightbox as ProfileImageLightbox return ( <ImageView - images={[{uri: opts.profileView.avatar || ''}]} + images={[{uri: opts.profile.avatar || ''}]} initialImageIndex={0} visible onRequestClose={onClose} FooterComponent={LightboxFooter} /> ) - } else if (store.shell.activeLightbox.name === 'images') { - const opts = store.shell.activeLightbox as models.ImagesLightbox + } else if (activeLightbox.name === 'images') { + const opts = activeLightbox as ImagesLightbox return ( <ImageView images={opts.images.map(img => ({...img}))} @@ -46,14 +50,10 @@ export const Lightbox = observer(function Lightbox() { } else { return null } -}) +} -const LightboxFooter = observer(function LightboxFooter({ - imageIndex, -}: { - imageIndex: number -}) { - const store = useStores() +function LightboxFooter({imageIndex}: {imageIndex: number}) { + const {activeLightbox} = useLightbox() const [isAltExpanded, setAltExpanded] = React.useState(false) const [permissionResponse, requestPermission] = MediaLibrary.usePermissions() @@ -81,7 +81,7 @@ const LightboxFooter = observer(function LightboxFooter({ [permissionResponse, requestPermission], ) - const lightbox = store.shell.activeLightbox + const lightbox = activeLightbox if (!lightbox) { return null } @@ -89,12 +89,12 @@ const LightboxFooter = observer(function LightboxFooter({ let altText = '' let uri = '' if (lightbox.name === 'images') { - const opts = lightbox as models.ImagesLightbox + const opts = lightbox as ImagesLightbox uri = opts.images[imageIndex].uri altText = opts.images[imageIndex].alt || '' } else if (lightbox.name === 'profile-image') { - const opts = lightbox as models.ProfileImageLightbox - uri = opts.profileView.avatar || '' + const opts = lightbox as ProfileImageLightbox + uri = opts.profile.avatar || '' } return ( @@ -132,7 +132,7 @@ const LightboxFooter = observer(function LightboxFooter({ </View> </View> ) -}) +} const styles = StyleSheet.create({ footer: { diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx index ddf965f42..45e1fa5a3 100644 --- a/src/view/com/lightbox/Lightbox.web.tsx +++ b/src/view/com/lightbox/Lightbox.web.tsx @@ -7,39 +7,42 @@ import { View, Pressable, } from 'react-native' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {useStores} from 'state/index' -import * as models from 'state/models/ui/shell' import {colors, s} from 'lib/styles' import ImageDefaultHeader from './ImageViewing/components/ImageDefaultHeader' import {Text} from '../util/text/Text' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import { + useLightbox, + useLightboxControls, + ImagesLightbox, + ProfileImageLightbox, +} from '#/state/lightbox' interface Img { uri: string alt?: string } -export const Lightbox = observer(function Lightbox() { - const store = useStores() - - const onClose = useCallback(() => store.shell.closeLightbox(), [store.shell]) +export function Lightbox() { + const {activeLightbox} = useLightbox() + const {closeLightbox} = useLightboxControls() - if (!store.shell.isLightboxActive) { + if (!activeLightbox) { return null } - const activeLightbox = store.shell.activeLightbox const initialIndex = - activeLightbox instanceof models.ImagesLightbox ? activeLightbox.index : 0 + activeLightbox instanceof ImagesLightbox ? activeLightbox.index : 0 let imgs: Img[] | undefined - if (activeLightbox instanceof models.ProfileImageLightbox) { + if (activeLightbox instanceof ProfileImageLightbox) { const opts = activeLightbox - if (opts.profileView.avatar) { - imgs = [{uri: opts.profileView.avatar}] + if (opts.profile.avatar) { + imgs = [{uri: opts.profile.avatar}] } - } else if (activeLightbox instanceof models.ImagesLightbox) { + } else if (activeLightbox instanceof ImagesLightbox) { const opts = activeLightbox imgs = opts.images } @@ -49,9 +52,13 @@ export const Lightbox = observer(function Lightbox() { } return ( - <LightboxInner imgs={imgs} initialIndex={initialIndex} onClose={onClose} /> + <LightboxInner + imgs={imgs} + initialIndex={initialIndex} + onClose={closeLightbox} + /> ) -}) +} function LightboxInner({ imgs, @@ -62,6 +69,7 @@ function LightboxInner({ initialIndex: number onClose: () => void }) { + const {_} = useLingui() const [index, setIndex] = useState<number>(initialIndex) const [isAltExpanded, setAltExpanded] = useState(false) @@ -101,7 +109,7 @@ function LightboxInner({ <TouchableWithoutFeedback onPress={onClose} accessibilityRole="button" - accessibilityLabel="Close image viewer" + accessibilityLabel={_(msg`Close image viewer`)} accessibilityHint="Exits image view" onAccessibilityEscape={onClose}> <View style={styles.imageCenterer}> @@ -117,7 +125,7 @@ function LightboxInner({ onPress={onPressLeft} style={[styles.btn, styles.leftBtn]} accessibilityRole="button" - accessibilityLabel="Previous image" + accessibilityLabel={_(msg`Previous image`)} accessibilityHint=""> <FontAwesomeIcon icon="angle-left" @@ -131,7 +139,7 @@ function LightboxInner({ onPress={onPressRight} style={[styles.btn, styles.rightBtn]} accessibilityRole="button" - accessibilityLabel="Next image" + accessibilityLabel={_(msg`Next image`)} accessibilityHint=""> <FontAwesomeIcon icon="angle-right" @@ -145,7 +153,7 @@ function LightboxInner({ {imgs[index].alt ? ( <View style={styles.footer}> <Pressable - accessibilityLabel="Expand alt text" + accessibilityLabel={_(msg`Expand alt text`)} accessibilityHint="If alt text is long, toggles alt text expanded state" onPress={() => { setAltExpanded(!isAltExpanded) diff --git a/src/view/com/lists/ListCard.tsx b/src/view/com/lists/ListCard.tsx index a481902d8..774e9e916 100644 --- a/src/view/com/lists/ListCard.tsx +++ b/src/view/com/lists/ListCard.tsx @@ -7,7 +7,7 @@ import {RichText as RichTextCom} from '../util/text/RichText' import {UserAvatar} from '../util/UserAvatar' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' +import {useSession} from '#/state/session' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {makeProfileLink} from 'lib/routes/links' @@ -28,7 +28,7 @@ export const ListCard = ({ style?: StyleProp<ViewStyle> }) => { const pal = usePalette('default') - const store = useStores() + const {currentAccount} = useSession() const rkey = React.useMemo(() => { try { @@ -80,7 +80,7 @@ export const ListCard = ({ {list.purpose === 'app.bsky.graph.defs#modlist' && 'Moderation list '} by{' '} - {list.creator.did === store.me.did + {list.creator.did === currentAccount?.did ? 'you' : sanitizeHandle(list.creator.handle, '@')} </Text> diff --git a/src/view/com/lists/ListItems.tsx b/src/view/com/lists/ListMembers.tsx index 192cdd9d3..e6afb3d3c 100644 --- a/src/view/com/lists/ListItems.tsx +++ b/src/view/com/lists/ListMembers.tsx @@ -1,6 +1,7 @@ import React, {MutableRefObject} from 'react' import { ActivityIndicator, + Dimensions, RefreshControl, StyleProp, View, @@ -8,27 +9,28 @@ import { } from 'react-native' import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api' import {FlatList} from '../util/Views' -import {observer} from 'mobx-react-lite' import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {ErrorMessage} from '../util/error/ErrorMessage' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' import {ProfileCard} from '../profile/ProfileCard' import {Button} from '../util/forms/Button' -import {ListModel} from 'state/models/content/list' import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {s} from 'lib/styles' -import {OnScrollCb} from 'lib/hooks/useOnMainScroll' +import {useListMembersQuery} from '#/state/queries/list-members' +import {OnScrollHandler} from 'lib/hooks/useOnMainScroll' import {logger} from '#/logger' +import {useModalControls} from '#/state/modals' +import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' +import {useSession} from '#/state/session' +import {cleanError} from '#/lib/strings/errors' const LOADING_ITEM = {_reactKey: '__loading__'} const EMPTY_ITEM = {_reactKey: '__empty__'} const ERROR_ITEM = {_reactKey: '__error__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} -export const ListItems = observer(function ListItemsImpl({ +export function ListMembers({ list, style, scrollElRef, @@ -41,10 +43,10 @@ export const ListItems = observer(function ListItemsImpl({ headerOffset = 0, desktopFixedHeightOffset, }: { - list: ListModel + list: string style?: StyleProp<ViewStyle> scrollElRef?: MutableRefObject<FlatList<any> | null> - onScroll?: OnScrollCb + onScroll: OnScrollHandler onPressTryAgain?: () => void renderHeader: () => JSX.Element renderEmptyState: () => JSX.Element @@ -54,37 +56,47 @@ export const ListItems = observer(function ListItemsImpl({ desktopFixedHeightOffset?: number }) { const pal = usePalette('default') - const store = useStores() const {track} = useAnalytics() const [isRefreshing, setIsRefreshing] = React.useState(false) const {isMobile} = useWebMediaQueries() + const {openModal} = useModalControls() + const {currentAccount} = useSession() - const data = React.useMemo(() => { + const { + data, + isFetching, + isFetched, + isError, + error, + refetch, + fetchNextPage, + hasNextPage, + } = useListMembersQuery(list) + const isEmpty = !isFetching && !data?.pages[0].items.length + const isOwner = + currentAccount && data?.pages[0].list.creator.did === currentAccount.did + + const items = React.useMemo(() => { let items: any[] = [] - if (list.hasLoaded) { - if (list.hasError) { + if (isFetched) { + if (isEmpty && isError) { items = items.concat([ERROR_ITEM]) } - if (list.isEmpty) { + if (isEmpty) { items = items.concat([EMPTY_ITEM]) - } else { - items = items.concat(list.items) + } else if (data) { + for (const page of data.pages) { + items = items.concat(page.items) + } } - if (list.loadMoreError) { + if (!isEmpty && isError) { items = items.concat([LOAD_MORE_ERROR_ITEM]) } - } else if (list.isLoading) { + } else if (isFetching) { items = items.concat([LOADING_ITEM]) } return items - }, [ - list.hasError, - list.hasLoaded, - list.isLoading, - list.isEmpty, - list.items, - list.loadMoreError, - ]) + }, [isFetched, isEmpty, isError, data, isFetching]) // events // = @@ -93,45 +105,36 @@ export const ListItems = observer(function ListItemsImpl({ track('Lists:onRefresh') setIsRefreshing(true) try { - await list.refresh() + await refetch() } catch (err) { logger.error('Failed to refresh lists', {error: err}) } setIsRefreshing(false) - }, [list, track, setIsRefreshing]) + }, [refetch, track, setIsRefreshing]) const onEndReached = React.useCallback(async () => { + if (isFetching || !hasNextPage || isError) return track('Lists:onEndReached') try { - await list.loadMore() + await fetchNextPage() } catch (err) { logger.error('Failed to load more lists', {error: err}) } - }, [list, track]) + }, [isFetching, hasNextPage, isError, fetchNextPage, track]) const onPressRetryLoadMore = React.useCallback(() => { - list.retryLoadMore() - }, [list]) + fetchNextPage() + }, [fetchNextPage]) const onPressEditMembership = React.useCallback( (profile: AppBskyActorDefs.ProfileViewBasic) => { - store.shell.openModal({ + openModal({ name: 'user-add-remove-lists', subject: profile.did, displayName: profile.displayName || profile.handle, - onAdd(listUri: string) { - if (listUri === list.uri) { - list.cacheAddMember(profile) - } - }, - onRemove(listUri: string) { - if (listUri === list.uri) { - list.cacheRemoveMember(profile) - } - }, }) }, - [store, list], + [openModal], ) // rendering @@ -139,7 +142,7 @@ export const ListItems = observer(function ListItemsImpl({ const renderMemberButton = React.useCallback( (profile: AppBskyActorDefs.ProfileViewBasic) => { - if (!list.isOwner) { + if (!isOwner) { return null } return ( @@ -151,7 +154,7 @@ export const ListItems = observer(function ListItemsImpl({ /> ) }, - [list, onPressEditMembership], + [isOwner, onPressEditMembership], ) const renderItem = React.useCallback( @@ -161,7 +164,7 @@ export const ListItems = observer(function ListItemsImpl({ } else if (item === ERROR_ITEM) { return ( <ErrorMessage - message={list.error} + message={cleanError(error)} onPressTryAgain={onPressTryAgain} /> ) @@ -189,7 +192,7 @@ export const ListItems = observer(function ListItemsImpl({ [ renderMemberButton, renderEmptyState, - list.error, + error, onPressTryAgain, onPressRetryLoadMore, isMobile, @@ -199,19 +202,20 @@ export const ListItems = observer(function ListItemsImpl({ const Footer = React.useCallback( () => ( <View style={{paddingTop: 20, paddingBottom: 200}}> - {list.isLoading && <ActivityIndicator />} + {isFetching && <ActivityIndicator />} </View> ), - [list.isLoading], + [isFetching], ) + const scrollHandler = useAnimatedScrollHandler(onScroll) return ( <View testID={testID} style={style}> <FlatList testID={testID ? `${testID}-flatlist` : undefined} ref={scrollElRef} - data={data} - keyExtractor={(item: any) => item._reactKey} + data={items} + keyExtractor={(item: any) => item.subject?.did || item._reactKey} renderItem={renderItem} ListHeaderComponent={renderHeader} ListFooterComponent={Footer} @@ -224,9 +228,11 @@ export const ListItems = observer(function ListItemsImpl({ progressViewOffset={headerOffset} /> } - contentContainerStyle={s.contentContainer} + contentContainerStyle={{ + minHeight: Dimensions.get('window').height * 1.5, + }} style={{paddingTop: headerOffset}} - onScroll={onScroll} + onScroll={scrollHandler} onEndReached={onEndReached} onEndReachedThreshold={0.6} scrollEventThrottle={scrollEventThrottle} @@ -237,4 +243,4 @@ export const ListItems = observer(function ListItemsImpl({ /> </View> ) -}) +} diff --git a/src/view/com/lists/ListsList.tsx b/src/view/com/lists/MyLists.tsx index 8c6510886..2c080582e 100644 --- a/src/view/com/lists/ListsList.tsx +++ b/src/view/com/lists/MyLists.tsx @@ -8,94 +8,71 @@ import { View, ViewStyle, } from 'react-native' -import {observer} from 'mobx-react-lite' import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' import {ListCard} from './ListCard' +import {MyListsFilter, useMyListsQuery} from '#/state/queries/my-lists' import {ErrorMessage} from '../util/error/ErrorMessage' -import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' import {Text} from '../util/text/Text' -import {ListsListModel} from 'state/models/lists/lists-list' import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' import {FlatList} from '../util/Views' import {s} from 'lib/styles' import {logger} from '#/logger' +import {Trans} from '@lingui/macro' +import {cleanError} from '#/lib/strings/errors' const LOADING = {_reactKey: '__loading__'} const EMPTY = {_reactKey: '__empty__'} const ERROR_ITEM = {_reactKey: '__error__'} -const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} -export const ListsList = observer(function ListsListImpl({ - listsList, +export function MyLists({ + filter, inline, style, - onPressTryAgain, renderItem, testID, }: { - listsList: ListsListModel + filter: MyListsFilter inline?: boolean style?: StyleProp<ViewStyle> - onPressTryAgain?: () => void renderItem?: (list: GraphDefs.ListView, index: number) => JSX.Element testID?: string }) { const pal = usePalette('default') const {track} = useAnalytics() - const [isRefreshing, setIsRefreshing] = React.useState(false) + const [isPTRing, setIsPTRing] = React.useState(false) + const {data, isFetching, isFetched, isError, error, refetch} = + useMyListsQuery(filter) + const isEmpty = !isFetching && !data?.length - const data = React.useMemo(() => { + const items = React.useMemo(() => { let items: any[] = [] - if (listsList.hasError) { + if (isError && isEmpty) { items = items.concat([ERROR_ITEM]) } - if (!listsList.hasLoaded && listsList.isLoading) { + if (!isFetched && isFetching) { items = items.concat([LOADING]) - } else if (listsList.isEmpty) { + } else if (isEmpty) { items = items.concat([EMPTY]) } else { - items = items.concat(listsList.lists) - } - if (listsList.loadMoreError) { - items = items.concat([LOAD_MORE_ERROR_ITEM]) + items = items.concat(data) } return items - }, [ - listsList.hasError, - listsList.hasLoaded, - listsList.isLoading, - listsList.lists, - listsList.isEmpty, - listsList.loadMoreError, - ]) + }, [isError, isEmpty, isFetched, isFetching, data]) // events // = const onRefresh = React.useCallback(async () => { track('Lists:onRefresh') - setIsRefreshing(true) + setIsPTRing(true) try { - await listsList.refresh() + await refetch() } catch (err) { logger.error('Failed to refresh lists', {error: err}) } - setIsRefreshing(false) - }, [listsList, track, setIsRefreshing]) - - const onEndReached = React.useCallback(async () => { - track('Lists:onEndReached') - try { - await listsList.loadMore() - } catch (err) { - logger.error('Failed to load more lists', {error: err}) - } - }, [listsList, track]) - - const onPressRetryLoadMore = React.useCallback(() => { - listsList.retryLoadMore() - }, [listsList]) + setIsPTRing(false) + }, [refetch, track, setIsPTRing]) // rendering // = @@ -107,21 +84,16 @@ export const ListsList = observer(function ListsListImpl({ <View testID="listsEmpty" style={[{padding: 18, borderTopWidth: 1}, pal.border]}> - <Text style={pal.textLight}>You have no lists.</Text> + <Text style={pal.textLight}> + <Trans>You have no lists.</Trans> + </Text> </View> ) } else if (item === ERROR_ITEM) { return ( <ErrorMessage - message={listsList.error} - onPressTryAgain={onPressTryAgain} - /> - ) - } else if (item === LOAD_MORE_ERROR_ITEM) { - return ( - <LoadMoreRetryBtn - label="There was an issue fetching your lists. Tap here to try again." - onPress={onPressRetryLoadMore} + message={cleanError(error)} + onPressTryAgain={onRefresh} /> ) } else if (item === LOADING) { @@ -141,29 +113,27 @@ export const ListsList = observer(function ListsListImpl({ /> ) }, - [listsList, onPressTryAgain, onPressRetryLoadMore, renderItem, pal], + [error, onRefresh, renderItem, pal], ) const FlatListCom = inline ? RNFlatList : FlatList return ( <View testID={testID} style={style}> - {data.length > 0 && ( + {items.length > 0 && ( <FlatListCom testID={testID ? `${testID}-flatlist` : undefined} - data={data} + data={items} keyExtractor={(item: any) => item._reactKey} renderItem={renderItemInner} refreshControl={ <RefreshControl - refreshing={isRefreshing} + refreshing={isPTRing} onRefresh={onRefresh} tintColor={pal.colors.text} titleColor={pal.colors.text} /> } contentContainerStyle={[s.contentContainer]} - onEndReached={onEndReached} - onEndReachedThreshold={0.6} removeClippedSubviews={true} // @ts-ignore our .web version only -prf desktopFixedHeight @@ -171,7 +141,7 @@ export const ListsList = observer(function ListsListImpl({ )} </View> ) -}) +} const styles = StyleSheet.create({ item: { diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx new file mode 100644 index 000000000..95cf8fde6 --- /dev/null +++ b/src/view/com/lists/ProfileLists.tsx @@ -0,0 +1,226 @@ +import React, {MutableRefObject} from 'react' +import { + Dimensions, + RefreshControl, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native' +import {useQueryClient} from '@tanstack/react-query' +import {FlatList} from '../util/Views' +import {ListCard} from './ListCard' +import {ErrorMessage} from '../util/error/ErrorMessage' +import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' +import {Text} from '../util/text/Text' +import {useAnalytics} from 'lib/analytics/analytics' +import {usePalette} from 'lib/hooks/usePalette' +import {useProfileListsQuery, RQKEY} from '#/state/queries/profile-lists' +import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll' +import {logger} from '#/logger' +import {Trans} from '@lingui/macro' +import {cleanError} from '#/lib/strings/errors' +import {useAnimatedScrollHandler} from 'react-native-reanimated' +import {useTheme} from '#/lib/ThemeContext' +import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' + +const LOADING = {_reactKey: '__loading__'} +const EMPTY = {_reactKey: '__empty__'} +const ERROR_ITEM = {_reactKey: '__error__'} +const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} + +interface SectionRef { + scrollToTop: () => void +} + +interface ProfileListsProps { + did: string + scrollElRef: MutableRefObject<FlatList<any> | null> + onScroll?: OnScrollHandler + scrollEventThrottle?: number + headerOffset: number + enabled?: boolean + style?: StyleProp<ViewStyle> + testID?: string +} + +export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>( + function ProfileListsImpl( + { + did, + scrollElRef, + onScroll, + scrollEventThrottle, + headerOffset, + enabled, + style, + testID, + }, + ref, + ) { + const pal = usePalette('default') + const theme = useTheme() + const {track} = useAnalytics() + const [isPTRing, setIsPTRing] = React.useState(false) + const opts = React.useMemo(() => ({enabled}), [enabled]) + const { + data, + isFetching, + isFetched, + hasNextPage, + fetchNextPage, + isError, + error, + refetch, + } = useProfileListsQuery(did, opts) + const isEmpty = !isFetching && !data?.pages[0]?.lists.length + + const items = React.useMemo(() => { + let items: any[] = [] + if (isError && isEmpty) { + items = items.concat([ERROR_ITEM]) + } + if (!isFetched && isFetching) { + items = items.concat([LOADING]) + } else if (isEmpty) { + items = items.concat([EMPTY]) + } else if (data?.pages) { + for (const page of data?.pages) { + items = items.concat( + page.lists.map(l => ({ + ...l, + _reactKey: l.uri, + })), + ) + } + } + if (isError && !isEmpty) { + items = items.concat([LOAD_MORE_ERROR_ITEM]) + } + return items + }, [isError, isEmpty, isFetched, isFetching, data]) + + // events + // = + + const queryClient = useQueryClient() + + const onScrollToTop = React.useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: -headerOffset}) + queryClient.invalidateQueries({queryKey: RQKEY(did)}) + }, [scrollElRef, queryClient, headerOffset, did]) + + React.useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + const onRefresh = React.useCallback(async () => { + track('Lists:onRefresh') + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh lists', {error: err}) + } + setIsPTRing(false) + }, [refetch, track, setIsPTRing]) + + const onEndReached = React.useCallback(async () => { + if (isFetching || !hasNextPage || isError) return + + track('Lists:onEndReached') + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more lists', {error: err}) + } + }, [isFetching, hasNextPage, isError, fetchNextPage, track]) + + const onPressRetryLoadMore = React.useCallback(() => { + fetchNextPage() + }, [fetchNextPage]) + + // rendering + // = + + const renderItemInner = React.useCallback( + ({item}: {item: any}) => { + if (item === EMPTY) { + return ( + <View + testID="listsEmpty" + style={[{padding: 18, borderTopWidth: 1}, pal.border]}> + <Text style={pal.textLight}> + <Trans>You have no lists.</Trans> + </Text> + </View> + ) + } else if (item === ERROR_ITEM) { + return ( + <ErrorMessage + message={cleanError(error)} + onPressTryAgain={refetch} + /> + ) + } else if (item === LOAD_MORE_ERROR_ITEM) { + return ( + <LoadMoreRetryBtn + label="There was an issue fetching your lists. Tap here to try again." + onPress={onPressRetryLoadMore} + /> + ) + } else if (item === LOADING) { + return <FeedLoadingPlaceholder /> + } + return ( + <ListCard + list={item} + testID={`list-${item.name}`} + style={styles.item} + /> + ) + }, + [error, refetch, onPressRetryLoadMore, pal], + ) + + const scrollHandler = useAnimatedScrollHandler(onScroll || {}) + return ( + <View testID={testID} style={style}> + <FlatList + testID={testID ? `${testID}-flatlist` : undefined} + ref={scrollElRef} + data={items} + keyExtractor={(item: any) => item._reactKey} + renderItem={renderItemInner} + refreshControl={ + <RefreshControl + refreshing={isPTRing} + onRefresh={onRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + progressViewOffset={headerOffset} + /> + } + contentContainerStyle={{ + minHeight: Dimensions.get('window').height * 1.5, + }} + style={{paddingTop: headerOffset}} + onScroll={onScroll != null ? scrollHandler : undefined} + scrollEventThrottle={scrollEventThrottle} + indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'} + removeClippedSubviews={true} + contentOffset={{x: 0, y: headerOffset * -1}} + // @ts-ignore our .web version only -prf + desktopFixedHeight + onEndReached={onEndReached} + /> + </View> + ) + }, +) + +const styles = StyleSheet.create({ + item: { + paddingHorizontal: 18, + }, +}) diff --git a/src/view/com/modals/AddAppPasswords.tsx b/src/view/com/modals/AddAppPasswords.tsx index 29763620f..812a36f45 100644 --- a/src/view/com/modals/AddAppPasswords.tsx +++ b/src/view/com/modals/AddAppPasswords.tsx @@ -3,7 +3,6 @@ import {StyleSheet, TextInput, View, TouchableOpacity} from 'react-native' import {Text} from '../util/text/Text' import {Button} from '../util/forms/Button' import {s} from 'lib/styles' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {isNative} from 'platform/detection' import { @@ -13,6 +12,13 @@ import { import Clipboard from '@react-native-clipboard/clipboard' import * as Toast from '../util/Toast' import {logger} from '#/logger' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import { + useAppPasswordsQuery, + useAppPasswordCreateMutation, +} from '#/state/queries/app-passwords' export const snapPoints = ['70%'] @@ -53,7 +59,10 @@ const shadesOfBlue: string[] = [ export function Component({}: {}) { const pal = usePalette('default') - const store = useStores() + const {_} = useLingui() + const {closeModal} = useModalControls() + const {data: passwords} = useAppPasswordsQuery() + const createMutation = useAppPasswordCreateMutation() const [name, setName] = useState( shadesOfBlue[Math.floor(Math.random() * shadesOfBlue.length)], ) @@ -69,33 +78,42 @@ export function Component({}: {}) { }, [appPassword]) const onDone = React.useCallback(() => { - store.shell.closeModal() - }, [store]) + closeModal() + }, [closeModal]) const createAppPassword = async () => { // if name is all whitespace, we don't allow it if (!name || !name.trim()) { Toast.show( 'Please enter a name for your app password. All spaces is not allowed.', + 'times', ) return } // if name is too short (under 4 chars), we don't allow it if (name.length < 4) { - Toast.show('App Password names must be at least 4 characters long.') + Toast.show( + 'App Password names must be at least 4 characters long.', + 'times', + ) + return + } + + if (passwords?.find(p => p.name === name)) { + Toast.show('This name is already in use', 'times') return } try { - const newPassword = await store.me.createAppPassword(name) + const newPassword = await createMutation.mutateAsync({name}) if (newPassword) { setAppPassword(newPassword.password) } else { - Toast.show('Failed to create app password.') + Toast.show('Failed to create app password.', 'times') // TODO: better error handling (?) } } catch (e) { - Toast.show('Failed to create app password.') + Toast.show('Failed to create app password.', 'times') logger.error('Failed to create app password', {error: e}) } } @@ -119,15 +137,19 @@ export function Component({}: {}) { <View> {!appPassword ? ( <Text type="lg" style={[pal.text]}> - Please enter a unique name for this App Password or use our randomly - generated one. + <Trans> + Please enter a unique name for this App Password or use our + randomly generated one. + </Trans> </Text> ) : ( <Text type="lg" style={[pal.text]}> - <Text type="lg-bold" style={[pal.text]}> - Here is your app password. - </Text>{' '} - Use this to sign into the other app along with your handle. + <Text type="lg-bold" style={[pal.text, s.mr5]}> + <Trans>Here is your app password.</Trans> + </Text> + <Trans> + Use this to sign into the other app along with your handle. + </Trans> </Text> )} {!appPassword ? ( @@ -152,7 +174,7 @@ export function Component({}: {}) { returnKeyType="done" onEndEditing={createAppPassword} accessible={true} - accessibilityLabel="Name" + accessibilityLabel={_(msg`Name`)} accessibilityHint="Input name for app password" /> </View> @@ -161,13 +183,15 @@ export function Component({}: {}) { style={[pal.border, styles.passwordContainer, pal.btn]} onPress={onCopy} accessibilityRole="button" - accessibilityLabel="Copy" + accessibilityLabel={_(msg`Copy`)} accessibilityHint="Copies app password"> <Text type="2xl-bold" style={[pal.text]}> {appPassword} </Text> {wasCopied ? ( - <Text style={[pal.textLight]}>Copied</Text> + <Text style={[pal.textLight]}> + <Trans>Copied</Trans> + </Text> ) : ( <FontAwesomeIcon icon={['far', 'clone']} @@ -180,14 +204,18 @@ export function Component({}: {}) { </View> {appPassword ? ( <Text type="lg" style={[pal.textLight, s.mb10]}> - For security reasons, you won't be able to view this again. If you - lose this password, you'll need to generate a new one. + <Trans> + For security reasons, you won't be able to view this again. If you + lose this password, you'll need to generate a new one. + </Trans> </Text> ) : ( <Text type="xs" style={[pal.textLight, s.mb10, s.mt2]}> - Can only contain letters, numbers, spaces, dashes, and underscores. - Must be at least 4 characters long, but no more than 32 characters - long. + <Trans> + Can only contain letters, numbers, spaces, dashes, and underscores. + Must be at least 4 characters long, but no more than 32 characters + long. + </Trans> </Text> )} <View style={styles.btnContainer}> diff --git a/src/view/com/modals/AltImage.tsx b/src/view/com/modals/AltImage.tsx index c084e84a3..80130f43a 100644 --- a/src/view/com/modals/AltImage.tsx +++ b/src/view/com/modals/AltImage.tsx @@ -17,9 +17,11 @@ import {MAX_ALT_TEXT} from 'lib/constants' import {useTheme} from 'lib/ThemeContext' import {Text} from '../util/text/Text' import LinearGradient from 'react-native-linear-gradient' -import {useStores} from 'state/index' import {isAndroid, isWeb} from 'platform/detection' import {ImageModel} from 'state/models/media/image' +import {useLingui} from '@lingui/react' +import {Trans, msg} from '@lingui/macro' +import {useModalControls} from '#/state/modals' export const snapPoints = ['fullscreen'] @@ -29,10 +31,11 @@ interface Props { export function Component({image}: Props) { const pal = usePalette('default') - const store = useStores() const theme = useTheme() + const {_} = useLingui() const [altText, setAltText] = useState(image.altText) const windim = useWindowDimensions() + const {closeModal} = useModalControls() const imageStyles = useMemo<ImageStyle>(() => { const maxWidth = isWeb ? 450 : windim.width @@ -53,11 +56,11 @@ export function Component({image}: Props) { const onPressSave = useCallback(() => { image.setAltText(altText) - store.shell.closeModal() - }, [store, image, altText]) + closeModal() + }, [closeModal, image, altText]) const onPressCancel = () => { - store.shell.closeModal() + closeModal() } return ( @@ -90,7 +93,7 @@ export function Component({image}: Props) { placeholderTextColor={pal.colors.textLight} value={altText} onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))} - accessibilityLabel="Image alt text" + accessibilityLabel={_(msg`Image alt text`)} accessibilityHint="" accessibilityLabelledBy="imageAltText" autoFocus @@ -99,7 +102,7 @@ export function Component({image}: Props) { <TouchableOpacity testID="altTextImageSaveBtn" onPress={onPressSave} - accessibilityLabel="Save alt text" + accessibilityLabel={_(msg`Save alt text`)} accessibilityHint={`Saves alt text, which reads: ${altText}`} accessibilityRole="button"> <LinearGradient @@ -108,7 +111,7 @@ export function Component({image}: Props) { end={{x: 1, y: 1}} style={[styles.button]}> <Text type="button-lg" style={[s.white, s.bold]}> - Save + <Trans>Save</Trans> </Text> </LinearGradient> </TouchableOpacity> @@ -116,12 +119,12 @@ export function Component({image}: Props) { testID="altTextImageCancelBtn" onPress={onPressCancel} accessibilityRole="button" - accessibilityLabel="Cancel add image alt text" + accessibilityLabel={_(msg`Cancel add image alt text`)} accessibilityHint="" onAccessibilityEscape={onPressCancel}> <View style={[styles.button]}> <Text type="button-lg" style={[pal.textLight]}> - Cancel + <Trans>Cancel</Trans> </Text> </View> </TouchableOpacity> diff --git a/src/view/com/modals/BirthDateSettings.tsx b/src/view/com/modals/BirthDateSettings.tsx index 6927ba8d2..c78f06ed4 100644 --- a/src/view/com/modals/BirthDateSettings.tsx +++ b/src/view/com/modals/BirthDateSettings.tsx @@ -5,41 +5,47 @@ import { TouchableOpacity, View, } from 'react-native' -import {observer} from 'mobx-react-lite' import {Text} from '../util/text/Text' import {DateInput} from '../util/forms/DateInput' import {ErrorMessage} from '../util/error/ErrorMessage' -import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {isWeb} from 'platform/detection' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {cleanError} from 'lib/strings/errors' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import { + usePreferencesQuery, + usePreferencesSetBirthDateMutation, + UsePreferencesQueryResponse, +} from '#/state/queries/preferences' +import {logger} from '#/logger' export const snapPoints = ['50%'] -export const Component = observer(function Component({}: {}) { +function Inner({preferences}: {preferences: UsePreferencesQueryResponse}) { const pal = usePalette('default') - const store = useStores() - const [date, setDate] = useState<Date>( - store.preferences.birthDate || new Date(), - ) - const [isProcessing, setIsProcessing] = useState<boolean>(false) - const [error, setError] = useState<string>('') const {isMobile} = useWebMediaQueries() + const {_} = useLingui() + const { + isPending, + isError, + error, + mutateAsync: setBirthDate, + } = usePreferencesSetBirthDateMutation() + const [date, setDate] = useState(preferences.birthDate || new Date()) + const {closeModal} = useModalControls() - const onSave = async () => { - setError('') - setIsProcessing(true) + const onSave = React.useCallback(async () => { try { - await store.preferences.setBirthDate(date) - store.shell.closeModal() + await setBirthDate({birthDate: date}) + closeModal() } catch (e) { - setError(cleanError(String(e))) - } finally { - setIsProcessing(false) + logger.error(`setBirthDate failed`, {error: e}) } - } + }, [date, setBirthDate, closeModal]) return ( <View @@ -47,12 +53,12 @@ export const Component = observer(function Component({}: {}) { style={[pal.view, styles.container, isMobile && {paddingHorizontal: 18}]}> <View style={styles.titleSection}> <Text type="title-lg" style={[pal.text, styles.title]}> - My Birthday + <Trans>My Birthday</Trans> </Text> </View> <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}> - This information is not shared with other users. + <Trans>This information is not shared with other users.</Trans> </Text> <View> @@ -63,18 +69,18 @@ export const Component = observer(function Component({}: {}) { buttonType="default-light" buttonStyle={[pal.border, styles.dateInputButton]} buttonLabelType="lg" - accessibilityLabel="Birthday" + accessibilityLabel={_(msg`Birthday`)} accessibilityHint="Enter your birth date" accessibilityLabelledBy="birthDate" /> </View> - {error ? ( - <ErrorMessage message={error} style={styles.error} /> + {isError ? ( + <ErrorMessage message={cleanError(error)} style={styles.error} /> ) : undefined} <View style={[styles.btnContainer, pal.borderDark]}> - {isProcessing ? ( + {isPending ? ( <View style={styles.btn}> <ActivityIndicator color="#fff" /> </View> @@ -84,15 +90,27 @@ export const Component = observer(function Component({}: {}) { onPress={onSave} style={styles.btn} accessibilityRole="button" - accessibilityLabel="Save" + accessibilityLabel={_(msg`Save`)} accessibilityHint=""> - <Text style={[s.white, s.bold, s.f18]}>Save</Text> + <Text style={[s.white, s.bold, s.f18]}> + <Trans>Save</Trans> + </Text> </TouchableOpacity> )} </View> </View> ) -}) +} + +export function Component({}: {}) { + const {data: preferences} = usePreferencesQuery() + + return !preferences ? ( + <ActivityIndicator /> + ) : ( + <Inner preferences={preferences} /> + ) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/com/modals/ChangeEmail.tsx b/src/view/com/modals/ChangeEmail.tsx index 012570556..73ab33dd4 100644 --- a/src/view/com/modals/ChangeEmail.tsx +++ b/src/view/com/modals/ChangeEmail.tsx @@ -1,17 +1,19 @@ import React, {useState} from 'react' import {ActivityIndicator, SafeAreaView, StyleSheet, View} from 'react-native' import {ScrollView, TextInput} from './util' -import {observer} from 'mobx-react-lite' import {Text} from '../util/text/Text' import {Button} from '../util/forms/Button' import {ErrorMessage} from '../util/error/ErrorMessage' import * as Toast from '../util/Toast' -import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {isWeb} from 'platform/detection' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {cleanError} from 'lib/strings/errors' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import {useSession, useSessionApi, getAgent} from '#/state/session' enum Stages { InputEmail, @@ -21,32 +23,33 @@ enum Stages { export const snapPoints = ['90%'] -export const Component = observer(function Component({}: {}) { +export function Component() { const pal = usePalette('default') - const store = useStores() + const {currentAccount} = useSession() + const {updateCurrentAccount} = useSessionApi() + const {_} = useLingui() const [stage, setStage] = useState<Stages>(Stages.InputEmail) - const [email, setEmail] = useState<string>( - store.session.currentSession?.email || '', - ) + const [email, setEmail] = useState<string>(currentAccount?.email || '') const [confirmationCode, setConfirmationCode] = useState<string>('') const [isProcessing, setIsProcessing] = useState<boolean>(false) const [error, setError] = useState<string>('') const {isMobile} = useWebMediaQueries() + const {openModal, closeModal} = useModalControls() const onRequestChange = async () => { - if (email === store.session.currentSession?.email) { + if (email === currentAccount?.email) { setError('Enter your new email above') return } setError('') setIsProcessing(true) try { - const res = await store.agent.com.atproto.server.requestEmailUpdate() + const res = await getAgent().com.atproto.server.requestEmailUpdate() if (res.data.tokenRequired) { setStage(Stages.ConfirmCode) } else { - await store.agent.com.atproto.server.updateEmail({email: email.trim()}) - store.session.updateLocalAccountData({ + await getAgent().com.atproto.server.updateEmail({email: email.trim()}) + updateCurrentAccount({ email: email.trim(), emailConfirmed: false, }) @@ -60,7 +63,9 @@ export const Component = observer(function Component({}: {}) { // you can remove this any time after Oct2023 // -prf if (err === 'email must be confirmed (temporary)') { - err = `Please confirm your email before changing it. This is a temporary requirement while email-updating tools are added, and it will soon be removed.` + err = _( + msg`Please confirm your email before changing it. This is a temporary requirement while email-updating tools are added, and it will soon be removed.`, + ) } setError(err) } finally { @@ -72,11 +77,11 @@ export const Component = observer(function Component({}: {}) { setError('') setIsProcessing(true) try { - await store.agent.com.atproto.server.updateEmail({ + await getAgent().com.atproto.server.updateEmail({ email: email.trim(), token: confirmationCode.trim(), }) - store.session.updateLocalAccountData({ + updateCurrentAccount({ email: email.trim(), emailConfirmed: false, }) @@ -90,8 +95,8 @@ export const Component = observer(function Component({}: {}) { } const onVerify = async () => { - store.shell.closeModal() - store.shell.openModal({name: 'verify-email'}) + closeModal() + openModal({name: 'verify-email'}) } return ( @@ -101,26 +106,26 @@ export const Component = observer(function Component({}: {}) { style={[s.flex1, isMobile && {paddingHorizontal: 18}]}> <View style={styles.titleSection}> <Text type="title-lg" style={[pal.text, styles.title]}> - {stage === Stages.InputEmail ? 'Change Your Email' : ''} - {stage === Stages.ConfirmCode ? 'Security Step Required' : ''} - {stage === Stages.Done ? 'Email Updated' : ''} + {stage === Stages.InputEmail ? _(msg`Change Your Email`) : ''} + {stage === Stages.ConfirmCode ? _(msg`Security Step Required`) : ''} + {stage === Stages.Done ? _(msg`Email Updated`) : ''} </Text> </View> <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}> {stage === Stages.InputEmail ? ( - <>Enter your new email address below.</> + <Trans>Enter your new email address below.</Trans> ) : stage === Stages.ConfirmCode ? ( - <> + <Trans> An email has been sent to your previous address,{' '} - {store.session.currentSession?.email || ''}. It includes a - confirmation code which you can enter below. - </> + {currentAccount?.email || ''}. It includes a confirmation code + which you can enter below. + </Trans> ) : ( - <> + <Trans> Your email has been updated but not verified. As a next step, please verify your new email. - </> + </Trans> )} </Text> @@ -133,7 +138,7 @@ export const Component = observer(function Component({}: {}) { value={email} onChangeText={setEmail} accessible={true} - accessibilityLabel="Email" + accessibilityLabel={_(msg`Email`)} accessibilityHint="" autoCapitalize="none" autoComplete="email" @@ -149,7 +154,7 @@ export const Component = observer(function Component({}: {}) { value={confirmationCode} onChangeText={setConfirmationCode} accessible={true} - accessibilityLabel="Confirmation code" + accessibilityLabel={_(msg`Confirmation code`)} accessibilityHint="" autoCapitalize="none" autoComplete="off" @@ -173,9 +178,9 @@ export const Component = observer(function Component({}: {}) { testID="requestChangeBtn" type="primary" onPress={onRequestChange} - accessibilityLabel="Request Change" + accessibilityLabel={_(msg`Request Change`)} accessibilityHint="" - label="Request Change" + label={_(msg`Request Change`)} labelContainerStyle={{justifyContent: 'center', padding: 4}} labelStyle={[s.f18]} /> @@ -185,9 +190,9 @@ export const Component = observer(function Component({}: {}) { testID="confirmBtn" type="primary" onPress={onConfirm} - accessibilityLabel="Confirm Change" + accessibilityLabel={_(msg`Confirm Change`)} accessibilityHint="" - label="Confirm Change" + label={_(msg`Confirm Change`)} labelContainerStyle={{justifyContent: 'center', padding: 4}} labelStyle={[s.f18]} /> @@ -197,9 +202,9 @@ export const Component = observer(function Component({}: {}) { testID="verifyBtn" type="primary" onPress={onVerify} - accessibilityLabel="Verify New Email" + accessibilityLabel={_(msg`Verify New Email`)} accessibilityHint="" - label="Verify New Email" + label={_(msg`Verify New Email`)} labelContainerStyle={{justifyContent: 'center', padding: 4}} labelStyle={[s.f18]} /> @@ -207,10 +212,12 @@ export const Component = observer(function Component({}: {}) { <Button testID="cancelBtn" type="default" - onPress={() => store.shell.closeModal()} - accessibilityLabel="Cancel" + onPress={() => { + closeModal() + }} + accessibilityLabel={_(msg`Cancel`)} accessibilityHint="" - label="Cancel" + label={_(msg`Cancel`)} labelContainerStyle={{justifyContent: 'center', padding: 4}} labelStyle={[s.f18]} /> @@ -220,7 +227,7 @@ export const Component = observer(function Component({}: {}) { </ScrollView> </SafeAreaView> ) -}) +} const styles = StyleSheet.create({ titleSection: { diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx index c54c1c043..03516d35a 100644 --- a/src/view/com/modals/ChangeHandle.tsx +++ b/src/view/com/modals/ChangeHandle.tsx @@ -1,5 +1,6 @@ import React, {useState} from 'react' import Clipboard from '@react-native-clipboard/clipboard' +import {ComAtprotoServerDescribeServer} from '@atproto/api' import * as Toast from '../util/Toast' import { ActivityIndicator, @@ -13,8 +14,6 @@ import {Text} from '../util/text/Text' import {Button} from '../util/forms/Button' import {SelectableBtn} from '../util/forms/SelectableBtn' import {ErrorMessage} from '../util/error/ErrorMessage' -import {useStores} from 'state/index' -import {ServiceDescription} from 'state/models/session' import {s} from 'lib/styles' import {createFullHandle, makeValidHandle} from 'lib/strings/handles' import {usePalette} from 'lib/hooks/usePalette' @@ -22,75 +21,74 @@ import {useTheme} from 'lib/ThemeContext' import {useAnalytics} from 'lib/analytics/analytics' import {cleanError} from 'lib/strings/errors' import {logger} from '#/logger' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import {useServiceQuery} from '#/state/queries/service' +import {useUpdateHandleMutation, useFetchDid} from '#/state/queries/handle' +import { + useSession, + useSessionApi, + SessionAccount, + getAgent, +} from '#/state/session' export const snapPoints = ['100%'] -export function Component({onChanged}: {onChanged: () => void}) { - const store = useStores() - const [error, setError] = useState<string>('') +export type Props = {onChanged: () => void} + +export function Component(props: Props) { + const {currentAccount} = useSession() + const { + isLoading, + data: serviceInfo, + error: serviceInfoError, + } = useServiceQuery(getAgent().service.toString()) + + return isLoading || !currentAccount ? ( + <View style={{padding: 18}}> + <ActivityIndicator /> + </View> + ) : serviceInfoError || !serviceInfo ? ( + <ErrorMessage message={cleanError(serviceInfoError)} /> + ) : ( + <Inner + {...props} + currentAccount={currentAccount} + serviceInfo={serviceInfo} + /> + ) +} + +export function Inner({ + currentAccount, + serviceInfo, + onChanged, +}: Props & { + currentAccount: SessionAccount + serviceInfo: ComAtprotoServerDescribeServer.OutputSchema +}) { + const {_} = useLingui() const pal = usePalette('default') const {track} = useAnalytics() + const {updateCurrentAccount} = useSessionApi() + const {closeModal} = useModalControls() + const {mutateAsync: updateHandle, isPending: isUpdateHandlePending} = + useUpdateHandleMutation() + + const [error, setError] = useState<string>('') - const [isProcessing, setProcessing] = useState<boolean>(false) - const [retryDescribeTrigger, setRetryDescribeTrigger] = React.useState<any>( - {}, - ) - const [serviceDescription, setServiceDescription] = React.useState< - ServiceDescription | undefined - >(undefined) - const [userDomain, setUserDomain] = React.useState<string>('') const [isCustom, setCustom] = React.useState<boolean>(false) const [handle, setHandle] = React.useState<string>('') const [canSave, setCanSave] = React.useState<boolean>(false) - // init - // = - React.useEffect(() => { - let aborted = false - setError('') - setServiceDescription(undefined) - setProcessing(true) - - // load the service description so we can properly provision handles - store.session.describeService(String(store.agent.service)).then( - desc => { - if (aborted) { - return - } - setServiceDescription(desc) - setUserDomain(desc.availableUserDomains[0]) - setProcessing(false) - }, - err => { - if (aborted) { - return - } - setProcessing(false) - logger.warn( - `Failed to fetch service description for ${String( - store.agent.service, - )}`, - {error: err}, - ) - setError( - 'Unable to contact your service. Please check your Internet connection.', - ) - }, - ) - return () => { - aborted = true - } - }, [store.agent.service, store.session, retryDescribeTrigger]) + const userDomain = serviceInfo.availableUserDomains?.[0] // events // = const onPressCancel = React.useCallback(() => { - store.shell.closeModal() - }, [store]) - const onPressRetryConnect = React.useCallback( - () => setRetryDescribeTrigger({}), - [setRetryDescribeTrigger], - ) + closeModal() + }, [closeModal]) const onToggleCustom = React.useCallback(() => { // toggle between a provided domain vs a custom one setHandle('') @@ -101,32 +99,42 @@ export function Component({onChanged}: {onChanged: () => void}) { ) }, [setCustom, isCustom, track]) const onPressSave = React.useCallback(async () => { - setError('') - setProcessing(true) + if (!userDomain) { + logger.error(`ChangeHandle: userDomain is undefined`, { + service: serviceInfo, + }) + setError(`The service you've selected has no domains configured.`) + return + } + try { track('EditHandle:SetNewHandle') const newHandle = isCustom ? handle : createFullHandle(handle, userDomain) logger.debug(`Updating handle to ${newHandle}`) - await store.agent.updateHandle({ + await updateHandle({ + handle: newHandle, + }) + updateCurrentAccount({ handle: newHandle, }) - store.shell.closeModal() + closeModal() onChanged() } catch (err: any) { setError(cleanError(err)) logger.error('Failed to update handle', {handle, error: err}) } finally { - setProcessing(false) } }, [ setError, - setProcessing, handle, userDomain, - store, isCustom, onChanged, track, + closeModal, + updateCurrentAccount, + updateHandle, + serviceInfo, ]) // rendering @@ -138,7 +146,7 @@ export function Component({onChanged}: {onChanged: () => void}) { <TouchableOpacity onPress={onPressCancel} accessibilityRole="button" - accessibilityLabel="Cancel change handle" + accessibilityLabel={_(msg`Cancel change handle`)} accessibilityHint="Exits handle change process" onAccessibilityEscape={onPressCancel}> <Text type="lg" style={pal.textLight}> @@ -150,30 +158,19 @@ export function Component({onChanged}: {onChanged: () => void}) { type="2xl-bold" style={[styles.titleMiddle, pal.text]} numberOfLines={1}> - Change Handle + <Trans>Change Handle</Trans> </Text> <View style={styles.titleRight}> - {isProcessing ? ( + {isUpdateHandlePending ? ( <ActivityIndicator /> - ) : error && !serviceDescription ? ( - <TouchableOpacity - testID="retryConnectButton" - onPress={onPressRetryConnect} - accessibilityRole="button" - accessibilityLabel="Retry change handle" - accessibilityHint={`Retries handle change to ${handle}`}> - <Text type="xl-bold" style={[pal.link, s.pr5]}> - Retry - </Text> - </TouchableOpacity> ) : canSave ? ( <TouchableOpacity onPress={onPressSave} accessibilityRole="button" - accessibilityLabel="Save handle change" + accessibilityLabel={_(msg`Save handle change`)} accessibilityHint={`Saves handle change to ${handle}`}> <Text type="2xl-medium" style={pal.link}> - Save + <Trans>Save</Trans> </Text> </TouchableOpacity> ) : undefined} @@ -188,8 +185,9 @@ export function Component({onChanged}: {onChanged: () => void}) { {isCustom ? ( <CustomHandleForm + currentAccount={currentAccount} handle={handle} - isProcessing={isProcessing} + isProcessing={isUpdateHandlePending} canSave={canSave} onToggleCustom={onToggleCustom} setHandle={setHandle} @@ -200,7 +198,7 @@ export function Component({onChanged}: {onChanged: () => void}) { <ProvidedHandleForm handle={handle} userDomain={userDomain} - isProcessing={isProcessing} + isProcessing={isUpdateHandlePending} onToggleCustom={onToggleCustom} setHandle={setHandle} setCanSave={setCanSave} @@ -231,6 +229,7 @@ function ProvidedHandleForm({ }) { const pal = usePalette('default') const theme = useTheme() + const {_} = useLingui() // events // = @@ -263,12 +262,12 @@ function ProvidedHandleForm({ onChangeText={onChangeHandle} editable={!isProcessing} accessible={true} - accessibilityLabel="Handle" + accessibilityLabel={_(msg`Handle`)} accessibilityHint="Sets Bluesky username" /> </View> <Text type="md" style={[pal.textLight, s.pl10, s.pt10]}> - Your full handle will be{' '} + <Trans>Your full handle will be </Trans> <Text type="md-bold" style={pal.textLight}> @{createFullHandle(handle, userDomain)} </Text> @@ -277,9 +276,9 @@ function ProvidedHandleForm({ onPress={onToggleCustom} accessibilityRole="button" accessibilityHint="Hosting provider" - accessibilityLabel="Opens modal for using custom domain"> + accessibilityLabel={_(msg`Opens modal for using custom domain`)}> <Text type="md-medium" style={[pal.link, s.pl10, s.pt5]}> - I have my own domain + <Trans>I have my own domain</Trans> </Text> </TouchableOpacity> </> @@ -290,6 +289,7 @@ function ProvidedHandleForm({ * The form for using a custom domain */ function CustomHandleForm({ + currentAccount, handle, canSave, isProcessing, @@ -298,6 +298,7 @@ function CustomHandleForm({ onPressSave, setCanSave, }: { + currentAccount: SessionAccount handle: string canSave: boolean isProcessing: boolean @@ -306,20 +307,23 @@ function CustomHandleForm({ onPressSave: () => void setCanSave: (v: boolean) => void }) { - const store = useStores() const pal = usePalette('default') const palSecondary = usePalette('secondary') const palError = usePalette('error') const theme = useTheme() + const {_} = useLingui() const [isVerifying, setIsVerifying] = React.useState(false) const [error, setError] = React.useState<string>('') const [isDNSForm, setDNSForm] = React.useState<boolean>(true) + const fetchDid = useFetchDid() // events // = const onPressCopy = React.useCallback(() => { - Clipboard.setString(isDNSForm ? `did=${store.me.did}` : store.me.did) + Clipboard.setString( + isDNSForm ? `did=${currentAccount.did}` : currentAccount.did, + ) Toast.show('Copied to clipboard') - }, [store.me.did, isDNSForm]) + }, [currentAccount, isDNSForm]) const onChangeHandle = React.useCallback( (v: string) => { setHandle(v) @@ -334,13 +338,11 @@ function CustomHandleForm({ try { setIsVerifying(true) setError('') - const res = await store.agent.com.atproto.identity.resolveHandle({ - handle, - }) - if (res.data.did === store.me.did) { + const did = await fetchDid(handle) + if (did === currentAccount.did) { setCanSave(true) } else { - setError(`Incorrect DID returned (got ${res.data.did})`) + setError(`Incorrect DID returned (got ${did})`) } } catch (err: any) { setError(cleanError(err)) @@ -350,13 +352,13 @@ function CustomHandleForm({ } }, [ handle, - store.me.did, + currentAccount, setIsVerifying, setCanSave, setError, canSave, onPressSave, - store.agent, + fetchDid, ]) // rendering @@ -364,7 +366,7 @@ function CustomHandleForm({ return ( <> <Text type="md" style={[pal.text, s.pb5, s.pl5]} nativeID="customDomain"> - Enter the domain you want to use + <Trans>Enter the domain you want to use</Trans> </Text> <View style={[pal.btn, styles.textInputWrapper]}> <FontAwesomeIcon @@ -382,7 +384,7 @@ function CustomHandleForm({ onChangeText={onChangeHandle} editable={!isProcessing} accessibilityLabelledBy="customDomain" - accessibilityLabel="Custom domain" + accessibilityLabel={_(msg`Custom domain`)} accessibilityHint="Input your preferred hosting provider" /> </View> @@ -410,7 +412,7 @@ function CustomHandleForm({ {isDNSForm ? ( <> <Text type="md" style={[pal.text, s.pb5, s.pl5]}> - Add the following DNS record to your domain: + <Trans>Add the following DNS record to your domain:</Trans> </Text> <View style={[styles.dnsTable, pal.btn]}> <Text type="md-medium" style={[styles.dnsLabel, pal.text]}> @@ -434,7 +436,7 @@ function CustomHandleForm({ </Text> <View style={[styles.dnsValue]}> <Text type="mono" style={[styles.monoText, pal.text]}> - did={store.me.did} + did={currentAccount.did} </Text> </View> </View> @@ -448,7 +450,7 @@ function CustomHandleForm({ ) : ( <> <Text type="md" style={[pal.text, s.pb5, s.pl5]}> - Upload a text file to: + <Trans>Upload a text file to:</Trans> </Text> <View style={[styles.valueContainer, pal.btn]}> <View style={[styles.dnsValue]}> @@ -464,7 +466,7 @@ function CustomHandleForm({ <View style={[styles.valueContainer, pal.btn]}> <View style={[styles.dnsValue]}> <Text type="mono" style={[styles.monoText, pal.text]}> - {store.me.did} + {currentAccount.did} </Text> </View> </View> @@ -480,7 +482,7 @@ function CustomHandleForm({ {canSave === true && ( <View style={[styles.message, palSecondary.view]}> <Text type="md-medium" style={palSecondary.text}> - Domain verified! + <Trans>Domain verified!</Trans> </Text> </View> )} @@ -508,7 +510,7 @@ function CustomHandleForm({ <View style={styles.spacer} /> <TouchableOpacity onPress={onToggleCustom} - accessibilityLabel="Use default provider" + accessibilityLabel={_(msg`Use default provider`)} accessibilityHint="Use bsky.social as hosting provider"> <Text type="md-medium" style={[pal.link, s.pl10, s.pt5]}> Nevermind, create a handle for me diff --git a/src/view/com/modals/Confirm.tsx b/src/view/com/modals/Confirm.tsx index c1324b1cb..5e869f396 100644 --- a/src/view/com/modals/Confirm.tsx +++ b/src/view/com/modals/Confirm.tsx @@ -6,13 +6,15 @@ import { View, } from 'react-native' import {Text} from '../util/text/Text' -import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {ErrorMessage} from '../util/error/ErrorMessage' import {cleanError} from 'lib/strings/errors' import {usePalette} from 'lib/hooks/usePalette' import {isWeb} from 'platform/detection' -import type {ConfirmModal} from 'state/models/ui/shell' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import type {ConfirmModal} from '#/state/modals' +import {useModalControls} from '#/state/modals' export const snapPoints = ['50%'] @@ -26,7 +28,8 @@ export function Component({ cancelBtnText, }: ConfirmModal) { const pal = usePalette('default') - const store = useStores() + const {_} = useLingui() + const {closeModal} = useModalControls() const [isProcessing, setIsProcessing] = useState<boolean>(false) const [error, setError] = useState<string>('') const onPress = async () => { @@ -34,7 +37,7 @@ export function Component({ setIsProcessing(true) try { await onPressConfirm() - store.shell.closeModal() + closeModal() return } catch (e: any) { setError(cleanError(e)) @@ -69,7 +72,7 @@ export function Component({ onPress={onPress} style={[styles.btn, confirmBtnStyle]} accessibilityRole="button" - accessibilityLabel="Confirm" + accessibilityLabel={_(msg`Confirm`)} accessibilityHint=""> <Text style={[s.white, s.bold, s.f18]}> {confirmBtnText ?? 'Confirm'} @@ -82,7 +85,7 @@ export function Component({ onPress={onPressCancel} style={[styles.btnCancel, s.mt10]} accessibilityRole="button" - accessibilityLabel="Cancel" + accessibilityLabel={_(msg`Cancel`)} accessibilityHint=""> <Text type="button-lg" style={pal.textLight}> {cancelBtnText ?? 'Cancel'} diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx index 9075d0272..8b42e1b1d 100644 --- a/src/view/com/modals/ContentFilteringSettings.tsx +++ b/src/view/com/modals/ContentFilteringSettings.tsx @@ -1,214 +1,228 @@ import React from 'react' +import {LabelPreference} from '@atproto/api' import {StyleSheet, Pressable, View} from 'react-native' import LinearGradient from 'react-native-linear-gradient' -import {observer} from 'mobx-react-lite' import {ScrollView} from './util' -import {useStores} from 'state/index' -import {LabelPreference} from 'state/models/ui/preferences' import {s, colors, gradients} from 'lib/styles' import {Text} from '../util/text/Text' import {TextLink} from '../util/Link' import {ToggleButton} from '../util/forms/ToggleButton' import {Button} from '../util/forms/Button' import {usePalette} from 'lib/hooks/usePalette' -import {CONFIGURABLE_LABEL_GROUPS} from 'lib/labeling/const' import {isIOS} from 'platform/detection' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import * as Toast from '../util/Toast' import {logger} from '#/logger' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import { + usePreferencesQuery, + usePreferencesSetContentLabelMutation, + usePreferencesSetAdultContentMutation, + ConfigurableLabelGroup, + CONFIGURABLE_LABEL_GROUPS, + UsePreferencesQueryResponse, +} from '#/state/queries/preferences' export const snapPoints = ['90%'] -export const Component = observer( - function ContentFilteringSettingsImpl({}: {}) { - const store = useStores() - const {isMobile} = useWebMediaQueries() - const pal = usePalette('default') +export function Component({}: {}) { + const {isMobile} = useWebMediaQueries() + const pal = usePalette('default') + const {_} = useLingui() + const {closeModal} = useModalControls() + const {data: preferences} = usePreferencesQuery() - React.useEffect(() => { - store.preferences.sync() - }, [store]) + const onPressDone = React.useCallback(() => { + closeModal() + }, [closeModal]) - const onPressDone = React.useCallback(() => { - store.shell.closeModal() - }, [store]) + return ( + <View testID="contentFilteringModal" style={[pal.view, styles.container]}> + <Text style={[pal.text, styles.title]}> + <Trans>Content Filtering</Trans> + </Text> - return ( - <View testID="contentFilteringModal" style={[pal.view, styles.container]}> - <Text style={[pal.text, styles.title]}>Content Filtering</Text> - <ScrollView style={styles.scrollContainer}> - <AdultContentEnabledPref /> - <ContentLabelPref - group="nsfw" - disabled={!store.preferences.adultContentEnabled} - /> - <ContentLabelPref - group="nudity" - disabled={!store.preferences.adultContentEnabled} - /> - <ContentLabelPref - group="suggestive" - disabled={!store.preferences.adultContentEnabled} - /> - <ContentLabelPref - group="gore" - disabled={!store.preferences.adultContentEnabled} - /> - <ContentLabelPref group="hate" /> - <ContentLabelPref group="spam" /> - <ContentLabelPref group="impersonation" /> - <View style={{height: isMobile ? 60 : 0}} /> - </ScrollView> - <View - style={[ - styles.btnContainer, - isMobile && styles.btnContainerMobile, - pal.borderDark, - ]}> - <Pressable - testID="sendReportBtn" - onPress={onPressDone} - accessibilityRole="button" - accessibilityLabel="Done" - accessibilityHint=""> - <LinearGradient - colors={[gradients.blueLight.start, gradients.blueLight.end]} - start={{x: 0, y: 0}} - end={{x: 1, y: 1}} - style={[styles.btn]}> - <Text style={[s.white, s.bold, s.f18]}>Done</Text> - </LinearGradient> - </Pressable> - </View> + <ScrollView style={styles.scrollContainer}> + <AdultContentEnabledPref /> + <ContentLabelPref + preferences={preferences} + labelGroup="nsfw" + disabled={!preferences?.adultContentEnabled} + /> + <ContentLabelPref + preferences={preferences} + labelGroup="nudity" + disabled={!preferences?.adultContentEnabled} + /> + <ContentLabelPref + preferences={preferences} + labelGroup="suggestive" + disabled={!preferences?.adultContentEnabled} + /> + <ContentLabelPref + preferences={preferences} + labelGroup="gore" + disabled={!preferences?.adultContentEnabled} + /> + <ContentLabelPref preferences={preferences} labelGroup="hate" /> + <ContentLabelPref preferences={preferences} labelGroup="spam" /> + <ContentLabelPref + preferences={preferences} + labelGroup="impersonation" + /> + <View style={{height: isMobile ? 60 : 0}} /> + </ScrollView> + + <View + style={[ + styles.btnContainer, + isMobile && styles.btnContainerMobile, + pal.borderDark, + ]}> + <Pressable + testID="sendReportBtn" + onPress={onPressDone} + accessibilityRole="button" + accessibilityLabel={_(msg`Done`)} + accessibilityHint=""> + <LinearGradient + colors={[gradients.blueLight.start, gradients.blueLight.end]} + start={{x: 0, y: 0}} + end={{x: 1, y: 1}} + style={[styles.btn]}> + <Text style={[s.white, s.bold, s.f18]}> + <Trans>Done</Trans> + </Text> + </LinearGradient> + </Pressable> </View> - ) - }, -) + </View> + ) +} -const AdultContentEnabledPref = observer( - function AdultContentEnabledPrefImpl() { - const store = useStores() - const pal = usePalette('default') +function AdultContentEnabledPref() { + const pal = usePalette('default') + const {data: preferences} = usePreferencesQuery() + const {mutate, variables} = usePreferencesSetAdultContentMutation() + const {openModal} = useModalControls() - const onSetAge = () => store.shell.openModal({name: 'birth-date-settings'}) + const onSetAge = React.useCallback( + () => openModal({name: 'birth-date-settings'}), + [openModal], + ) - const onToggleAdultContent = async () => { - if (isIOS) { - return - } - try { - await store.preferences.setAdultContentEnabled( - !store.preferences.adultContentEnabled, - ) - } catch (e) { - Toast.show( - 'There was an issue syncing your preferences with the server', - ) - logger.error('Failed to update preferences with server', {error: e}) - } + const onToggleAdultContent = React.useCallback(async () => { + if (isIOS) return + + try { + mutate({ + enabled: !(variables?.enabled ?? preferences?.adultContentEnabled), + }) + } catch (e) { + Toast.show('There was an issue syncing your preferences with the server') + logger.error('Failed to update preferences with server', {error: e}) } + }, [variables, preferences, mutate]) - return ( - <View style={s.mb10}> - {isIOS ? ( - store.preferences.adultContentEnabled ? null : ( - <Text type="md" style={pal.textLight}> - Adult content can only be enabled via the Web at{' '} - <TextLink - style={pal.link} - href="https://bsky.app" - text="bsky.app" - /> - . - </Text> - ) - ) : typeof store.preferences.birthDate === 'undefined' ? ( - <View style={[pal.viewLight, styles.agePrompt]}> - <Text type="md" style={[pal.text, {flex: 1}]}> - Confirm your age to enable adult content. - </Text> - <Button type="primary" label="Set Age" onPress={onSetAge} /> - </View> - ) : (store.preferences.userAge || 0) >= 18 ? ( - <ToggleButton - type="default-light" - label="Enable Adult Content" - isSelected={store.preferences.adultContentEnabled} - onPress={onToggleAdultContent} - style={styles.toggleBtn} - /> - ) : ( - <View style={[pal.viewLight, styles.agePrompt]}> - <Text type="md" style={[pal.text, {flex: 1}]}> - You must be 18 or older to enable adult content. - </Text> - <Button type="primary" label="Set Age" onPress={onSetAge} /> - </View> - )} - </View> - ) - }, -) + return ( + <View style={s.mb10}> + {isIOS ? ( + preferences?.adultContentEnabled ? null : ( + <Text type="md" style={pal.textLight}> + Adult content can only be enabled via the Web at{' '} + <TextLink + style={pal.link} + href="https://bsky.app" + text="bsky.app" + /> + . + </Text> + ) + ) : typeof preferences?.birthDate === 'undefined' ? ( + <View style={[pal.viewLight, styles.agePrompt]}> + <Text type="md" style={[pal.text, {flex: 1}]}> + Confirm your age to enable adult content. + </Text> + <Button type="primary" label="Set Age" onPress={onSetAge} /> + </View> + ) : (preferences.userAge || 0) >= 18 ? ( + <ToggleButton + type="default-light" + label="Enable Adult Content" + isSelected={variables?.enabled ?? preferences?.adultContentEnabled} + onPress={onToggleAdultContent} + style={styles.toggleBtn} + /> + ) : ( + <View style={[pal.viewLight, styles.agePrompt]}> + <Text type="md" style={[pal.text, {flex: 1}]}> + You must be 18 or older to enable adult content. + </Text> + <Button type="primary" label="Set Age" onPress={onSetAge} /> + </View> + )} + </View> + ) +} // TODO: Refactor this component to pass labels down to each tab -const ContentLabelPref = observer(function ContentLabelPrefImpl({ - group, +function ContentLabelPref({ + preferences, + labelGroup, disabled, }: { - group: keyof typeof CONFIGURABLE_LABEL_GROUPS + preferences?: UsePreferencesQueryResponse + labelGroup: ConfigurableLabelGroup disabled?: boolean }) { - const store = useStores() const pal = usePalette('default') + const visibility = preferences?.contentLabels?.[labelGroup] + const {mutate, variables} = usePreferencesSetContentLabelMutation() const onChange = React.useCallback( - async (v: LabelPreference) => { - try { - await store.preferences.setContentLabelPref(group, v) - } catch (e) { - Toast.show( - 'There was an issue syncing your preferences with the server', - ) - logger.error('Failed to update preferences with server', {error: e}) - } + (vis: LabelPreference) => { + mutate({labelGroup, visibility: vis}) }, - [store, group], + [mutate, labelGroup], ) return ( <View style={[styles.contentLabelPref, pal.border]}> <View style={s.flex1}> <Text type="md-medium" style={[pal.text]}> - {CONFIGURABLE_LABEL_GROUPS[group].title} + {CONFIGURABLE_LABEL_GROUPS[labelGroup].title} </Text> - {typeof CONFIGURABLE_LABEL_GROUPS[group].subtitle === 'string' && ( + {typeof CONFIGURABLE_LABEL_GROUPS[labelGroup].subtitle === 'string' && ( <Text type="sm" style={[pal.textLight]}> - {CONFIGURABLE_LABEL_GROUPS[group].subtitle} + {CONFIGURABLE_LABEL_GROUPS[labelGroup].subtitle} </Text> )} </View> - {disabled ? ( + + {disabled || !visibility ? ( <Text type="sm-bold" style={pal.textLight}> Hide </Text> ) : ( <SelectGroup - current={store.preferences.contentLabels[group]} + current={variables?.visibility || visibility} onChange={onChange} - group={group} + labelGroup={labelGroup} /> )} </View> ) -}) +} interface SelectGroupProps { current: LabelPreference onChange: (v: LabelPreference) => void - group: keyof typeof CONFIGURABLE_LABEL_GROUPS + labelGroup: ConfigurableLabelGroup } -function SelectGroup({current, onChange, group}: SelectGroupProps) { +function SelectGroup({current, onChange, labelGroup}: SelectGroupProps) { return ( <View style={styles.selectableBtns}> <SelectableBtn @@ -217,14 +231,14 @@ function SelectGroup({current, onChange, group}: SelectGroupProps) { label="Hide" left onChange={onChange} - group={group} + labelGroup={labelGroup} /> <SelectableBtn current={current} value="warn" label="Warn" onChange={onChange} - group={group} + labelGroup={labelGroup} /> <SelectableBtn current={current} @@ -232,7 +246,7 @@ function SelectGroup({current, onChange, group}: SelectGroupProps) { label="Show" right onChange={onChange} - group={group} + labelGroup={labelGroup} /> </View> ) @@ -245,7 +259,7 @@ interface SelectableBtnProps { left?: boolean right?: boolean onChange: (v: LabelPreference) => void - group: keyof typeof CONFIGURABLE_LABEL_GROUPS + labelGroup: ConfigurableLabelGroup } function SelectableBtn({ @@ -255,7 +269,7 @@ function SelectableBtn({ left, right, onChange, - group, + labelGroup, }: SelectableBtnProps) { const pal = usePalette('default') const palPrimary = usePalette('inverted') @@ -271,7 +285,7 @@ function SelectableBtn({ onPress={() => onChange(value)} accessibilityRole="button" accessibilityLabel={value} - accessibilityHint={`Set ${value} for ${group} content moderation policy`}> + accessibilityHint={`Set ${value} for ${labelGroup} content moderation policy`}> <Text style={current === value ? palPrimary.text : pal.text}> {label} </Text> diff --git a/src/view/com/modals/CreateOrEditList.tsx b/src/view/com/modals/CreateOrEditList.tsx index 1ea12695f..8d13cdf2f 100644 --- a/src/view/com/modals/CreateOrEditList.tsx +++ b/src/view/com/modals/CreateOrEditList.tsx @@ -1,5 +1,4 @@ import React, {useState, useCallback, useMemo} from 'react' -import * as Toast from '../util/Toast' import { ActivityIndicator, KeyboardAvoidingView, @@ -9,12 +8,12 @@ import { TouchableOpacity, View, } from 'react-native' +import {AppBskyGraphDefs} from '@atproto/api' import LinearGradient from 'react-native-linear-gradient' import {Image as RNImage} from 'react-native-image-crop-picker' import {Text} from '../util/text/Text' import {ErrorMessage} from '../util/error/ErrorMessage' -import {useStores} from 'state/index' -import {ListModel} from 'state/models/content/list' +import * as Toast from '../util/Toast' import {s, colors, gradients} from 'lib/styles' import {enforceLen} from 'lib/strings/helpers' import {compressIfNeeded} from 'lib/media/manip' @@ -24,6 +23,13 @@ import {useTheme} from 'lib/ThemeContext' import {useAnalytics} from 'lib/analytics/analytics' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {cleanError, isNetworkError} from 'lib/strings/errors' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import { + useListCreateMutation, + useListMetadataMutation, +} from '#/state/queries/list' const MAX_NAME = 64 // todo const MAX_DESCRIPTION = 300 // todo @@ -37,18 +43,21 @@ export function Component({ }: { purpose?: string onSave?: (uri: string) => void - list?: ListModel + list?: AppBskyGraphDefs.ListView }) { - const store = useStores() + const {closeModal} = useModalControls() const {isMobile} = useWebMediaQueries() const [error, setError] = useState<string>('') const pal = usePalette('default') const theme = useTheme() const {track} = useAnalytics() + const {_} = useLingui() + const listCreateMutation = useListCreateMutation() + const listMetadataMutation = useListMetadataMutation() const activePurpose = useMemo(() => { - if (list?.data?.purpose) { - return list.data.purpose + if (list?.purpose) { + return list.purpose } if (purpose) { return purpose @@ -59,16 +68,16 @@ export function Component({ const purposeLabel = isCurateList ? 'User' : 'Moderation' const [isProcessing, setProcessing] = useState<boolean>(false) - const [name, setName] = useState<string>(list?.data?.name || '') + const [name, setName] = useState<string>(list?.name || '') const [description, setDescription] = useState<string>( - list?.data?.description || '', + list?.description || '', ) - const [avatar, setAvatar] = useState<string | undefined>(list?.data?.avatar) + const [avatar, setAvatar] = useState<string | undefined>(list?.avatar) const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>() const onPressCancel = useCallback(() => { - store.shell.closeModal() - }, [store]) + closeModal() + }, [closeModal]) const onSelectNewAvatar = useCallback( async (img: RNImage | null) => { @@ -106,7 +115,8 @@ export function Component({ } try { if (list) { - await list.updateMetadata({ + await listMetadataMutation.mutateAsync({ + uri: list.uri, name: nameTrimmed, description: description.trim(), avatar: newAvatar, @@ -114,7 +124,7 @@ export function Component({ Toast.show(`${purposeLabel} list updated`) onSave?.(list.uri) } else { - const res = await ListModel.createList(store, { + const res = await listCreateMutation.mutateAsync({ purpose: activePurpose, name, description, @@ -123,7 +133,7 @@ export function Component({ Toast.show(`${purposeLabel} list created`) onSave?.(res.uri) } - store.shell.closeModal() + closeModal() } catch (e: any) { if (isNetworkError(e)) { setError( @@ -140,7 +150,7 @@ export function Component({ setError, error, onSave, - store, + closeModal, activePurpose, isCurateList, purposeLabel, @@ -148,6 +158,8 @@ export function Component({ description, newAvatar, list, + listMetadataMutation, + listCreateMutation, ]) return ( @@ -161,14 +173,18 @@ export function Component({ ]} testID="createOrEditListModal"> <Text style={[styles.title, pal.text]}> - {list ? 'Edit' : 'New'} {purposeLabel} List + <Trans> + {list ? 'Edit' : 'New'} {purposeLabel} List + </Trans> </Text> {error !== '' && ( <View style={styles.errorContainer}> <ErrorMessage message={error} /> </View> )} - <Text style={[styles.label, pal.text]}>List Avatar</Text> + <Text style={[styles.label, pal.text]}> + <Trans>List Avatar</Trans> + </Text> <View style={[styles.avi, {borderColor: pal.colors.background}]}> <EditableUserAvatar type="list" @@ -180,7 +196,7 @@ export function Component({ <View style={styles.form}> <View> <Text style={[styles.label, pal.text]} nativeID="list-name"> - List Name + <Trans>List Name</Trans> </Text> <TextInput testID="editNameInput" @@ -192,14 +208,14 @@ export function Component({ value={name} onChangeText={v => setName(enforceLen(v, MAX_NAME))} accessible={true} - accessibilityLabel="Name" + accessibilityLabel={_(msg`Name`)} accessibilityHint="" accessibilityLabelledBy="list-name" /> </View> <View style={s.pb10}> <Text style={[styles.label, pal.text]} nativeID="list-description"> - Description + <Trans>Description</Trans> </Text> <TextInput testID="editDescriptionInput" @@ -215,7 +231,7 @@ export function Component({ value={description} onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))} accessible={true} - accessibilityLabel="Description" + accessibilityLabel={_(msg`Description`)} accessibilityHint="" accessibilityLabelledBy="list-description" /> @@ -230,14 +246,16 @@ export function Component({ style={s.mt10} onPress={onPressSave} accessibilityRole="button" - accessibilityLabel="Save" + accessibilityLabel={_(msg`Save`)} accessibilityHint=""> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} end={{x: 1, y: 1}} style={[styles.btn]}> - <Text style={[s.white, s.bold]}>Save</Text> + <Text style={[s.white, s.bold]}> + <Trans>Save</Trans> + </Text> </LinearGradient> </TouchableOpacity> )} @@ -246,11 +264,13 @@ export function Component({ style={s.mt5} onPress={onPressCancel} accessibilityRole="button" - accessibilityLabel="Cancel" + accessibilityLabel={_(msg`Cancel`)} accessibilityHint="" onAccessibilityEscape={onPressCancel}> <View style={[styles.btn]}> - <Text style={[s.black, s.bold, pal.text]}>Cancel</Text> + <Text style={[s.black, s.bold, pal.text]}> + <Trans>Cancel</Trans> + </Text> </View> </TouchableOpacity> </View> diff --git a/src/view/com/modals/DeleteAccount.tsx b/src/view/com/modals/DeleteAccount.tsx index 50a4cd603..ee16d46b3 100644 --- a/src/view/com/modals/DeleteAccount.tsx +++ b/src/view/com/modals/DeleteAccount.tsx @@ -9,7 +9,6 @@ import {TextInput} from './util' import LinearGradient from 'react-native-linear-gradient' import * as Toast from '../util/Toast' import {Text} from '../util/text/Text' -import {useStores} from 'state/index' import {s, colors, gradients} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' @@ -17,13 +16,20 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {ErrorMessage} from '../util/error/ErrorMessage' import {cleanError} from 'lib/strings/errors' import {resetToTab} from '../../../Navigation' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import {useSession, useSessionApi, getAgent} from '#/state/session' export const snapPoints = ['60%'] export function Component({}: {}) { const pal = usePalette('default') const theme = useTheme() - const store = useStores() + const {currentAccount} = useSession() + const {clearCurrentAccount, removeAccount} = useSessionApi() + const {_} = useLingui() + const {closeModal} = useModalControls() const {isMobile} = useWebMediaQueries() const [isEmailSent, setIsEmailSent] = React.useState<boolean>(false) const [confirmCode, setConfirmCode] = React.useState<string>('') @@ -34,7 +40,7 @@ export function Component({}: {}) { setError('') setIsProcessing(true) try { - await store.agent.com.atproto.server.requestAccountDelete() + await getAgent().com.atproto.server.requestAccountDelete() setIsEmailSent(true) } catch (e: any) { setError(cleanError(e)) @@ -42,34 +48,39 @@ export function Component({}: {}) { setIsProcessing(false) } const onPressConfirmDelete = async () => { + if (!currentAccount?.did) { + throw new Error(`DeleteAccount modal: currentAccount.did is undefined`) + } + setError('') setIsProcessing(true) const token = confirmCode.replace(/\s/g, '') try { - await store.agent.com.atproto.server.deleteAccount({ - did: store.me.did, + await getAgent().com.atproto.server.deleteAccount({ + did: currentAccount.did, password, token, }) Toast.show('Your account has been deleted') resetToTab('HomeTab') - store.session.clear() - store.shell.closeModal() + removeAccount(currentAccount) + clearCurrentAccount() + closeModal() } catch (e: any) { setError(cleanError(e)) } setIsProcessing(false) } const onCancel = () => { - store.shell.closeModal() + closeModal() } return ( <View style={[styles.container, pal.view]}> <View style={[styles.innerContainer, pal.view]}> <View style={[styles.titleContainer, pal.view]}> <Text type="title-xl" style={[s.textCenter, pal.text]}> - Delete Account + <Trans>Delete Account</Trans> </Text> <View style={[pal.view, s.flexRow]}> <Text type="title-xl" style={[pal.text, s.bold]}> @@ -83,7 +94,7 @@ export function Component({}: {}) { pal.text, s.bold, ]}> - {store.me.handle} + {currentAccount?.handle} </Text> <Text type="title-xl" style={[pal.text, s.bold]}> {'"'} @@ -93,8 +104,10 @@ export function Component({}: {}) { {!isEmailSent ? ( <> <Text type="lg" style={[styles.description, pal.text]}> - For security reasons, we'll need to send a confirmation code to - your email address. + <Trans> + For security reasons, we'll need to send a confirmation code to + your email address. + </Trans> </Text> {error ? ( <View style={s.mt10}> @@ -111,7 +124,7 @@ export function Component({}: {}) { style={styles.mt20} onPress={onPressSendEmail} accessibilityRole="button" - accessibilityLabel="Send email" + accessibilityLabel={_(msg`Send email`)} accessibilityHint="Sends email with confirmation code for account deletion"> <LinearGradient colors={[ @@ -122,7 +135,7 @@ export function Component({}: {}) { end={{x: 1, y: 1}} style={[styles.btn]}> <Text type="button-lg" style={[s.white, s.bold]}> - Send Email + <Trans>Send Email</Trans> </Text> </LinearGradient> </TouchableOpacity> @@ -130,11 +143,11 @@ export function Component({}: {}) { style={[styles.btn, s.mt10]} onPress={onCancel} accessibilityRole="button" - accessibilityLabel="Cancel account deletion" + accessibilityLabel={_(msg`Cancel account deletion`)} accessibilityHint="" onAccessibilityEscape={onCancel}> <Text type="button-lg" style={pal.textLight}> - Cancel + <Trans>Cancel</Trans> </Text> </TouchableOpacity> </> @@ -147,8 +160,10 @@ export function Component({}: {}) { type="lg" style={styles.description} nativeID="confirmationCode"> - Check your inbox for an email with the confirmation code to enter - below: + <Trans> + Check your inbox for an email with the confirmation code to + enter below: + </Trans> </Text> <TextInput style={[styles.textInput, pal.borderDark, pal.text, styles.mb20]} @@ -158,11 +173,11 @@ export function Component({}: {}) { value={confirmCode} onChangeText={setConfirmCode} accessibilityLabelledBy="confirmationCode" - accessibilityLabel="Confirmation code" + accessibilityLabel={_(msg`Confirmation code`)} accessibilityHint="Input confirmation code for account deletion" /> <Text type="lg" style={styles.description} nativeID="password"> - Please enter your password as well: + <Trans>Please enter your password as well:</Trans> </Text> <TextInput style={[styles.textInput, pal.borderDark, pal.text]} @@ -173,7 +188,7 @@ export function Component({}: {}) { value={password} onChangeText={setPassword} accessibilityLabelledBy="password" - accessibilityLabel="Password" + accessibilityLabel={_(msg`Password`)} accessibilityHint="Input password for account deletion" /> {error ? ( @@ -191,21 +206,21 @@ export function Component({}: {}) { style={[styles.btn, styles.evilBtn, styles.mt20]} onPress={onPressConfirmDelete} accessibilityRole="button" - accessibilityLabel="Confirm delete account" + accessibilityLabel={_(msg`Confirm delete account`)} accessibilityHint=""> <Text type="button-lg" style={[s.white, s.bold]}> - Delete my account + <Trans>Delete my account</Trans> </Text> </TouchableOpacity> <TouchableOpacity style={[styles.btn, s.mt10]} onPress={onCancel} accessibilityRole="button" - accessibilityLabel="Cancel account deletion" + accessibilityLabel={_(msg`Cancel account deletion`)} accessibilityHint="Exits account deletion process" onAccessibilityEscape={onCancel}> <Text type="button-lg" style={pal.textLight}> - Cancel + <Trans>Cancel</Trans> </Text> </TouchableOpacity> </> diff --git a/src/view/com/modals/EditImage.tsx b/src/view/com/modals/EditImage.tsx index dcb6668c7..753907472 100644 --- a/src/view/com/modals/EditImage.tsx +++ b/src/view/com/modals/EditImage.tsx @@ -6,7 +6,6 @@ import {gradients, s} from 'lib/styles' import {useTheme} from 'lib/ThemeContext' import {Text} from '../util/text/Text' import LinearGradient from 'react-native-linear-gradient' -import {useStores} from 'state/index' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import ImageEditor, {Position} from 'react-avatar-editor' import {TextInput} from './util' @@ -19,6 +18,9 @@ import {Slider} from '@miblanchard/react-native-slider' import {MaterialIcons} from '@expo/vector-icons' import {observer} from 'mobx-react-lite' import {getKeys} from 'lib/type-assertions' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' export const snapPoints = ['80%'] @@ -52,9 +54,10 @@ export const Component = observer(function EditImageImpl({ }: Props) { const pal = usePalette('default') const theme = useTheme() - const store = useStores() + const {_} = useLingui() const windowDimensions = useWindowDimensions() const {isMobile} = useWebMediaQueries() + const {closeModal} = useModalControls() const { aspectRatio, @@ -128,8 +131,8 @@ export const Component = observer(function EditImageImpl({ }, [image]) const onCloseModal = useCallback(() => { - store.shell.closeModal() - }, [store.shell]) + closeModal() + }, [closeModal]) const onPressCancel = useCallback(async () => { await gallery.previous(image) @@ -200,7 +203,9 @@ export const Component = observer(function EditImageImpl({ paddingHorizontal: isMobile ? 16 : undefined, }, ]}> - <Text style={[styles.title, pal.text]}>Edit image</Text> + <Text style={[styles.title, pal.text]}> + <Trans>Edit image</Trans> + </Text> <View style={[styles.gap18, s.flexRow]}> <View> <View @@ -228,7 +233,7 @@ export const Component = observer(function EditImageImpl({ <View> {!isMobile ? ( <Text type="sm-bold" style={pal.text}> - Ratios + <Trans>Ratios</Trans> </Text> ) : null} <View style={imgControlStyles}> @@ -263,7 +268,7 @@ export const Component = observer(function EditImageImpl({ </View> {!isMobile ? ( <Text type="sm-bold" style={[pal.text, styles.subsection]}> - Transformations + <Trans>Transformations</Trans> </Text> ) : null} <View style={imgControlStyles}> @@ -291,7 +296,7 @@ export const Component = observer(function EditImageImpl({ </View> <View style={[styles.gap18, styles.bottomSection, pal.border]}> <Text type="sm-bold" style={pal.text} nativeID="alt-text"> - Accessibility + <Trans>Accessibility</Trans> </Text> <TextInput testID="altTextImageInput" @@ -307,7 +312,7 @@ export const Component = observer(function EditImageImpl({ multiline value={altText} onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))} - accessibilityLabel="Alt text" + accessibilityLabel={_(msg`Alt text`)} accessibilityHint="" accessibilityLabelledBy="alt-text" /> @@ -315,7 +320,7 @@ export const Component = observer(function EditImageImpl({ <View style={styles.btns}> <Pressable onPress={onPressCancel} accessibilityRole="button"> <Text type="xl" style={pal.link}> - Cancel + <Trans>Cancel</Trans> </Text> </Pressable> <Pressable onPress={onPressSave} accessibilityRole="button"> @@ -325,7 +330,7 @@ export const Component = observer(function EditImageImpl({ end={{x: 1, y: 1}} style={[styles.btn]}> <Text type="xl-medium" style={s.white}> - Done + <Trans>Done</Trans> </Text> </LinearGradient> </Pressable> diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx index dfd5305f5..e044f8c0e 100644 --- a/src/view/com/modals/EditProfile.tsx +++ b/src/view/com/modals/EditProfile.tsx @@ -11,10 +11,9 @@ import { } from 'react-native' import LinearGradient from 'react-native-linear-gradient' import {Image as RNImage} from 'react-native-image-crop-picker' +import {AppBskyActorDefs} from '@atproto/api' import {Text} from '../util/text/Text' import {ErrorMessage} from '../util/error/ErrorMessage' -import {useStores} from 'state/index' -import {ProfileModel} from 'state/models/content/profile' import {s, colors, gradients} from 'lib/styles' import {enforceLen} from 'lib/strings/helpers' import {MAX_DISPLAY_NAME, MAX_DESCRIPTION} from 'lib/constants' @@ -24,9 +23,14 @@ import {EditableUserAvatar} from '../util/UserAvatar' import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' import {useAnalytics} from 'lib/analytics/analytics' -import {cleanError, isNetworkError} from 'lib/strings/errors' +import {cleanError} from 'lib/strings/errors' import Animated, {FadeOut} from 'react-native-reanimated' import {isWeb} from 'platform/detection' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import {useProfileUpdateMutation} from '#/state/queries/profile' +import {logger} from '#/logger' const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity) @@ -34,30 +38,30 @@ const AnimatedTouchableOpacity = export const snapPoints = ['fullscreen'] export function Component({ - profileView, + profile, onUpdate, }: { - profileView: ProfileModel + profile: AppBskyActorDefs.ProfileViewDetailed onUpdate?: () => void }) { - const store = useStores() - const [error, setError] = useState<string>('') const pal = usePalette('default') const theme = useTheme() const {track} = useAnalytics() - - const [isProcessing, setProcessing] = useState<boolean>(false) + const {_} = useLingui() + const {closeModal} = useModalControls() + const updateMutation = useProfileUpdateMutation() + const [imageError, setImageError] = useState<string>('') const [displayName, setDisplayName] = useState<string>( - profileView.displayName || '', + profile.displayName || '', ) const [description, setDescription] = useState<string>( - profileView.description || '', + profile.description || '', ) const [userBanner, setUserBanner] = useState<string | undefined | null>( - profileView.banner, + profile.banner, ) const [userAvatar, setUserAvatar] = useState<string | undefined | null>( - profileView.avatar, + profile.avatar, ) const [newUserBanner, setNewUserBanner] = useState< RNImage | undefined | null @@ -66,10 +70,11 @@ export function Component({ RNImage | undefined | null >() const onPressCancel = () => { - store.shell.closeModal() + closeModal() } const onSelectNewAvatar = useCallback( async (img: RNImage | null) => { + setImageError('') if (img === null) { setNewUserAvatar(null) setUserAvatar(null) @@ -81,14 +86,15 @@ export function Component({ setNewUserAvatar(finalImg) setUserAvatar(finalImg.path) } catch (e: any) { - setError(cleanError(e)) + setImageError(cleanError(e)) } }, - [track, setNewUserAvatar, setUserAvatar, setError], + [track, setNewUserAvatar, setUserAvatar, setImageError], ) const onSelectNewBanner = useCallback( async (img: RNImage | null) => { + setImageError('') if (!img) { setNewUserBanner(null) setUserBanner(null) @@ -100,58 +106,50 @@ export function Component({ setNewUserBanner(finalImg) setUserBanner(finalImg.path) } catch (e: any) { - setError(cleanError(e)) + setImageError(cleanError(e)) } }, - [track, setNewUserBanner, setUserBanner, setError], + [track, setNewUserBanner, setUserBanner, setImageError], ) const onPressSave = useCallback(async () => { track('EditProfile:Save') - setProcessing(true) - if (error) { - setError('') - } + setImageError('') try { - await profileView.updateProfile( - { + await updateMutation.mutateAsync({ + profile, + updates: { displayName, description, }, newUserAvatar, newUserBanner, - ) + }) Toast.show('Profile updated') onUpdate?.() - store.shell.closeModal() + closeModal() } catch (e: any) { - if (isNetworkError(e)) { - setError( - 'Failed to save your profile. Check your internet connection and try again.', - ) - } else { - setError(cleanError(e)) - } + logger.error('Failed to update user profile', {error: String(e)}) } - setProcessing(false) }, [ track, - setProcessing, - setError, - error, - profileView, + updateMutation, + profile, onUpdate, - store, + closeModal, displayName, description, newUserAvatar, newUserBanner, + setImageError, ]) return ( <KeyboardAvoidingView style={s.flex1} behavior="height"> <ScrollView style={[pal.view]} testID="editProfileModal"> - <Text style={[styles.title, pal.text]}>Edit my profile</Text> + <Text style={[styles.title, pal.text]}> + <Trans>Edit my profile</Trans> + </Text> <View style={styles.photos}> <UserBanner banner={userBanner} @@ -165,14 +163,21 @@ export function Component({ /> </View> </View> - {error !== '' && ( + {updateMutation.isError && ( + <View style={styles.errorContainer}> + <ErrorMessage message={cleanError(updateMutation.error)} /> + </View> + )} + {imageError !== '' && ( <View style={styles.errorContainer}> - <ErrorMessage message={error} /> + <ErrorMessage message={imageError} /> </View> )} <View style={styles.form}> <View> - <Text style={[styles.label, pal.text]}>Display Name</Text> + <Text style={[styles.label, pal.text]}> + <Trans>Display Name</Trans> + </Text> <TextInput testID="editProfileDisplayNameInput" style={[styles.textInput, pal.border, pal.text]} @@ -183,12 +188,14 @@ export function Component({ setDisplayName(enforceLen(v, MAX_DISPLAY_NAME)) } accessible={true} - accessibilityLabel="Display name" + accessibilityLabel={_(msg`Display name`)} accessibilityHint="Edit your display name" /> </View> <View style={s.pb10}> - <Text style={[styles.label, pal.text]}>Description</Text> + <Text style={[styles.label, pal.text]}> + <Trans>Description</Trans> + </Text> <TextInput testID="editProfileDescriptionInput" style={[styles.textArea, pal.border, pal.text]} @@ -199,11 +206,11 @@ export function Component({ value={description} onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))} accessible={true} - accessibilityLabel="Description" + accessibilityLabel={_(msg`Description`)} accessibilityHint="Edit your profile description" /> </View> - {isProcessing ? ( + {updateMutation.isPending ? ( <View style={[styles.btn, s.mt10, {backgroundColor: colors.gray2}]}> <ActivityIndicator /> </View> @@ -213,29 +220,33 @@ export function Component({ style={s.mt10} onPress={onPressSave} accessibilityRole="button" - accessibilityLabel="Save" + accessibilityLabel={_(msg`Save`)} accessibilityHint="Saves any changes to your profile"> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} end={{x: 1, y: 1}} style={[styles.btn]}> - <Text style={[s.white, s.bold]}>Save Changes</Text> + <Text style={[s.white, s.bold]}> + <Trans>Save Changes</Trans> + </Text> </LinearGradient> </TouchableOpacity> )} - {!isProcessing && ( + {!updateMutation.isPending && ( <AnimatedTouchableOpacity exiting={!isWeb ? FadeOut : undefined} testID="editProfileCancelBtn" style={s.mt5} onPress={onPressCancel} accessibilityRole="button" - accessibilityLabel="Cancel profile editing" + accessibilityLabel={_(msg`Cancel profile editing`)} accessibilityHint="" onAccessibilityEscape={onPressCancel}> <View style={[styles.btn]}> - <Text style={[s.black, s.bold, pal.text]}>Cancel</Text> + <Text style={[s.black, s.bold, pal.text]}> + <Trans>Cancel</Trans> + </Text> </View> </AnimatedTouchableOpacity> )} diff --git a/src/view/com/modals/InviteCodes.tsx b/src/view/com/modals/InviteCodes.tsx index 09cfd4de7..82a826aca 100644 --- a/src/view/com/modals/InviteCodes.tsx +++ b/src/view/com/modals/InviteCodes.tsx @@ -1,6 +1,11 @@ import React from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {observer} from 'mobx-react-lite' +import { + StyleSheet, + TouchableOpacity, + View, + ActivityIndicator, +} from 'react-native' +import {ComAtprotoServerDefs} from '@atproto/api' import { FontAwesomeIcon, FontAwesomeIconStyle, @@ -9,30 +14,57 @@ import Clipboard from '@react-native-clipboard/clipboard' import {Text} from '../util/text/Text' import {Button} from '../util/forms/Button' import * as Toast from '../util/Toast' -import {useStores} from 'state/index' import {ScrollView} from './util' import {usePalette} from 'lib/hooks/usePalette' import {isWeb} from 'platform/detection' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {Trans} from '@lingui/macro' +import {cleanError} from 'lib/strings/errors' +import {useModalControls} from '#/state/modals' +import {useInvitesState, useInvitesAPI} from '#/state/invites' +import {UserInfoText} from '../util/UserInfoText' +import {makeProfileLink} from '#/lib/routes/links' +import {Link} from '../util/Link' +import {ErrorMessage} from '../util/error/ErrorMessage' +import { + useInviteCodesQuery, + InviteCodesQueryResponse, +} from '#/state/queries/invites' export const snapPoints = ['70%'] -export function Component({}: {}) { +export function Component() { + const {isLoading, data: invites, error} = useInviteCodesQuery() + + return error ? ( + <ErrorMessage message={cleanError(error)} /> + ) : isLoading || !invites ? ( + <View style={{padding: 18}}> + <ActivityIndicator /> + </View> + ) : ( + <Inner invites={invites} /> + ) +} + +export function Inner({invites}: {invites: InviteCodesQueryResponse}) { const pal = usePalette('default') - const store = useStores() + const {closeModal} = useModalControls() const {isTabletOrDesktop} = useWebMediaQueries() const onClose = React.useCallback(() => { - store.shell.closeModal() - }, [store]) + closeModal() + }, [closeModal]) - if (store.me.invites.length === 0) { + if (invites.all.length === 0) { return ( <View style={[styles.container, pal.view]} testID="inviteCodesModal"> <View style={[styles.empty, pal.viewLight]}> <Text type="lg" style={[pal.text, styles.emptyText]}> - You don't have any invite codes yet! We'll send you some when you've - been on Bluesky for a little longer. + <Trans> + You don't have any invite codes yet! We'll send you some when + you've been on Bluesky for a little longer. + </Trans> </Text> </View> <View style={styles.flex1} /> @@ -56,18 +88,29 @@ export function Component({}: {}) { return ( <View style={[styles.container, pal.view]} testID="inviteCodesModal"> <Text type="title-xl" style={[styles.title, pal.text]}> - Invite a Friend + <Trans>Invite a Friend</Trans> </Text> <Text type="lg" style={[styles.description, pal.text]}> - Each code works once. You'll receive more invite codes periodically. + <Trans> + Each code works once. You'll receive more invite codes periodically. + </Trans> </Text> <ScrollView style={[styles.scrollContainer, pal.border]}> - {store.me.invites.map((invite, i) => ( + {invites.available.map((invite, i) => ( <InviteCode testID={`inviteCode-${i}`} key={invite.code} - code={invite.code} - used={invite.available - invite.uses.length <= 0 || invite.disabled} + invite={invite} + invites={invites} + /> + ))} + {invites.used.map((invite, i) => ( + <InviteCode + used + testID={`inviteCode-${i}`} + key={invite.code} + invite={invite} + invites={invites} /> ))} </ScrollView> @@ -85,56 +128,89 @@ export function Component({}: {}) { ) } -const InviteCode = observer(function InviteCodeImpl({ +function InviteCode({ testID, - code, + invite, used, + invites, }: { testID: string - code: string + invite: ComAtprotoServerDefs.InviteCode used?: boolean + invites: InviteCodesQueryResponse }) { const pal = usePalette('default') - const store = useStores() - const {invitesAvailable} = store.me + const invitesState = useInvitesState() + const {setInviteCopied} = useInvitesAPI() const onPress = React.useCallback(() => { - Clipboard.setString(code) + Clipboard.setString(invite.code) Toast.show('Copied to clipboard') - store.invitedUsers.setInviteCopied(code) - }, [store, code]) + setInviteCopied(invite.code) + }, [setInviteCopied, invite]) return ( - <TouchableOpacity - testID={testID} - style={[styles.inviteCode, pal.border]} - onPress={onPress} - accessibilityRole="button" - accessibilityLabel={ - invitesAvailable === 1 - ? 'Invite codes: 1 available' - : `Invite codes: ${invitesAvailable} available` - } - accessibilityHint="Opens list of invite codes"> - <Text - testID={`${testID}-code`} - type={used ? 'md' : 'md-bold'} - style={used ? [pal.textLight, styles.strikeThrough] : pal.text}> - {code} - </Text> - <View style={styles.flex1} /> - {!used && store.invitedUsers.isInviteCopied(code) && ( - <Text style={[pal.textLight, styles.codeCopied]}>Copied</Text> - )} - {!used && ( - <FontAwesomeIcon - icon={['far', 'clone']} - style={pal.text as FontAwesomeIconStyle} - /> - )} - </TouchableOpacity> + <View + style={[ + pal.border, + {borderBottomWidth: 1, paddingHorizontal: 20, paddingVertical: 14}, + ]}> + <TouchableOpacity + testID={testID} + style={[styles.inviteCode]} + onPress={onPress} + accessibilityRole="button" + accessibilityLabel={ + invites.available.length === 1 + ? 'Invite codes: 1 available' + : `Invite codes: ${invites.available.length} available` + } + accessibilityHint="Opens list of invite codes"> + <Text + testID={`${testID}-code`} + type={used ? 'md' : 'md-bold'} + style={used ? [pal.textLight, styles.strikeThrough] : pal.text}> + {invite.code} + </Text> + <View style={styles.flex1} /> + {!used && invitesState.copiedInvites.includes(invite.code) && ( + <Text style={[pal.textLight, styles.codeCopied]}> + <Trans>Copied</Trans> + </Text> + )} + {!used && ( + <FontAwesomeIcon + icon={['far', 'clone']} + style={pal.text as FontAwesomeIconStyle} + /> + )} + </TouchableOpacity> + {invite.uses.length > 0 ? ( + <View + style={{ + flexDirection: 'column', + gap: 8, + paddingTop: 6, + }}> + <Text style={pal.text}> + <Trans>Used by:</Trans> + </Text> + {invite.uses.map(use => ( + <Link + key={use.usedBy} + href={makeProfileLink({handle: use.usedBy, did: ''})} + style={{ + flexDirection: 'row', + }}> + <Text style={pal.text}>• </Text> + <UserInfoText did={use.usedBy} style={pal.link} /> + </Link> + ))} + </View> + ) : null} + </View> ) -}) +} const styles = StyleSheet.create({ container: { @@ -176,9 +252,6 @@ const styles = StyleSheet.create({ inviteCode: { flexDirection: 'row', alignItems: 'center', - borderBottomWidth: 1, - paddingHorizontal: 20, - paddingVertical: 14, }, codeCopied: { marginRight: 8, diff --git a/src/view/com/modals/LinkWarning.tsx b/src/view/com/modals/LinkWarning.tsx index 67a156af4..39e6cc3e6 100644 --- a/src/view/com/modals/LinkWarning.tsx +++ b/src/view/com/modals/LinkWarning.tsx @@ -1,33 +1,29 @@ import React from 'react' import {Linking, SafeAreaView, StyleSheet, View} from 'react-native' import {ScrollView} from './util' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Text} from '../util/text/Text' import {Button} from '../util/forms/Button' -import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {isWeb} from 'platform/detection' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {isPossiblyAUrl, splitApexDomain} from 'lib/strings/url-helpers' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' export const snapPoints = ['50%'] -export const Component = observer(function Component({ - text, - href, -}: { - text: string - href: string -}) { +export function Component({text, href}: {text: string; href: string}) { const pal = usePalette('default') - const store = useStores() + const {closeModal} = useModalControls() const {isMobile} = useWebMediaQueries() + const {_} = useLingui() const potentiallyMisleading = isPossiblyAUrl(text) const onPressVisit = () => { - store.shell.closeModal() + closeModal() Linking.openURL(href) } @@ -45,26 +41,26 @@ export const Component = observer(function Component({ size={18} /> <Text type="title-lg" style={[pal.text, styles.title]}> - Potentially Misleading Link + <Trans>Potentially Misleading Link</Trans> </Text> </> ) : ( <Text type="title-lg" style={[pal.text, styles.title]}> - Leaving Bluesky + <Trans>Leaving Bluesky</Trans> </Text> )} </View> <View style={{gap: 10}}> <Text type="lg" style={pal.text}> - This link is taking you to the following website: + <Trans>This link is taking you to the following website:</Trans> </Text> <LinkBox href={href} /> {potentiallyMisleading && ( <Text type="lg" style={pal.text}> - Make sure this is where you intend to go! + <Trans>Make sure this is where you intend to go!</Trans> </Text> )} </View> @@ -74,7 +70,7 @@ export const Component = observer(function Component({ testID="confirmBtn" type="primary" onPress={onPressVisit} - accessibilityLabel="Visit Site" + accessibilityLabel={_(msg`Visit Site`)} accessibilityHint="" label="Visit Site" labelContainerStyle={{justifyContent: 'center', padding: 4}} @@ -83,8 +79,10 @@ export const Component = observer(function Component({ <Button testID="cancelBtn" type="default" - onPress={() => store.shell.closeModal()} - accessibilityLabel="Cancel" + onPress={() => { + closeModal() + }} + accessibilityLabel={_(msg`Cancel`)} accessibilityHint="" label="Cancel" labelContainerStyle={{justifyContent: 'center', padding: 4}} @@ -94,7 +92,7 @@ export const Component = observer(function Component({ </ScrollView> </SafeAreaView> ) -}) +} function LinkBox({href}: {href: string}) { const pal = usePalette('default') diff --git a/src/view/com/modals/ListAddUser.tsx b/src/view/com/modals/ListAddRemoveUsers.tsx index a04e2d186..14e16d6bf 100644 --- a/src/view/com/modals/ListAddUser.tsx +++ b/src/view/com/modals/ListAddRemoveUsers.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useCallback, useState, useMemo} from 'react' +import React, {useCallback, useState} from 'react' import { ActivityIndicator, Pressable, @@ -6,17 +6,13 @@ import { StyleSheet, View, } from 'react-native' -import {AppBskyActorDefs} from '@atproto/api' +import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api' import {ScrollView, TextInput} from './util' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Text} from '../util/text/Text' import {Button} from '../util/forms/Button' import {UserAvatar} from '../util/UserAvatar' import * as Toast from '../util/Toast' -import {useStores} from 'state/index' -import {ListModel} from 'state/models/content/list' -import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {isWeb} from 'platform/detection' @@ -26,47 +22,40 @@ import {cleanError} from 'lib/strings/errors' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {HITSLOP_20} from '#/lib/constants' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import { + useDangerousListMembershipsQuery, + getMembership, + ListMembersip, + useListMembershipAddMutation, + useListMembershipRemoveMutation, +} from '#/state/queries/list-memberships' +import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' export const snapPoints = ['90%'] -export const Component = observer(function Component({ +export function Component({ list, - onAdd, + onChange, }: { - list: ListModel - onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void + list: AppBskyGraphDefs.ListView + onChange?: ( + type: 'add' | 'remove', + profile: AppBskyActorDefs.ProfileViewBasic, + ) => void }) { const pal = usePalette('default') - const store = useStores() + const {_} = useLingui() + const {closeModal} = useModalControls() const {isMobile} = useWebMediaQueries() const [query, setQuery] = useState('') - const autocompleteView = useMemo<UserAutocompleteModel>( - () => new UserAutocompleteModel(store), - [store], - ) + const autocomplete = useActorAutocompleteQuery(query) + const {data: memberships} = useDangerousListMembershipsQuery() const [isKeyboardVisible] = useIsKeyboardVisible() - // initial setup - useEffect(() => { - autocompleteView.setup().then(() => { - autocompleteView.setPrefix('') - }) - autocompleteView.setActive(true) - list.loadAll() - }, [autocompleteView, list]) - - const onChangeQuery = useCallback( - (text: string) => { - setQuery(text) - autocompleteView.setPrefix(text) - }, - [setQuery, autocompleteView], - ) - - const onPressCancelSearch = useCallback( - () => onChangeQuery(''), - [onChangeQuery], - ) + const onPressCancelSearch = useCallback(() => setQuery(''), [setQuery]) return ( <SafeAreaView @@ -81,9 +70,9 @@ export const Component = observer(function Component({ placeholder="Search for users" placeholderTextColor={pal.colors.textLight} value={query} - onChangeText={onChangeQuery} + onChangeText={setQuery} accessible={true} - accessibilityLabel="Search" + accessibilityLabel={_(msg`Search`)} accessibilityHint="" autoFocus autoCapitalize="none" @@ -95,7 +84,7 @@ export const Component = observer(function Component({ <Pressable onPress={onPressCancelSearch} accessibilityRole="button" - accessibilityLabel="Cancel search" + accessibilityLabel={_(msg`Cancel search`)} accessibilityHint="Exits inputting search query" onAccessibilityEscape={onPressCancelSearch} hitSlop={HITSLOP_20}> @@ -111,19 +100,20 @@ export const Component = observer(function Component({ style={[s.flex1]} keyboardDismissMode="none" keyboardShouldPersistTaps="always"> - {autocompleteView.isLoading ? ( + {autocomplete.isLoading ? ( <View style={{marginVertical: 20}}> <ActivityIndicator /> </View> - ) : autocompleteView.suggestions.length ? ( + ) : autocomplete.data?.length ? ( <> - {autocompleteView.suggestions.slice(0, 40).map((item, i) => ( + {autocomplete.data.slice(0, 40).map((item, i) => ( <UserResult key={item.did} list={list} profile={item} + memberships={memberships} noBorder={i === 0} - onAdd={onAdd} + onChange={onChange} /> ))} </> @@ -134,7 +124,7 @@ export const Component = observer(function Component({ pal.textLight, {paddingHorizontal: 12, paddingVertical: 16}, ]}> - No results found for {autocompleteView.prefix} + <Trans>No results found for {query}</Trans> </Text> )} </ScrollView> @@ -146,8 +136,10 @@ export const Component = observer(function Component({ <Button testID="doneBtn" type="default" - onPress={() => store.shell.closeModal()} - accessibilityLabel="Done" + onPress={() => { + closeModal() + }} + accessibilityLabel={_(msg`Done`)} accessibilityHint="" label="Done" labelContainerStyle={{justifyContent: 'center', padding: 4}} @@ -157,36 +149,71 @@ export const Component = observer(function Component({ </View> </SafeAreaView> ) -}) +} function UserResult({ profile, list, + memberships, noBorder, - onAdd, + onChange, }: { profile: AppBskyActorDefs.ProfileViewBasic - list: ListModel + list: AppBskyGraphDefs.ListView + memberships: ListMembersip[] | undefined noBorder: boolean - onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void | undefined + onChange?: ( + type: 'add' | 'remove', + profile: AppBskyActorDefs.ProfileViewBasic, + ) => void | undefined }) { const pal = usePalette('default') + const {_} = useLingui() const [isProcessing, setIsProcessing] = useState(false) - const [isAdded, setIsAdded] = useState(list.isMember(profile.did)) + const membership = React.useMemo( + () => getMembership(memberships, list.uri, profile.did), + [memberships, list.uri, profile.did], + ) + const listMembershipAddMutation = useListMembershipAddMutation() + const listMembershipRemoveMutation = useListMembershipRemoveMutation() - const onPressAdd = useCallback(async () => { + const onToggleMembership = useCallback(async () => { + if (typeof membership === 'undefined') { + return + } setIsProcessing(true) try { - await list.addMember(profile) - Toast.show('Added to list') - setIsAdded(true) - onAdd?.(profile) + if (membership === false) { + await listMembershipAddMutation.mutateAsync({ + listUri: list.uri, + actorDid: profile.did, + }) + Toast.show(_(msg`Added to list`)) + onChange?.('add', profile) + } else { + await listMembershipRemoveMutation.mutateAsync({ + listUri: list.uri, + actorDid: profile.did, + membershipUri: membership, + }) + Toast.show(_(msg`Removed from list`)) + onChange?.('remove', profile) + } } catch (e) { Toast.show(cleanError(e)) } finally { setIsProcessing(false) } - }, [list, profile, setIsProcessing, setIsAdded, onAdd]) + }, [ + _, + list, + profile, + membership, + setIsProcessing, + onChange, + listMembershipAddMutation, + listMembershipRemoveMutation, + ]) return ( <View @@ -228,16 +255,14 @@ function UserResult({ {!!profile.viewer?.followedBy && <View style={s.flexRow} />} </View> <View> - {isAdded ? ( - <FontAwesomeIcon icon="check" /> - ) : isProcessing ? ( + {isProcessing || typeof membership === 'undefined' ? ( <ActivityIndicator /> ) : ( <Button testID={`user-${profile.handle}-addBtn`} type="default" - label="Add" - onPress={onPressAdd} + label={membership === false ? _(msg`Add`) : _(msg`Remove`)} + onPress={onToggleMembership} /> )} </View> diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 5aaa09e87..a3e6fb9e5 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -1,15 +1,15 @@ import React, {useRef, useEffect} from 'react' import {StyleSheet} from 'react-native' import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context' -import {observer} from 'mobx-react-lite' import BottomSheet from '@gorhom/bottom-sheet' -import {useStores} from 'state/index' import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' import {usePalette} from 'lib/hooks/usePalette' import {timeout} from 'lib/async/timeout' import {navigate} from '../../../Navigation' import once from 'lodash.once' +import {useModals, useModalControls} from '#/state/modals' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import * as ConfirmModal from './Confirm' import * as EditProfileModal from './EditProfile' import * as ProfilePreviewModal from './ProfilePreview' @@ -18,7 +18,7 @@ import * as RepostModal from './Repost' import * as SelfLabelModal from './SelfLabel' import * as CreateOrEditListModal from './CreateOrEditList' import * as UserAddRemoveListsModal from './UserAddRemoveLists' -import * as ListAddUserModal from './ListAddUser' +import * as ListAddUserModal from './ListAddRemoveUsers' import * as AltImageModal from './AltImage' import * as EditImageModal from './AltImage' import * as ReportModal from './report/Modal' @@ -40,26 +40,29 @@ import * as LinkWarningModal from './LinkWarning' const DEFAULT_SNAPPOINTS = ['90%'] const HANDLE_HEIGHT = 24 -export const ModalsContainer = observer(function ModalsContainer() { - const store = useStores() +export function ModalsContainer() { + const {isModalActive, activeModals} = useModals() + const {closeModal} = useModalControls() const bottomSheetRef = useRef<BottomSheet>(null) const pal = usePalette('default') const safeAreaInsets = useSafeAreaInsets() - const activeModal = - store.shell.activeModals[store.shell.activeModals.length - 1] + const activeModal = activeModals[activeModals.length - 1] const navigateOnce = once(navigate) - const onBottomSheetAnimate = (fromIndex: number, toIndex: number) => { - if (activeModal?.name === 'profile-preview' && toIndex === 1) { - // begin loading the profile screen behind the scenes - navigateOnce('Profile', {name: activeModal.did}) - } - } + // It seems like the bottom sheet bugs out when this callback changes. + const onBottomSheetAnimate = useNonReactiveCallback( + (_fromIndex: number, toIndex: number) => { + if (activeModal?.name === 'profile-preview' && toIndex === 1) { + // begin loading the profile screen behind the scenes + navigateOnce('Profile', {name: activeModal.did}) + } + }, + ) const onBottomSheetChange = async (snapPoint: number) => { if (snapPoint === -1) { - store.shell.closeModal() + closeModal() } else if (activeModal?.name === 'profile-preview' && snapPoint === 1) { await navigateOnce('Profile', {name: activeModal.did}) // There is no particular callback for when the view has actually been presented. @@ -67,21 +70,21 @@ export const ModalsContainer = observer(function ModalsContainer() { // It's acceptable because the data is already being fetched + it usually takes longer anyway. // TODO: Figure out why avatar/cover don't always show instantly from cache. await timeout(200) - store.shell.closeModal() + closeModal() } } const onClose = () => { bottomSheetRef.current?.close() - store.shell.closeModal() + closeModal() } useEffect(() => { - if (store.shell.isModalActive) { + if (isModalActive) { bottomSheetRef.current?.expand() } else { bottomSheetRef.current?.close() } - }, [store.shell.isModalActive, bottomSheetRef, activeModal?.name]) + }, [isModalActive, bottomSheetRef, activeModal?.name]) let needsSafeTopInset = false let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS @@ -108,7 +111,7 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'user-add-remove-lists') { snapPoints = UserAddRemoveListsModal.snapPoints element = <UserAddRemoveListsModal.Component {...activeModal} /> - } else if (activeModal?.name === 'list-add-user') { + } else if (activeModal?.name === 'list-add-remove-users') { snapPoints = ListAddUserModal.snapPoints element = <ListAddUserModal.Component {...activeModal} /> } else if (activeModal?.name === 'delete-account') { @@ -184,12 +187,12 @@ export const ModalsContainer = observer(function ModalsContainer() { snapPoints={snapPoints} topInset={topInset} handleHeight={HANDLE_HEIGHT} - index={store.shell.isModalActive ? 0 : -1} + index={isModalActive ? 0 : -1} enablePanDownToClose android_keyboardInputMode="adjustResize" keyboardBlurBehavior="restore" backdropComponent={ - store.shell.isModalActive ? createCustomBackdrop(onClose) : undefined + isModalActive ? createCustomBackdrop(onClose) : undefined } handleIndicatorStyle={{backgroundColor: pal.text.color}} handleStyle={[styles.handle, pal.view]} @@ -198,7 +201,7 @@ export const ModalsContainer = observer(function ModalsContainer() { {element} </BottomSheet> ) -}) +} const styles = StyleSheet.create({ handle: { diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index ede845378..c39ba1f51 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -1,11 +1,11 @@ import React from 'react' import {TouchableWithoutFeedback, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {useStores} from 'state/index' +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import type {Modal as ModalIface} from 'state/models/ui/shell' +import {useModals, useModalControls} from '#/state/modals' +import type {Modal as ModalIface} from '#/state/modals' import * as ConfirmModal from './Confirm' import * as EditProfileModal from './EditProfile' import * as ProfilePreviewModal from './ProfilePreview' @@ -13,7 +13,7 @@ import * as ServerInputModal from './ServerInput' import * as ReportModal from './report/Modal' import * as CreateOrEditListModal from './CreateOrEditList' import * as UserAddRemoveLists from './UserAddRemoveLists' -import * as ListAddUserModal from './ListAddUser' +import * as ListAddUserModal from './ListAddRemoveUsers' import * as DeleteAccountModal from './DeleteAccount' import * as RepostModal from './Repost' import * as SelfLabelModal from './SelfLabel' @@ -33,28 +33,29 @@ import * as VerifyEmailModal from './VerifyEmail' import * as ChangeEmailModal from './ChangeEmail' import * as LinkWarningModal from './LinkWarning' -export const ModalsContainer = observer(function ModalsContainer() { - const store = useStores() +export function ModalsContainer() { + const {isModalActive, activeModals} = useModals() - if (!store.shell.isModalActive) { + if (!isModalActive) { return null } return ( <> - {store.shell.activeModals.map((modal, i) => ( + {activeModals.map((modal, i) => ( <Modal key={`modal-${i}`} modal={modal} /> ))} </> ) -}) +} function Modal({modal}: {modal: ModalIface}) { - const store = useStores() + const {isModalActive} = useModals() + const {closeModal} = useModalControls() const pal = usePalette('default') const {isMobile} = useWebMediaQueries() - if (!store.shell.isModalActive) { + if (!isModalActive) { return null } @@ -62,7 +63,7 @@ function Modal({modal}: {modal: ModalIface}) { if (modal.name === 'crop-image' || modal.name === 'edit-image') { return // dont close on mask presses during crop } - store.shell.closeModal() + closeModal() } const onInnerPress = () => { // TODO: can we use prevent default? @@ -84,7 +85,7 @@ function Modal({modal}: {modal: ModalIface}) { element = <CreateOrEditListModal.Component {...modal} /> } else if (modal.name === 'user-add-remove-lists') { element = <UserAddRemoveLists.Component {...modal} /> - } else if (modal.name === 'list-add-user') { + } else if (modal.name === 'list-add-remove-users') { element = <ListAddUserModal.Component {...modal} /> } else if (modal.name === 'crop-image') { element = <CropImageModal.Component {...modal} /> @@ -129,7 +130,10 @@ function Modal({modal}: {modal: ModalIface}) { return ( // eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors <TouchableWithoutFeedback onPress={onPressMask}> - <View style={styles.mask}> + <Animated.View + style={styles.mask} + entering={FadeIn.duration(150)} + exiting={FadeOut}> {/* eslint-disable-next-line react-native-a11y/has-valid-accessibility-descriptors */} <TouchableWithoutFeedback onPress={onInnerPress}> <View @@ -142,7 +146,7 @@ function Modal({modal}: {modal: ModalIface}) { {element} </View> </TouchableWithoutFeedback> - </View> + </Animated.View> </TouchableWithoutFeedback> ) } diff --git a/src/view/com/modals/ModerationDetails.tsx b/src/view/com/modals/ModerationDetails.tsx index c01312d69..c117023d4 100644 --- a/src/view/com/modals/ModerationDetails.tsx +++ b/src/view/com/modals/ModerationDetails.tsx @@ -1,7 +1,6 @@ import React from 'react' import {StyleSheet, View} from 'react-native' import {ModerationUI} from '@atproto/api' -import {useStores} from 'state/index' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {s} from 'lib/styles' import {Text} from '../util/text/Text' @@ -10,6 +9,7 @@ import {usePalette} from 'lib/hooks/usePalette' import {isWeb} from 'platform/detection' import {listUriToHref} from 'lib/strings/url-helpers' import {Button} from '../util/forms/Button' +import {useModalControls} from '#/state/modals' export const snapPoints = [300] @@ -20,7 +20,7 @@ export function Component({ context: 'account' | 'content' moderation: ModerationUI }) { - const store = useStores() + const {closeModal} = useModalControls() const {isMobile} = useWebMediaQueries() const pal = usePalette('default') @@ -102,7 +102,9 @@ export function Component({ <Button type="primary" style={styles.btn} - onPress={() => store.shell.closeModal()}> + onPress={() => { + closeModal() + }}> <Text type="button-lg" style={[pal.textLight, s.textCenter, s.white]}> Okay </Text> diff --git a/src/view/com/modals/ProfilePreview.tsx b/src/view/com/modals/ProfilePreview.tsx index dad02aa5e..edfbf6a82 100644 --- a/src/view/com/modals/ProfilePreview.tsx +++ b/src/view/com/modals/ProfilePreview.tsx @@ -1,27 +1,81 @@ import React, {useState, useEffect} from 'react' import {ActivityIndicator, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' +import {AppBskyActorDefs, ModerationOpts, moderateProfile} from '@atproto/api' import {ThemedText} from '../util/text/ThemedText' -import {useStores} from 'state/index' -import {ProfileModel} from 'state/models/content/profile' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics/analytics' import {ProfileHeader} from '../profile/ProfileHeader' import {InfoCircleIcon} from 'lib/icons' import {useNavigationState} from '@react-navigation/native' import {s} from 'lib/styles' +import {useModerationOpts} from '#/state/queries/preferences' +import {useProfileQuery} from '#/state/queries/profile' +import {ErrorScreen} from '../util/error/ErrorScreen' +import {CenteredView} from '../util/Views' +import {cleanError} from '#/lib/strings/errors' +import {useProfileShadow} from '#/state/cache/profile-shadow' export const snapPoints = [520, '100%'] -export const Component = observer(function ProfilePreviewImpl({ - did, +export function Component({did}: {did: string}) { + const pal = usePalette('default') + const moderationOpts = useModerationOpts() + const { + data: profile, + error: profileError, + refetch: refetchProfile, + isFetching: isFetchingProfile, + } = useProfileQuery({ + did: did, + }) + + if (isFetchingProfile || !moderationOpts) { + return ( + <CenteredView style={[pal.view, s.flex1]}> + <ProfileHeader + profile={null} + moderation={null} + isProfilePreview={true} + /> + </CenteredView> + ) + } + if (profileError) { + return ( + <ErrorScreen + title="Oops!" + message={cleanError(profileError)} + onPressTryAgain={refetchProfile} + /> + ) + } + if (profile && moderationOpts) { + return <ComponentLoaded profile={profile} moderationOpts={moderationOpts} /> + } + // should never happen + return ( + <ErrorScreen + title="Oops!" + message="Something went wrong and we're not sure what." + onPressTryAgain={refetchProfile} + /> + ) +} + +function ComponentLoaded({ + profile: profileUnshadowed, + moderationOpts, }: { - did: string + profile: AppBskyActorDefs.ProfileViewDetailed + moderationOpts: ModerationOpts }) { - const store = useStores() const pal = usePalette('default') - const [model] = useState(new ProfileModel(store, {actor: did})) + const profile = useProfileShadow(profileUnshadowed) const {screen} = useAnalytics() + const moderation = React.useMemo( + () => moderateProfile(profile, moderationOpts), + [profile, moderationOpts], + ) // track the navigator state to detect if a page-load occurred const navState = useNavigationState(state => state) @@ -30,16 +84,15 @@ export const Component = observer(function ProfilePreviewImpl({ useEffect(() => { screen('Profile:Preview') - model.setup() - }, [model, screen]) + }, [screen]) return ( <View testID="profilePreview" style={[pal.view, s.flex1]}> <View style={[styles.headerWrapper]}> <ProfileHeader - view={model} + profile={profile} + moderation={moderation} hideBackButton - onRefreshAll={() => {}} isProfilePreview /> </View> @@ -59,7 +112,7 @@ export const Component = observer(function ProfilePreviewImpl({ </View> </View> ) -}) +} const styles = StyleSheet.create({ headerWrapper: { diff --git a/src/view/com/modals/Repost.tsx b/src/view/com/modals/Repost.tsx index b1862ecbd..a72da29b4 100644 --- a/src/view/com/modals/Repost.tsx +++ b/src/view/com/modals/Repost.tsx @@ -1,12 +1,14 @@ import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' import LinearGradient from 'react-native-linear-gradient' -import {useStores} from 'state/index' import {s, colors, gradients} from 'lib/styles' import {Text} from '../util/text/Text' import {usePalette} from 'lib/hooks/usePalette' import {RepostIcon} from 'lib/icons' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' export const snapPoints = [250] @@ -20,10 +22,11 @@ export function Component({ isReposted: boolean // TODO: Add author into component }) { - const store = useStores() const pal = usePalette('default') + const {_} = useLingui() + const {closeModal} = useModalControls() const onPress = async () => { - store.shell.closeModal() + closeModal() } return ( @@ -38,7 +41,7 @@ export function Component({ accessibilityHint={isReposted ? 'Remove repost' : 'Repost '}> <RepostIcon strokeWidth={2} size={24} style={s.blue3} /> <Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}> - {!isReposted ? 'Repost' : 'Undo repost'} + <Trans>{!isReposted ? 'Repost' : 'Undo repost'}</Trans> </Text> </TouchableOpacity> <TouchableOpacity @@ -46,11 +49,11 @@ export function Component({ style={[styles.actionBtn]} onPress={onQuote} accessibilityRole="button" - accessibilityLabel="Quote post" + accessibilityLabel={_(msg`Quote post`)} accessibilityHint=""> <FontAwesomeIcon icon="quote-left" size={24} style={s.blue3} /> <Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}> - Quote Post + <Trans>Quote Post</Trans> </Text> </TouchableOpacity> </View> @@ -58,7 +61,7 @@ export function Component({ testID="cancelBtn" onPress={onPress} accessibilityRole="button" - accessibilityLabel="Cancel quote post" + accessibilityLabel={_(msg`Cancel quote post`)} accessibilityHint="" onAccessibilityEscape={onPress}> <LinearGradient @@ -66,7 +69,9 @@ export function Component({ start={{x: 0, y: 0}} end={{x: 1, y: 1}} style={[styles.btn]}> - <Text style={[s.white, s.bold, s.f18]}>Cancel</Text> + <Text style={[s.white, s.bold, s.f18]}> + <Trans>Cancel</Trans> + </Text> </LinearGradient> </TouchableOpacity> </View> diff --git a/src/view/com/modals/SelfLabel.tsx b/src/view/com/modals/SelfLabel.tsx index 820f2895b..092dd2d32 100644 --- a/src/view/com/modals/SelfLabel.tsx +++ b/src/view/com/modals/SelfLabel.tsx @@ -1,8 +1,6 @@ import React, {useState} from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {Text} from '../util/text/Text' -import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' @@ -10,12 +8,15 @@ import {isWeb} from 'platform/detection' import {Button} from '../util/forms/Button' import {SelectableBtn} from '../util/forms/SelectableBtn' import {ScrollView} from 'view/com/modals/util' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' const ADULT_CONTENT_LABELS = ['sexual', 'nudity', 'porn'] export const snapPoints = ['50%'] -export const Component = observer(function Component({ +export function Component({ labels, hasMedia, onChange, @@ -25,9 +26,10 @@ export const Component = observer(function Component({ onChange: (labels: string[]) => void }) { const pal = usePalette('default') - const store = useStores() + const {closeModal} = useModalControls() const {isMobile} = useWebMediaQueries() const [selected, setSelected] = useState(labels) + const {_} = useLingui() const toggleAdultLabel = (label: string) => { const hadLabel = selected.includes(label) @@ -51,7 +53,7 @@ export const Component = observer(function Component({ <View testID="selfLabelModal" style={[pal.view, styles.container]}> <View style={styles.titleSection}> <Text type="title-lg" style={[pal.text, styles.title]}> - Add a content warning + <Trans>Add a content warning</Trans> </Text> </View> @@ -70,7 +72,7 @@ export const Component = observer(function Component({ paddingBottom: 8, }}> <Text type="title" style={pal.text}> - Adult Content + <Trans>Adult Content</Trans> </Text> {hasAdultSelection ? ( <Button @@ -78,7 +80,7 @@ export const Component = observer(function Component({ onPress={removeAdultLabel} style={{paddingTop: 0, paddingBottom: 0, paddingRight: 0}}> <Text type="md" style={pal.link}> - Remove + <Trans>Remove</Trans> </Text> </Button> ) : null} @@ -116,23 +118,25 @@ export const Component = observer(function Component({ <Text style={[pal.text, styles.adultExplainer]}> {selected.includes('sexual') ? ( - <>Pictures meant for adults.</> + <Trans>Pictures meant for adults.</Trans> ) : selected.includes('nudity') ? ( - <>Artistic or non-erotic nudity.</> + <Trans>Artistic or non-erotic nudity.</Trans> ) : selected.includes('porn') ? ( - <>Sexual activity or erotic nudity.</> + <Trans>Sexual activity or erotic nudity.</Trans> ) : ( - <>If none are selected, suitable for all ages.</> + <Trans>If none are selected, suitable for all ages.</Trans> )} </Text> </> ) : ( <View> <Text style={[pal.textLight]}> - <Text type="md-bold" style={[pal.textLight]}> - Not Applicable + <Text type="md-bold" style={[pal.textLight, s.mr5]}> + <Trans>Not Applicable.</Trans> </Text> - . This warning is only available for posts with media attached. + <Trans> + This warning is only available for posts with media attached. + </Trans> </Text> </View> )} @@ -143,18 +147,20 @@ export const Component = observer(function Component({ <TouchableOpacity testID="confirmBtn" onPress={() => { - store.shell.closeModal() + closeModal() }} style={styles.btn} accessibilityRole="button" - accessibilityLabel="Confirm" + accessibilityLabel={_(msg`Confirm`)} accessibilityHint=""> - <Text style={[s.white, s.bold, s.f18]}>Done</Text> + <Text style={[s.white, s.bold, s.f18]}> + <Trans>Done</Trans> + </Text> </TouchableOpacity> </View> </View> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/com/modals/ServerInput.tsx b/src/view/com/modals/ServerInput.tsx index 13b21fe22..b30293859 100644 --- a/src/view/com/modals/ServerInput.tsx +++ b/src/view/com/modals/ServerInput.tsx @@ -6,33 +6,36 @@ import { } from '@fortawesome/react-native-fontawesome' import {ScrollView, TextInput} from './util' import {Text} from '../util/text/Text' -import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' -import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'state/index' +import {LOCAL_DEV_SERVICE, STAGING_SERVICE, PROD_SERVICE} from 'lib/constants' import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' export const snapPoints = ['80%'] export function Component({onSelect}: {onSelect: (url: string) => void}) { const theme = useTheme() const pal = usePalette('default') - const store = useStores() const [customUrl, setCustomUrl] = useState<string>('') + const {_} = useLingui() + const {closeModal} = useModalControls() const doSelect = (url: string) => { if (!url.startsWith('http://') && !url.startsWith('https://')) { url = `https://${url}` } - store.shell.closeModal() + closeModal() onSelect(url) } return ( <View style={[pal.view, s.flex1]} testID="serverInputModal"> <Text type="2xl-bold" style={[pal.text, s.textCenter]}> - Choose Service + <Trans>Choose Service</Trans> </Text> <ScrollView style={styles.inner}> <View style={styles.group}> @@ -43,7 +46,9 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) { style={styles.btn} onPress={() => doSelect(LOCAL_DEV_SERVICE)} accessibilityRole="button"> - <Text style={styles.btnText}>Local dev server</Text> + <Text style={styles.btnText}> + <Trans>Local dev server</Trans> + </Text> <FontAwesomeIcon icon="arrow-right" style={s.white as FontAwesomeIconStyle} @@ -53,7 +58,9 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) { style={styles.btn} onPress={() => doSelect(STAGING_SERVICE)} accessibilityRole="button"> - <Text style={styles.btnText}>Staging</Text> + <Text style={styles.btnText}> + <Trans>Staging</Trans> + </Text> <FontAwesomeIcon icon="arrow-right" style={s.white as FontAwesomeIconStyle} @@ -65,9 +72,11 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) { style={styles.btn} onPress={() => doSelect(PROD_SERVICE)} accessibilityRole="button" - accessibilityLabel="Select Bluesky Social" + accessibilityLabel={_(msg`Select Bluesky Social`)} accessibilityHint="Sets Bluesky Social as your service provider"> - <Text style={styles.btnText}>Bluesky.Social</Text> + <Text style={styles.btnText}> + <Trans>Bluesky.Social</Trans> + </Text> <FontAwesomeIcon icon="arrow-right" style={s.white as FontAwesomeIconStyle} @@ -75,7 +84,9 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) { </TouchableOpacity> </View> <View style={styles.group}> - <Text style={[pal.text, styles.label]}>Other service</Text> + <Text style={[pal.text, styles.label]}> + <Trans>Other service</Trans> + </Text> <View style={s.flexRow}> <TextInput testID="customServerTextInput" @@ -88,7 +99,7 @@ export function Component({onSelect}: {onSelect: (url: string) => void}) { keyboardAppearance={theme.colorScheme} value={customUrl} onChangeText={setCustomUrl} - accessibilityLabel="Custom domain" + accessibilityLabel={_(msg`Custom domain`)} // TODO: Simplify this wording further to be understandable by everyone accessibilityHint="Use your domain as your Bluesky client service provider" /> diff --git a/src/view/com/modals/SwitchAccount.tsx b/src/view/com/modals/SwitchAccount.tsx index d5fa32692..38e1ce1e0 100644 --- a/src/view/com/modals/SwitchAccount.tsx +++ b/src/view/com/modals/SwitchAccount.tsx @@ -6,7 +6,6 @@ import { View, } from 'react-native' import {Text} from '../util/text/Text' -import {useStores} from 'state/index' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics/analytics' @@ -17,88 +16,114 @@ import {Link} from '../util/Link' import {makeProfileLink} from 'lib/routes/links' import {BottomSheetScrollView} from '@gorhom/bottom-sheet' import {Haptics} from 'lib/haptics' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useSession, useSessionApi, SessionAccount} from '#/state/session' +import {useProfileQuery} from '#/state/queries/profile' export const snapPoints = ['40%', '90%'] -export function Component({}: {}) { +function SwitchAccountCard({account}: {account: SessionAccount}) { const pal = usePalette('default') + const {_} = useLingui() const {track} = useAnalytics() + const {isSwitchingAccounts, currentAccount} = useSession() + const {logout} = useSessionApi() + const {data: profile} = useProfileQuery({did: account.did}) + const isCurrentAccount = account.did === currentAccount?.did + const {onPressSwitchAccount} = useAccountSwitcher() + + const onPressSignout = React.useCallback(() => { + track('Settings:SignOutButtonClicked') + logout() + }, [track, logout]) - const store = useStores() - const [isSwitching, _, onPressSwitchAccount] = useAccountSwitcher() + const contents = ( + <View style={[pal.view, styles.linkCard]}> + <View style={styles.avi}> + <UserAvatar size={40} avatar={profile?.avatar} /> + </View> + <View style={[s.flex1]}> + <Text type="md-bold" style={pal.text} numberOfLines={1}> + {profile?.displayName || account?.handle} + </Text> + <Text type="sm" style={pal.textLight} numberOfLines={1}> + {account?.handle} + </Text> + </View> + + {isCurrentAccount ? ( + <TouchableOpacity + testID="signOutBtn" + onPress={isSwitchingAccounts ? undefined : onPressSignout} + accessibilityRole="button" + accessibilityLabel={_(msg`Sign out`)} + accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`}> + <Text type="lg" style={pal.link}> + <Trans>Sign out</Trans> + </Text> + </TouchableOpacity> + ) : ( + <AccountDropdownBtn account={account} /> + )} + </View> + ) + + return isCurrentAccount ? ( + <Link + href={makeProfileLink({ + did: currentAccount.did, + handle: currentAccount.handle, + })} + title={_(msg`Your profile`)} + noFeedback> + {contents} + </Link> + ) : ( + <TouchableOpacity + testID={`switchToAccountBtn-${account.handle}`} + key={account.did} + style={[isSwitchingAccounts && styles.dimmed]} + onPress={ + isSwitchingAccounts ? undefined : () => onPressSwitchAccount(account) + } + accessibilityRole="button" + accessibilityLabel={`Switch to ${account.handle}`} + accessibilityHint="Switches the account you are logged in to"> + {contents} + </TouchableOpacity> + ) +} + +export function Component({}: {}) { + const pal = usePalette('default') + const {isSwitchingAccounts, currentAccount, accounts} = useSession() React.useEffect(() => { Haptics.default() }) - const onPressSignout = React.useCallback(() => { - track('Settings:SignOutButtonClicked') - store.session.logout() - }, [track, store]) - return ( <BottomSheetScrollView style={[styles.container, pal.view]} contentContainerStyle={[styles.innerContainer, pal.view]}> <Text type="title-xl" style={[styles.title, pal.text]}> - Switch Account + <Trans>Switch Account</Trans> </Text> - {isSwitching ? ( + + {isSwitchingAccounts || !currentAccount ? ( <View style={[pal.view, styles.linkCard]}> <ActivityIndicator /> </View> ) : ( - <Link href={makeProfileLink(store.me)} title="Your profile" noFeedback> - <View style={[pal.view, styles.linkCard]}> - <View style={styles.avi}> - <UserAvatar size={40} avatar={store.me.avatar} /> - </View> - <View style={[s.flex1]}> - <Text type="md-bold" style={pal.text} numberOfLines={1}> - {store.me.displayName || store.me.handle} - </Text> - <Text type="sm" style={pal.textLight} numberOfLines={1}> - {store.me.handle} - </Text> - </View> - <TouchableOpacity - testID="signOutBtn" - onPress={isSwitching ? undefined : onPressSignout} - accessibilityRole="button" - accessibilityLabel="Sign out" - accessibilityHint={`Signs ${store.me.displayName} out of Bluesky`}> - <Text type="lg" style={pal.link}> - Sign out - </Text> - </TouchableOpacity> - </View> - </Link> + <SwitchAccountCard account={currentAccount} /> )} - {store.session.switchableAccounts.map(account => ( - <TouchableOpacity - testID={`switchToAccountBtn-${account.handle}`} - key={account.did} - style={[pal.view, styles.linkCard, isSwitching && styles.dimmed]} - onPress={ - isSwitching ? undefined : () => onPressSwitchAccount(account) - } - accessibilityRole="button" - accessibilityLabel={`Switch to ${account.handle}`} - accessibilityHint="Switches the account you are logged in to"> - <View style={styles.avi}> - <UserAvatar size={40} avatar={account.aviUrl} /> - </View> - <View style={[s.flex1]}> - <Text type="md-bold" style={pal.text}> - {account.displayName || account.handle} - </Text> - <Text type="sm" style={pal.textLight}> - {account.handle} - </Text> - </View> - <AccountDropdownBtn handle={account.handle} /> - </TouchableOpacity> - ))} + + {accounts + .filter(a => a.did !== currentAccount?.did) + .map(account => ( + <SwitchAccountCard key={account.did} account={account} /> + ))} </BottomSheetScrollView> ) } diff --git a/src/view/com/modals/UserAddRemoveLists.tsx b/src/view/com/modals/UserAddRemoveLists.tsx index aeec2e87f..8c3dc8bb7 100644 --- a/src/view/com/modals/UserAddRemoveLists.tsx +++ b/src/view/com/modals/UserAddRemoveLists.tsx @@ -1,30 +1,32 @@ import React, {useCallback} from 'react' -import {observer} from 'mobx-react-lite' -import {ActivityIndicator, Pressable, StyleSheet, View} from 'react-native' +import {ActivityIndicator, StyleSheet, View} from 'react-native' import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' -import {ListsList} from '../lists/ListsList' -import {ListsListModel} from 'state/models/lists/lists-list' -import {ListMembershipModel} from 'state/models/content/list-membership' +import {MyLists} from '../lists/MyLists' import {Button} from '../util/forms/Button' import * as Toast from '../util/Toast' -import {useStores} from 'state/index' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {isWeb, isAndroid} from 'platform/detection' -import isEqual from 'lodash.isequal' -import {logger} from '#/logger' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import { + useDangerousListMembershipsQuery, + getMembership, + ListMembersip, + useListMembershipAddMutation, + useListMembershipRemoveMutation, +} from '#/state/queries/list-memberships' +import {cleanError} from '#/lib/strings/errors' +import {useSession} from '#/state/session' export const snapPoints = ['fullscreen'] -export const Component = observer(function UserAddRemoveListsImpl({ +export function Component({ subject, displayName, onAdd, @@ -35,191 +37,161 @@ export const Component = observer(function UserAddRemoveListsImpl({ onAdd?: (listUri: string) => void onRemove?: (listUri: string) => void }) { - const store = useStores() + const {closeModal} = useModalControls() const pal = usePalette('default') - const palPrimary = usePalette('primary') - const palInverted = usePalette('inverted') - const [originalSelections, setOriginalSelections] = React.useState<string[]>( - [], - ) - const [selected, setSelected] = React.useState<string[]>([]) - const [membershipsLoaded, setMembershipsLoaded] = React.useState(false) + const {_} = useLingui() + const {data: memberships} = useDangerousListMembershipsQuery() - const listsList: ListsListModel = React.useMemo( - () => new ListsListModel(store, store.me.did), - [store], - ) - const memberships: ListMembershipModel = React.useMemo( - () => new ListMembershipModel(store, subject), - [store, subject], - ) - React.useEffect(() => { - listsList.refresh() - memberships.fetch().then( - () => { - const ids = memberships.memberships.map(m => m.value.list) - setOriginalSelections(ids) - setSelected(ids) - setMembershipsLoaded(true) - }, - err => { - logger.error('Failed to fetch memberships', {error: err}) - }, - ) - }, [memberships, listsList, store, setSelected, setMembershipsLoaded]) - - const onPressCancel = useCallback(() => { - store.shell.closeModal() - }, [store]) - - const onPressSave = useCallback(async () => { - let changes - try { - changes = await memberships.updateTo(selected) - } catch (err) { - logger.error('Failed to update memberships', {error: err}) - return - } - Toast.show('Lists updated') - for (const uri of changes.added) { - onAdd?.(uri) - } - for (const uri of changes.removed) { - onRemove?.(uri) - } - store.shell.closeModal() - }, [store, selected, memberships, onAdd, onRemove]) - - const onToggleSelected = useCallback( - (uri: string) => { - if (selected.includes(uri)) { - setSelected(selected.filter(uri2 => uri2 !== uri)) - } else { - setSelected([...selected, uri]) - } - }, - [selected, setSelected], - ) - - const renderItem = useCallback( - (list: GraphDefs.ListView, index: number) => { - const isSelected = selected.includes(list.uri) - return ( - <Pressable - testID={`toggleBtn-${list.name}`} - style={[ - styles.listItem, - pal.border, - { - opacity: membershipsLoaded ? 1 : 0.5, - borderTopWidth: index === 0 ? 0 : 1, - }, - ]} - accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${ - list.name - }`} - accessibilityHint="" - disabled={!membershipsLoaded} - onPress={() => onToggleSelected(list.uri)}> - <View style={styles.listItemAvi}> - <UserAvatar size={40} avatar={list.avatar} /> - </View> - <View style={styles.listItemContent}> - <Text - type="lg" - style={[s.bold, pal.text]} - numberOfLines={1} - lineHeight={1.2}> - {sanitizeDisplayName(list.name)} - </Text> - <Text type="md" style={[pal.textLight]} numberOfLines={1}> - {list.purpose === 'app.bsky.graph.defs#curatelist' && - 'User list '} - {list.purpose === 'app.bsky.graph.defs#modlist' && - 'Moderation list '} - by{' '} - {list.creator.did === store.me.did - ? 'you' - : sanitizeHandle(list.creator.handle, '@')} - </Text> - </View> - {membershipsLoaded && ( - <View - style={ - isSelected - ? [styles.checkbox, palPrimary.border, palPrimary.view] - : [styles.checkbox, pal.borderDark] - }> - {isSelected && ( - <FontAwesomeIcon - icon="check" - style={palInverted.text as FontAwesomeIconStyle} - /> - )} - </View> - )} - </Pressable> - ) - }, - [ - pal, - palPrimary, - palInverted, - onToggleSelected, - selected, - store.me.did, - membershipsLoaded, - ], - ) - - // Only show changes button if there are some items on the list to choose from AND user has made changes in selection - const canSaveChanges = - !listsList.isEmpty && !isEqual(selected, originalSelections) + const onPressDone = useCallback(() => { + closeModal() + }, [closeModal]) return ( <View testID="userAddRemoveListsModal" style={s.hContentRegion}> <Text style={[styles.title, pal.text]}> - Update {displayName} in Lists + <Trans>Update {displayName} in Lists</Trans> </Text> - <ListsList - listsList={listsList} + <MyLists + filter="all" inline - renderItem={renderItem} + renderItem={(list, index) => ( + <ListItem + index={index} + list={list} + memberships={memberships} + subject={subject} + onAdd={onAdd} + onRemove={onRemove} + /> + )} style={[styles.list, pal.border]} /> <View style={[styles.btns, pal.border]}> <Button - testID="cancelBtn" + testID="doneBtn" type="default" - onPress={onPressCancel} + onPress={onPressDone} style={styles.footerBtn} - accessibilityLabel="Cancel" + accessibilityLabel={_(msg`Done`)} accessibilityHint="" - onAccessibilityEscape={onPressCancel} - label="Cancel" + onAccessibilityEscape={onPressDone} + label="Done" /> - {canSaveChanges && ( + </View> + </View> + ) +} + +function ListItem({ + index, + list, + memberships, + subject, + onAdd, + onRemove, +}: { + index: number + list: GraphDefs.ListView + memberships: ListMembersip[] | undefined + subject: string + onAdd?: (listUri: string) => void + onRemove?: (listUri: string) => void +}) { + const pal = usePalette('default') + const {_} = useLingui() + const {currentAccount} = useSession() + const [isProcessing, setIsProcessing] = React.useState(false) + const membership = React.useMemo( + () => getMembership(memberships, list.uri, subject), + [memberships, list.uri, subject], + ) + const listMembershipAddMutation = useListMembershipAddMutation() + const listMembershipRemoveMutation = useListMembershipRemoveMutation() + + const onToggleMembership = useCallback(async () => { + if (typeof membership === 'undefined') { + return + } + setIsProcessing(true) + try { + if (membership === false) { + await listMembershipAddMutation.mutateAsync({ + listUri: list.uri, + actorDid: subject, + }) + Toast.show(_(msg`Added to list`)) + onAdd?.(list.uri) + } else { + await listMembershipRemoveMutation.mutateAsync({ + listUri: list.uri, + actorDid: subject, + membershipUri: membership, + }) + Toast.show(_(msg`Removed from list`)) + onRemove?.(list.uri) + } + } catch (e) { + Toast.show(cleanError(e)) + } finally { + setIsProcessing(false) + } + }, [ + _, + list, + subject, + membership, + setIsProcessing, + onAdd, + onRemove, + listMembershipAddMutation, + listMembershipRemoveMutation, + ]) + + return ( + <View + testID={`toggleBtn-${list.name}`} + style={[ + styles.listItem, + pal.border, + { + borderTopWidth: index === 0 ? 0 : 1, + }, + ]}> + <View style={styles.listItemAvi}> + <UserAvatar size={40} avatar={list.avatar} /> + </View> + <View style={styles.listItemContent}> + <Text + type="lg" + style={[s.bold, pal.text]} + numberOfLines={1} + lineHeight={1.2}> + {sanitizeDisplayName(list.name)} + </Text> + <Text type="md" style={[pal.textLight]} numberOfLines={1}> + {list.purpose === 'app.bsky.graph.defs#curatelist' && 'User list '} + {list.purpose === 'app.bsky.graph.defs#modlist' && 'Moderation list '} + by{' '} + {list.creator.did === currentAccount?.did + ? 'you' + : sanitizeHandle(list.creator.handle, '@')} + </Text> + </View> + <View> + {isProcessing || typeof membership === 'undefined' ? ( + <ActivityIndicator /> + ) : ( <Button - testID="saveBtn" - type="primary" - onPress={onPressSave} - style={styles.footerBtn} - accessibilityLabel="Save changes" - accessibilityHint="" - onAccessibilityEscape={onPressSave} - label="Save Changes" + testID={`user-${subject}-addBtn`} + type="default" + label={membership === false ? _(msg`Add`) : _(msg`Remove`)} + onPress={onToggleMembership} /> )} - - {(listsList.isLoading || !membershipsLoaded) && ( - <View style={styles.loadingContainer}> - <ActivityIndicator /> - </View> - )} </View> </View> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/com/modals/VerifyEmail.tsx b/src/view/com/modals/VerifyEmail.tsx index 9fe8811b0..4376a3e45 100644 --- a/src/view/com/modals/VerifyEmail.tsx +++ b/src/view/com/modals/VerifyEmail.tsx @@ -8,18 +8,20 @@ import { } from 'react-native' import {Svg, Circle, Path} from 'react-native-svg' import {ScrollView, TextInput} from './util' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Text} from '../util/text/Text' import {Button} from '../util/forms/Button' import {ErrorMessage} from '../util/error/ErrorMessage' import * as Toast from '../util/Toast' -import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {isWeb} from 'platform/detection' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {cleanError} from 'lib/strings/errors' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import {useSession, useSessionApi, getAgent} from '#/state/session' export const snapPoints = ['90%'] @@ -29,13 +31,11 @@ enum Stages { ConfirmCode, } -export const Component = observer(function Component({ - showReminder, -}: { - showReminder?: boolean -}) { +export function Component({showReminder}: {showReminder?: boolean}) { const pal = usePalette('default') - const store = useStores() + const {currentAccount} = useSession() + const {updateCurrentAccount} = useSessionApi() + const {_} = useLingui() const [stage, setStage] = useState<Stages>( showReminder ? Stages.Reminder : Stages.Email, ) @@ -43,12 +43,13 @@ export const Component = observer(function Component({ const [isProcessing, setIsProcessing] = useState<boolean>(false) const [error, setError] = useState<string>('') const {isMobile} = useWebMediaQueries() + const {openModal, closeModal} = useModalControls() const onSendEmail = async () => { setError('') setIsProcessing(true) try { - await store.agent.com.atproto.server.requestEmailConfirmation() + await getAgent().com.atproto.server.requestEmailConfirmation() setStage(Stages.ConfirmCode) } catch (e) { setError(cleanError(String(e))) @@ -61,13 +62,13 @@ export const Component = observer(function Component({ setError('') setIsProcessing(true) try { - await store.agent.com.atproto.server.confirmEmail({ - email: (store.session.currentSession?.email || '').trim(), + await getAgent().com.atproto.server.confirmEmail({ + email: (currentAccount?.email || '').trim(), token: confirmationCode.trim(), }) - store.session.updateLocalAccountData({emailConfirmed: true}) + updateCurrentAccount({emailConfirmed: true}) Toast.show('Email verified') - store.shell.closeModal() + closeModal() } catch (e) { setError(cleanError(String(e))) } finally { @@ -76,8 +77,8 @@ export const Component = observer(function Component({ } const onEmailIncorrect = () => { - store.shell.closeModal() - store.shell.openModal({name: 'change-email'}) + closeModal() + openModal({name: 'change-email'}) } return ( @@ -96,21 +97,20 @@ export const Component = observer(function Component({ <Text type="lg" style={[pal.textLight, {marginBottom: 10}]}> {stage === Stages.Reminder ? ( - <> + <Trans> Your email has not yet been verified. This is an important security step which we recommend. - </> + </Trans> ) : stage === Stages.Email ? ( - <> + <Trans> This is important in case you ever need to change your email or reset your password. - </> + </Trans> ) : stage === Stages.ConfirmCode ? ( - <> - An email has been sent to{' '} - {store.session.currentSession?.email || ''}. It includes a - confirmation code which you can enter below. - </> + <Trans> + An email has been sent to {currentAccount?.email || ''}. It + includes a confirmation code which you can enter below. + </Trans> ) : ( '' )} @@ -125,12 +125,12 @@ export const Component = observer(function Component({ size={16} /> <Text type="xl-medium" style={[pal.text, s.flex1, {minWidth: 0}]}> - {store.session.currentSession?.email || ''} + {currentAccount?.email || ''} </Text> </View> <Pressable accessibilityRole="link" - accessibilityLabel="Change my email" + accessibilityLabel={_(msg`Change my email`)} accessibilityHint="" onPress={onEmailIncorrect} style={styles.changeEmailLink}> @@ -148,7 +148,7 @@ export const Component = observer(function Component({ value={confirmationCode} onChangeText={setConfirmationCode} accessible={true} - accessibilityLabel="Confirmation code" + accessibilityLabel={_(msg`Confirmation code`)} accessibilityHint="" autoCapitalize="none" autoComplete="off" @@ -172,7 +172,7 @@ export const Component = observer(function Component({ testID="getStartedBtn" type="primary" onPress={() => setStage(Stages.Email)} - accessibilityLabel="Get Started" + accessibilityLabel={_(msg`Get Started`)} accessibilityHint="" label="Get Started" labelContainerStyle={{justifyContent: 'center', padding: 4}} @@ -185,7 +185,7 @@ export const Component = observer(function Component({ testID="sendEmailBtn" type="primary" onPress={onSendEmail} - accessibilityLabel="Send Confirmation Email" + accessibilityLabel={_(msg`Send Confirmation Email`)} accessibilityHint="" label="Send Confirmation Email" labelContainerStyle={{ @@ -197,7 +197,7 @@ export const Component = observer(function Component({ <Button testID="haveCodeBtn" type="default" - accessibilityLabel="I have a code" + accessibilityLabel={_(msg`I have a code`)} accessibilityHint="" label="I have a confirmation code" labelContainerStyle={{ @@ -214,7 +214,7 @@ export const Component = observer(function Component({ testID="confirmBtn" type="primary" onPress={onConfirm} - accessibilityLabel="Confirm" + accessibilityLabel={_(msg`Confirm`)} accessibilityHint="" label="Confirm" labelContainerStyle={{justifyContent: 'center', padding: 4}} @@ -224,7 +224,9 @@ export const Component = observer(function Component({ <Button testID="cancelBtn" type="default" - onPress={() => store.shell.closeModal()} + onPress={() => { + closeModal() + }} accessibilityLabel={ stage === Stages.Reminder ? 'Not right now' : 'Cancel' } @@ -239,7 +241,7 @@ export const Component = observer(function Component({ </ScrollView> </SafeAreaView> ) -}) +} function ReminderIllustration() { const pal = usePalette('default') diff --git a/src/view/com/modals/Waitlist.tsx b/src/view/com/modals/Waitlist.tsx index 0fb371fe4..a31545c0a 100644 --- a/src/view/com/modals/Waitlist.tsx +++ b/src/view/com/modals/Waitlist.tsx @@ -12,19 +12,22 @@ import { } from '@fortawesome/react-native-fontawesome' import LinearGradient from 'react-native-linear-gradient' import {Text} from '../util/text/Text' -import {useStores} from 'state/index' import {s, gradients} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' import {ErrorMessage} from '../util/error/ErrorMessage' import {cleanError} from 'lib/strings/errors' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' export const snapPoints = ['80%'] export function Component({}: {}) { const pal = usePalette('default') const theme = useTheme() - const store = useStores() + const {_} = useLingui() + const {closeModal} = useModalControls() const [email, setEmail] = React.useState<string>('') const [isEmailSent, setIsEmailSent] = React.useState<boolean>(false) const [isProcessing, setIsProcessing] = React.useState<boolean>(false) @@ -54,19 +57,21 @@ export function Component({}: {}) { setIsProcessing(false) } const onCancel = () => { - store.shell.closeModal() + closeModal() } return ( <View style={[styles.container, pal.view]}> <View style={[styles.innerContainer, pal.view]}> <Text type="title-xl" style={[styles.title, pal.text]}> - Join the waitlist + <Trans>Join the waitlist</Trans> </Text> <Text type="lg" style={[styles.description, pal.text]}> - Bluesky uses invites to build a healthier community. If you don't know - anybody with an invite, you can sign up for the waitlist and we'll - send one soon. + <Trans> + Bluesky uses invites to build a healthier community. If you don't + know anybody with an invite, you can sign up for the waitlist and + we'll send one soon. + </Trans> </Text> <TextInput style={[styles.textInput, pal.borderDark, pal.text, s.mb10, s.mt10]} @@ -80,7 +85,7 @@ export function Component({}: {}) { onSubmitEditing={onPressSignup} enterKeyHint="done" accessible={true} - accessibilityLabel="Email" + accessibilityLabel={_(msg`Email`)} accessibilityHint="Input your email to get on the Bluesky waitlist" /> {error ? ( @@ -99,7 +104,9 @@ export function Component({}: {}) { style={pal.text as FontAwesomeIconStyle} /> <Text style={[s.ml10, pal.text]}> - Your email has been saved! We'll be in touch soon. + <Trans> + Your email has been saved! We'll be in touch soon. + </Trans> </Text> </View> ) : ( @@ -114,7 +121,7 @@ export function Component({}: {}) { end={{x: 1, y: 1}} style={[styles.btn]}> <Text type="button-lg" style={[s.white, s.bold]}> - Join Waitlist + <Trans>Join Waitlist</Trans> </Text> </LinearGradient> </TouchableOpacity> @@ -122,11 +129,11 @@ export function Component({}: {}) { style={[styles.btn, s.mt10]} onPress={onCancel} accessibilityRole="button" - accessibilityLabel="Cancel waitlist signup" + accessibilityLabel={_(msg`Cancel waitlist signup`)} accessibilityHint={`Exits signing up for waitlist with ${email}`} onAccessibilityEscape={onCancel}> <Text type="button-lg" style={pal.textLight}> - Cancel + <Trans>Cancel</Trans> </Text> </TouchableOpacity> </> diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx index 8e35201d1..6f094a1fd 100644 --- a/src/view/com/modals/crop-image/CropImage.web.tsx +++ b/src/view/com/modals/crop-image/CropImage.web.tsx @@ -7,10 +7,12 @@ import {Text} from 'view/com/util/text/Text' import {Dimensions} from 'lib/media/types' import {getDataUriSize} from 'lib/media/util' import {s, gradients} from 'lib/styles' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {SquareIcon, RectWideIcon, RectTallIcon} from 'lib/icons' import {Image as RNImage} from 'react-native-image-crop-picker' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' enum AspectRatio { Square = 'square', @@ -33,8 +35,9 @@ export function Component({ uri: string onSelect: (img?: RNImage) => void }) { - const store = useStores() + const {closeModal} = useModalControls() const pal = usePalette('default') + const {_} = useLingui() const [as, setAs] = React.useState<AspectRatio>(AspectRatio.Square) const [scale, setScale] = React.useState<number>(1) const editorRef = React.useRef<ImageEditor>(null) @@ -43,7 +46,7 @@ export function Component({ const onPressCancel = () => { onSelect(undefined) - store.shell.closeModal() + closeModal() } const onPressDone = () => { const canvas = editorRef.current?.getImageScaledToCanvas() @@ -59,7 +62,7 @@ export function Component({ } else { onSelect(undefined) } - store.shell.closeModal() + closeModal() } let cropperStyle @@ -96,7 +99,7 @@ export function Component({ <TouchableOpacity onPress={doSetAs(AspectRatio.Wide)} accessibilityRole="button" - accessibilityLabel="Wide" + accessibilityLabel={_(msg`Wide`)} accessibilityHint="Sets image aspect ratio to wide"> <RectWideIcon size={24} @@ -106,7 +109,7 @@ export function Component({ <TouchableOpacity onPress={doSetAs(AspectRatio.Tall)} accessibilityRole="button" - accessibilityLabel="Tall" + accessibilityLabel={_(msg`Tall`)} accessibilityHint="Sets image aspect ratio to tall"> <RectTallIcon size={24} @@ -116,7 +119,7 @@ export function Component({ <TouchableOpacity onPress={doSetAs(AspectRatio.Square)} accessibilityRole="button" - accessibilityLabel="Square" + accessibilityLabel={_(msg`Square`)} accessibilityHint="Sets image aspect ratio to square"> <SquareIcon size={24} @@ -128,7 +131,7 @@ export function Component({ <TouchableOpacity onPress={onPressCancel} accessibilityRole="button" - accessibilityLabel="Cancel image crop" + accessibilityLabel={_(msg`Cancel image crop`)} accessibilityHint="Exits image cropping process"> <Text type="xl" style={pal.link}> Cancel @@ -138,7 +141,7 @@ export function Component({ <TouchableOpacity onPress={onPressDone} accessibilityRole="button" - accessibilityLabel="Save image crop" + accessibilityLabel={_(msg`Save image crop`)} accessibilityHint="Saves image crop settings"> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} @@ -146,7 +149,7 @@ export function Component({ end={{x: 1, y: 1}} style={[styles.btn]}> <Text type="xl-medium" style={s.white}> - Done + <Trans>Done</Trans> </Text> </LinearGradient> </TouchableOpacity> diff --git a/src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx b/src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx index c2d0c222a..91e11a19c 100644 --- a/src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx +++ b/src/view/com/modals/lang-settings/ConfirmLanguagesButton.tsx @@ -4,6 +4,8 @@ import LinearGradient from 'react-native-linear-gradient' import {s, colors, gradients} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' export const ConfirmLanguagesButton = ({ onPress, @@ -13,6 +15,7 @@ export const ConfirmLanguagesButton = ({ extraText?: string }) => { const pal = usePalette('default') + const {_} = useLingui() const {isMobile} = useWebMediaQueries() return ( <View @@ -28,14 +31,16 @@ export const ConfirmLanguagesButton = ({ testID="confirmContentLanguagesBtn" onPress={onPress} accessibilityRole="button" - accessibilityLabel="Confirm content language settings" + accessibilityLabel={_(msg`Confirm content language settings`)} accessibilityHint=""> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} end={{x: 1, y: 1}} style={[styles.btn]}> - <Text style={[s.white, s.bold, s.f18]}>Done{extraText}</Text> + <Text style={[s.white, s.bold, s.f18]}> + <Trans>Done{extraText}</Trans> + </Text> </LinearGradient> </Pressable> </View> diff --git a/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx b/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx index 910522f90..b8c125b65 100644 --- a/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx +++ b/src/view/com/modals/lang-settings/ContentLanguagesSettings.tsx @@ -1,7 +1,6 @@ import React from 'react' import {StyleSheet, View} from 'react-native' import {ScrollView} from '../util' -import {useStores} from 'state/index' import {Text} from '../../util/text/Text' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' @@ -9,16 +8,24 @@ import {deviceLocales} from 'platform/detection' import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages' import {LanguageToggle} from './LanguageToggle' import {ConfirmLanguagesButton} from './ConfirmLanguagesButton' +import {Trans} from '@lingui/macro' +import {useModalControls} from '#/state/modals' +import { + useLanguagePrefs, + useLanguagePrefsApi, +} from '#/state/preferences/languages' export const snapPoints = ['100%'] export function Component({}: {}) { - const store = useStores() + const {closeModal} = useModalControls() + const langPrefs = useLanguagePrefs() + const setLangPrefs = useLanguagePrefsApi() const pal = usePalette('default') const {isMobile} = useWebMediaQueries() const onPressDone = React.useCallback(() => { - store.shell.closeModal() - }, [store]) + closeModal() + }, [closeModal]) const languages = React.useMemo(() => { const langs = LANGUAGES.filter( @@ -29,23 +36,23 @@ export function Component({}: {}) { // sort so that device & selected languages are on top, then alphabetically langs.sort((a, b) => { const hasA = - store.preferences.hasContentLanguage(a.code2) || + langPrefs.contentLanguages.includes(a.code2) || deviceLocales.includes(a.code2) const hasB = - store.preferences.hasContentLanguage(b.code2) || + langPrefs.contentLanguages.includes(b.code2) || deviceLocales.includes(b.code2) if (hasA === hasB) return a.name.localeCompare(b.name) if (hasA) return -1 return 1 }) return langs - }, [store]) + }, [langPrefs]) const onPress = React.useCallback( (code2: string) => { - store.preferences.toggleContentLanguage(code2) + setLangPrefs.toggleContentLanguage(code2) }, - [store], + [setLangPrefs], ) return ( @@ -63,12 +70,16 @@ export function Component({}: {}) { maxHeight: '90vh', }, ]}> - <Text style={[pal.text, styles.title]}>Content Languages</Text> + <Text style={[pal.text, styles.title]}> + <Trans>Content Languages</Trans> + </Text> <Text style={[pal.text, styles.description]}> - Which languages would you like to see in your algorithmic feeds? + <Trans> + Which languages would you like to see in your algorithmic feeds? + </Trans> </Text> <Text style={[pal.textLight, styles.description]}> - Leave them all unchecked to see any language. + <Trans>Leave them all unchecked to see any language.</Trans> </Text> <ScrollView style={styles.scrollContainer}> {languages.map(lang => ( diff --git a/src/view/com/modals/lang-settings/LanguageToggle.tsx b/src/view/com/modals/lang-settings/LanguageToggle.tsx index 187b46e8c..45b100f20 100644 --- a/src/view/com/modals/lang-settings/LanguageToggle.tsx +++ b/src/view/com/modals/lang-settings/LanguageToggle.tsx @@ -1,11 +1,10 @@ import React from 'react' import {StyleSheet} from 'react-native' import {usePalette} from 'lib/hooks/usePalette' -import {observer} from 'mobx-react-lite' import {ToggleButton} from 'view/com/util/forms/ToggleButton' -import {useStores} from 'state/index' +import {useLanguagePrefs, toPostLanguages} from '#/state/preferences/languages' -export const LanguageToggle = observer(function LanguageToggleImpl({ +export function LanguageToggle({ code2, name, onPress, @@ -17,17 +16,17 @@ export const LanguageToggle = observer(function LanguageToggleImpl({ langType: 'contentLanguages' | 'postLanguages' }) { const pal = usePalette('default') - const store = useStores() + const langPrefs = useLanguagePrefs() - const isSelected = store.preferences[langType].includes(code2) + const values = + langType === 'contentLanguages' + ? langPrefs.contentLanguages + : toPostLanguages(langPrefs.postLanguage) + const isSelected = values.includes(code2) // enforce a max of 3 selections for post languages let isDisabled = false - if ( - langType === 'postLanguages' && - store.preferences[langType].length >= 3 && - !isSelected - ) { + if (langType === 'postLanguages' && values.length >= 3 && !isSelected) { isDisabled = true } @@ -39,7 +38,7 @@ export const LanguageToggle = observer(function LanguageToggleImpl({ style={[pal.border, styles.languageToggle, isDisabled && styles.dimmed]} /> ) -}) +} const styles = StyleSheet.create({ languageToggle: { diff --git a/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx b/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx index d74d884cc..05cfb8115 100644 --- a/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx +++ b/src/view/com/modals/lang-settings/PostLanguagesSettings.tsx @@ -1,8 +1,6 @@ import React from 'react' import {StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {ScrollView} from '../util' -import {useStores} from 'state/index' import {Text} from '../../util/text/Text' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' @@ -10,16 +8,25 @@ import {deviceLocales} from 'platform/detection' import {LANGUAGES, LANGUAGES_MAP_CODE2} from '../../../../locale/languages' import {ConfirmLanguagesButton} from './ConfirmLanguagesButton' import {ToggleButton} from 'view/com/util/forms/ToggleButton' +import {Trans} from '@lingui/macro' +import {useModalControls} from '#/state/modals' +import { + useLanguagePrefs, + useLanguagePrefsApi, + hasPostLanguage, +} from '#/state/preferences/languages' export const snapPoints = ['100%'] -export const Component = observer(function PostLanguagesSettingsImpl() { - const store = useStores() +export function Component() { + const {closeModal} = useModalControls() + const langPrefs = useLanguagePrefs() + const setLangPrefs = useLanguagePrefsApi() const pal = usePalette('default') const {isMobile} = useWebMediaQueries() const onPressDone = React.useCallback(() => { - store.shell.closeModal() - }, [store]) + closeModal() + }, [closeModal]) const languages = React.useMemo(() => { const langs = LANGUAGES.filter( @@ -30,23 +37,23 @@ export const Component = observer(function PostLanguagesSettingsImpl() { // sort so that device & selected languages are on top, then alphabetically langs.sort((a, b) => { const hasA = - store.preferences.hasPostLanguage(a.code2) || + hasPostLanguage(langPrefs.postLanguage, a.code2) || deviceLocales.includes(a.code2) const hasB = - store.preferences.hasPostLanguage(b.code2) || + hasPostLanguage(langPrefs.postLanguage, b.code2) || deviceLocales.includes(b.code2) if (hasA === hasB) return a.name.localeCompare(b.name) if (hasA) return -1 return 1 }) return langs - }, [store]) + }, [langPrefs]) const onPress = React.useCallback( (code2: string) => { - store.preferences.togglePostLanguage(code2) + setLangPrefs.togglePostLanguage(code2) }, - [store], + [setLangPrefs], ) return ( @@ -64,20 +71,19 @@ export const Component = observer(function PostLanguagesSettingsImpl() { maxHeight: '90vh', }, ]}> - <Text style={[pal.text, styles.title]}>Post Languages</Text> + <Text style={[pal.text, styles.title]}> + <Trans>Post Languages</Trans> + </Text> <Text style={[pal.text, styles.description]}> - Which languages are used in this post? + <Trans>Which languages are used in this post?</Trans> </Text> <ScrollView style={styles.scrollContainer}> {languages.map(lang => { - const isSelected = store.preferences.hasPostLanguage(lang.code2) + const isSelected = hasPostLanguage(langPrefs.postLanguage, lang.code2) // enforce a max of 3 selections for post languages let isDisabled = false - if ( - store.preferences.postLanguage.split(',').length >= 3 && - !isSelected - ) { + if (langPrefs.postLanguage.split(',').length >= 3 && !isSelected) { isDisabled = true } @@ -104,7 +110,7 @@ export const Component = observer(function PostLanguagesSettingsImpl() { <ConfirmLanguagesButton onPress={onPressDone} /> </View> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/com/modals/report/InputIssueDetails.tsx b/src/view/com/modals/report/InputIssueDetails.tsx index 70a8f7b24..2f701b799 100644 --- a/src/view/com/modals/report/InputIssueDetails.tsx +++ b/src/view/com/modals/report/InputIssueDetails.tsx @@ -8,6 +8,8 @@ import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {s} from 'lib/styles' import {SendReportButton} from './SendReportButton' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' export function InputIssueDetails({ details, @@ -23,6 +25,7 @@ export function InputIssueDetails({ isProcessing: boolean }) { const pal = usePalette('default') + const {_} = useLingui() const {isMobile} = useWebMediaQueries() return ( @@ -35,14 +38,16 @@ export function InputIssueDetails({ style={[s.mb10, styles.backBtn]} onPress={goBack} accessibilityRole="button" - accessibilityLabel="Add details" + accessibilityLabel={_(msg`Add details`)} accessibilityHint="Add more details to your report"> <FontAwesomeIcon size={18} icon="angle-left" style={[pal.link]} /> - <Text style={[pal.text, s.f18, pal.link]}> Back</Text> + <Text style={[pal.text, s.f18, pal.link]}> + <Trans> Back</Trans> + </Text> </TouchableOpacity> <View style={[pal.btn, styles.detailsInputContainer]}> <TextInput - accessibilityLabel="Text input field" + accessibilityLabel={_(msg`Text input field`)} accessibilityHint="Enter a reason for reporting this post." placeholder="Enter a reason or any other details here." placeholderTextColor={pal.textLight.color} diff --git a/src/view/com/modals/report/Modal.tsx b/src/view/com/modals/report/Modal.tsx index 98aa2d471..60c3f06b7 100644 --- a/src/view/com/modals/report/Modal.tsx +++ b/src/view/com/modals/report/Modal.tsx @@ -2,7 +2,6 @@ import React, {useState, useMemo} from 'react' import {Linking, StyleSheet, TouchableOpacity, View} from 'react-native' import {ScrollView} from 'react-native-gesture-handler' import {AtUri} from '@atproto/api' -import {useStores} from 'state/index' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {s} from 'lib/styles' import {Text} from '../../util/text/Text' @@ -14,6 +13,10 @@ import {SendReportButton} from './SendReportButton' import {InputIssueDetails} from './InputIssueDetails' import {ReportReasonOptions} from './ReasonOptions' import {CollectionId} from './types' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import {getAgent} from '#/state/session' const DMCA_LINK = 'https://blueskyweb.xyz/support/copyright' @@ -36,7 +39,7 @@ type ReportComponentProps = } export function Component(content: ReportComponentProps) { - const store = useStores() + const {closeModal} = useModalControls() const pal = usePalette('default') const {isMobile} = useWebMediaQueries() const [isProcessing, setIsProcessing] = useState(false) @@ -60,13 +63,13 @@ export function Component(content: ReportComponentProps) { try { if (issue === '__copyright__') { Linking.openURL(DMCA_LINK) - store.shell.closeModal() + closeModal() return } const $type = !isAccountReport ? 'com.atproto.repo.strongRef' : 'com.atproto.admin.defs#repoRef' - await store.agent.createModerationReport({ + await getAgent().createModerationReport({ reasonType: issue, subject: { $type, @@ -76,7 +79,7 @@ export function Component(content: ReportComponentProps) { }) Toast.show("Thank you for your report! We'll look into it promptly.") - store.shell.closeModal() + closeModal() return } catch (e: any) { setError(cleanError(e)) @@ -146,6 +149,7 @@ const SelectIssue = ({ atUri: AtUri | null }) => { const pal = usePalette('default') + const {_} = useLingui() const collectionName = getCollectionNameForReport(atUri) const onSelectIssue = (v: string) => setIssue(v) const goToDetails = () => { @@ -158,9 +162,11 @@ const SelectIssue = ({ return ( <> - <Text style={[pal.text, styles.title]}>Report {collectionName}</Text> + <Text style={[pal.text, styles.title]}> + <Trans>Report {collectionName}</Trans> + </Text> <Text style={[pal.textLight, styles.description]}> - What is the issue with this {collectionName}? + <Trans>What is the issue with this {collectionName}?</Trans> </Text> <View style={{marginBottom: 10}}> <ReportReasonOptions @@ -182,9 +188,11 @@ const SelectIssue = ({ style={styles.addDetailsBtn} onPress={goToDetails} accessibilityRole="button" - accessibilityLabel="Add details" + accessibilityLabel={_(msg`Add details`)} accessibilityHint="Add more details to your report"> - <Text style={[s.f18, pal.link]}>Add details to report</Text> + <Text style={[s.f18, pal.link]}> + <Trans>Add details to report</Trans> + </Text> </TouchableOpacity> </> ) : undefined} diff --git a/src/view/com/modals/report/SendReportButton.tsx b/src/view/com/modals/report/SendReportButton.tsx index 82fb65f20..40c239bff 100644 --- a/src/view/com/modals/report/SendReportButton.tsx +++ b/src/view/com/modals/report/SendReportButton.tsx @@ -8,6 +8,8 @@ import { } from 'react-native' import {Text} from '../../util/text/Text' import {s, gradients, colors} from 'lib/styles' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' export function SendReportButton({ onPress, @@ -16,6 +18,7 @@ export function SendReportButton({ onPress: () => void isProcessing: boolean }) { + const {_} = useLingui() // loading state // = if (isProcessing) { @@ -31,14 +34,16 @@ export function SendReportButton({ style={s.mt10} onPress={onPress} accessibilityRole="button" - accessibilityLabel="Report post" + accessibilityLabel={_(msg`Report post`)} accessibilityHint={`Reports post with reason and details`}> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} end={{x: 1, y: 1}} style={[styles.btn]}> - <Text style={[s.white, s.bold, s.f18]}>Send Report</Text> + <Text style={[s.white, s.bold, s.f18]}> + <Trans>Send Report</Trans> + </Text> </LinearGradient> </TouchableOpacity> ) diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx index 74769bc76..260c9bbd5 100644 --- a/src/view/com/notifications/Feed.tsx +++ b/src/view/com/notifications/Feed.tsx @@ -1,66 +1,76 @@ import React, {MutableRefObject} from 'react' -import {observer} from 'mobx-react-lite' import {CenteredView, FlatList} from '../util/Views' import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' -import {NotificationsFeedModel} from 'state/models/feeds/notifications' import {FeedItem} from './FeedItem' import {NotificationFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {ErrorMessage} from '../util/error/ErrorMessage' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' import {EmptyState} from '../util/EmptyState' -import {OnScrollCb} from 'lib/hooks/useOnMainScroll' +import {OnScrollHandler} from 'lib/hooks/useOnMainScroll' +import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' +import {useNotificationFeedQuery} from '#/state/queries/notifications/feed' +import {useUnreadNotificationsApi} from '#/state/queries/notifications/unread' import {logger} from '#/logger' +import {cleanError} from '#/lib/strings/errors' +import {useModerationOpts} from '#/state/queries/preferences' const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} -const LOADING_SPINNER = {_reactKey: '__loading_spinner__'} +const LOADING_ITEM = {_reactKey: '__loading__'} -export const Feed = observer(function Feed({ - view, +export function Feed({ scrollElRef, onPressTryAgain, onScroll, ListHeaderComponent, }: { - view: NotificationsFeedModel scrollElRef?: MutableRefObject<FlatList<any> | null> onPressTryAgain?: () => void - onScroll?: OnScrollCb + onScroll?: OnScrollHandler ListHeaderComponent?: () => JSX.Element }) { const pal = usePalette('default') const [isPTRing, setIsPTRing] = React.useState(false) - const data = React.useMemo(() => { - let feedItems: any[] = [] - if (view.isRefreshing && !isPTRing) { - feedItems = [LOADING_SPINNER] - } - if (view.hasLoaded) { - if (view.isEmpty) { - feedItems = feedItems.concat([EMPTY_FEED_ITEM]) - } else { - feedItems = feedItems.concat(view.notifications) + + const moderationOpts = useModerationOpts() + const {checkUnread} = useUnreadNotificationsApi() + const { + data, + isFetching, + isFetched, + isError, + error, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useNotificationFeedQuery({enabled: !!moderationOpts}) + const isEmpty = !isFetching && !data?.pages[0]?.items.length + + const items = React.useMemo(() => { + let arr: any[] = [] + if (isFetched) { + if (isEmpty) { + arr = arr.concat([EMPTY_FEED_ITEM]) + } else if (data) { + for (const page of data?.pages) { + arr = arr.concat(page.items) + } } + if (isError && !isEmpty) { + arr = arr.concat([LOAD_MORE_ERROR_ITEM]) + } + } else { + arr.push(LOADING_ITEM) } - if (view.loadMoreError) { - feedItems = (feedItems || []).concat([LOAD_MORE_ERROR_ITEM]) - } - return feedItems - }, [ - view.hasLoaded, - view.isEmpty, - view.notifications, - view.loadMoreError, - view.isRefreshing, - isPTRing, - ]) + return arr + }, [isFetched, isError, isEmpty, data]) const onRefresh = React.useCallback(async () => { try { setIsPTRing(true) - await view.refresh() + await checkUnread({invalidate: true}) } catch (err) { logger.error('Failed to refresh notifications feed', { error: err, @@ -68,21 +78,21 @@ export const Feed = observer(function Feed({ } finally { setIsPTRing(false) } - }, [view, setIsPTRing]) + }, [checkUnread, setIsPTRing]) const onEndReached = React.useCallback(async () => { + if (isFetching || !hasNextPage || isError) return + try { - await view.loadMore() + await fetchNextPage() } catch (err) { - logger.error('Failed to load more notifications', { - error: err, - }) + logger.error('Failed to load more notifications', {error: err}) } - }, [view]) + }, [isFetching, hasNextPage, isError, fetchNextPage]) const onPressRetryLoadMore = React.useCallback(() => { - view.retryLoadMore() - }, [view]) + fetchNextPage() + }, [fetchNextPage]) // TODO optimize renderItem or FeedItem, we're getting this notice from RN: -prf // VirtualizedList: You have a large list that is slow to update - make sure your @@ -105,77 +115,66 @@ export const Feed = observer(function Feed({ onPress={onPressRetryLoadMore} /> ) - } else if (item === LOADING_SPINNER) { - return ( - <View style={styles.loading}> - <ActivityIndicator size="small" /> - </View> - ) + } else if (item === LOADING_ITEM) { + return <NotificationFeedLoadingPlaceholder /> } - return <FeedItem item={item} /> + return <FeedItem item={item} moderationOpts={moderationOpts!} /> }, - [onPressRetryLoadMore], + [onPressRetryLoadMore, moderationOpts], ) const FeedFooter = React.useCallback( () => - view.isLoading ? ( + isFetchingNextPage ? ( <View style={styles.feedFooter}> <ActivityIndicator /> </View> ) : ( <View /> ), - [view], + [isFetchingNextPage], ) + const scrollHandler = useAnimatedScrollHandler(onScroll || {}) return ( <View style={s.hContentRegion}> - <CenteredView> - {view.isLoading && !data.length && ( - <NotificationFeedLoadingPlaceholder /> - )} - {view.hasError && ( + {error && ( + <CenteredView> <ErrorMessage - message={view.error} + message={cleanError(error)} onPressTryAgain={onPressTryAgain} /> - )} - </CenteredView> - {data.length ? ( - <FlatList - testID="notifsFeed" - ref={scrollElRef} - data={data} - keyExtractor={item => item._reactKey} - renderItem={renderItem} - ListHeaderComponent={ListHeaderComponent} - ListFooterComponent={FeedFooter} - refreshControl={ - <RefreshControl - refreshing={isPTRing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } - onEndReached={onEndReached} - onEndReachedThreshold={0.6} - onScroll={onScroll} - scrollEventThrottle={100} - contentContainerStyle={s.contentContainer} - // @ts-ignore our .web version only -prf - desktopFixedHeight - /> - ) : null} + </CenteredView> + )} + <FlatList + testID="notifsFeed" + ref={scrollElRef} + data={items} + keyExtractor={item => item._reactKey} + renderItem={renderItem} + ListHeaderComponent={ListHeaderComponent} + ListFooterComponent={FeedFooter} + refreshControl={ + <RefreshControl + refreshing={isPTRing} + onRefresh={onRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + /> + } + onEndReached={onEndReached} + onEndReachedThreshold={0.6} + onScroll={scrollHandler} + scrollEventThrottle={1} + contentContainerStyle={s.contentContainer} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> </View> ) -}) +} const styles = StyleSheet.create({ - loading: { - paddingVertical: 20, - }, feedFooter: {paddingTop: 20}, emptyState: {paddingVertical: 40}, }) diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index c38ab3fd5..aaa2ea2c6 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -1,5 +1,4 @@ -import React, {useMemo, useState, useEffect} from 'react' -import {observer} from 'mobx-react-lite' +import React, {memo, useMemo, useState, useEffect} from 'react' import { Animated, TouchableOpacity, @@ -9,6 +8,9 @@ import { } from 'react-native' import { AppBskyEmbedImages, + AppBskyFeedDefs, + AppBskyFeedPost, + ModerationOpts, ProfileModeration, moderateProfile, AppBskyEmbedRecordWithMedia, @@ -19,8 +21,7 @@ import { FontAwesomeIconStyle, Props, } from '@fortawesome/react-native-fontawesome' -import {NotificationsFeedItemModel} from 'state/models/feeds/notifications' -import {PostThreadModel} from 'state/models/content/post-thread' +import {FeedNotification} from '#/state/queries/notifications/feed' import {s, colors} from 'lib/styles' import {niceDate} from 'lib/strings/time' import {sanitizeDisplayName} from 'lib/strings/display-names' @@ -33,13 +34,14 @@ import {UserPreviewLink} from '../util/UserPreviewLink' import {ImageHorzList} from '../util/images/ImageHorzList' import {Post} from '../post/Post' import {Link, TextLink} from '../util/Link' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {formatCount} from '../util/numeric/format' import {makeProfileLink} from 'lib/routes/links' import {TimeElapsed} from '../util/TimeElapsed' import {isWeb} from 'platform/detection' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' const MAX_AUTHORS = 5 @@ -54,40 +56,34 @@ interface Author { moderation: ProfileModeration } -export const FeedItem = observer(function FeedItemImpl({ +let FeedItem = ({ item, + moderationOpts, }: { - item: NotificationsFeedItemModel -}) { - const store = useStores() + item: FeedNotification + moderationOpts: ModerationOpts +}): React.ReactNode => { const pal = usePalette('default') const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false) const itemHref = useMemo(() => { - if (item.isLike || item.isRepost) { - const urip = new AtUri(item.subjectUri) - return `/profile/${urip.host}/post/${urip.rkey}` - } else if (item.isFollow) { - return makeProfileLink(item.author) - } else if (item.isReply) { - const urip = new AtUri(item.uri) + if (item.type === 'post-like' || item.type === 'repost') { + if (item.subjectUri) { + const urip = new AtUri(item.subjectUri) + return `/profile/${urip.host}/post/${urip.rkey}` + } + } else if (item.type === 'follow') { + return makeProfileLink(item.notification.author) + } else if (item.type === 'reply') { + const urip = new AtUri(item.notification.uri) return `/profile/${urip.host}/post/${urip.rkey}` - } else if (item.isCustomFeedLike) { - const urip = new AtUri(item.subjectUri) - return `/profile/${urip.host}/feed/${urip.rkey}` + } else if (item.type === 'feedgen-like') { + if (item.subjectUri) { + const urip = new AtUri(item.subjectUri) + return `/profile/${urip.host}/feed/${urip.rkey}` + } } return '' }, [item]) - const itemTitle = useMemo(() => { - if (item.isLike || item.isRepost) { - return 'Post' - } else if (item.isFollow) { - return item.author.handle - } else if (item.isReply) { - return 'Post' - } else if (item.isCustomFeedLike) { - return 'Custom Feed' - } - }, [item]) const onToggleAuthorsExpanded = () => { setAuthorsExpanded(currentlyExpanded => !currentlyExpanded) @@ -96,15 +92,12 @@ export const FeedItem = observer(function FeedItemImpl({ const authors: Author[] = useMemo(() => { return [ { - href: makeProfileLink(item.author), - did: item.author.did, - handle: item.author.handle, - displayName: item.author.displayName, - avatar: item.author.avatar, - moderation: moderateProfile( - item.author, - store.preferences.moderationOpts, - ), + href: makeProfileLink(item.notification.author), + did: item.notification.author.did, + handle: item.notification.author.handle, + displayName: item.notification.author.displayName, + avatar: item.notification.author.avatar, + moderation: moderateProfile(item.notification.author, moderationOpts), }, ...(item.additional?.map(({author}) => { return { @@ -113,33 +106,35 @@ export const FeedItem = observer(function FeedItemImpl({ handle: author.handle, displayName: author.displayName, avatar: author.avatar, - moderation: moderateProfile(author, store.preferences.moderationOpts), + moderation: moderateProfile(author, moderationOpts), } }) || []), ] - }, [store, item.additional, item.author]) + }, [item, moderationOpts]) - if (item.additionalPost?.notFound) { + if (item.subjectUri && !item.subject) { // don't render anything if the target post was deleted or unfindable return <View /> } - if (item.isReply || item.isMention || item.isQuote) { - if (!item.additionalPost || item.additionalPost?.error) { - // hide errors - it doesnt help the user to show them - return <View /> + if ( + item.type === 'reply' || + item.type === 'mention' || + item.type === 'quote' + ) { + if (!item.subject) { + return null } return ( <Link - testID={`feedItem-by-${item.author.handle}`} + testID={`feedItem-by-${item.notification.author.handle}`} href={itemHref} - title={itemTitle} noFeedback accessible={false}> <Post - view={item.additionalPost} + post={item.subject} style={ - item.isRead + item.notification.isRead ? undefined : { backgroundColor: pal.colors.unreadNotifBg, @@ -154,23 +149,25 @@ export const FeedItem = observer(function FeedItemImpl({ let action = '' let icon: Props['icon'] | 'HeartIconSolid' let iconStyle: Props['style'] = [] - if (item.isLike) { + if (item.type === 'post-like') { action = 'liked your post' icon = 'HeartIconSolid' iconStyle = [ s.likeColor as FontAwesomeIconStyle, {position: 'relative', top: -4}, ] - } else if (item.isRepost) { + } else if (item.type === 'repost') { action = 'reposted your post' icon = 'retweet' iconStyle = [s.green3 as FontAwesomeIconStyle] - } else if (item.isFollow) { + } else if (item.type === 'follow') { action = 'followed you' icon = 'user-plus' iconStyle = [s.blue3 as FontAwesomeIconStyle] - } else if (item.isCustomFeedLike) { - action = `liked your custom feed '${new AtUri(item.subjectUri).rkey}'` + } else if (item.type === 'feedgen-like') { + action = `liked your custom feed${ + item.subjectUri ? ` '${new AtUri(item.subjectUri).rkey}}'` : '' + }` icon = 'HeartIconSolid' iconStyle = [ s.likeColor as FontAwesomeIconStyle, @@ -182,12 +179,12 @@ export const FeedItem = observer(function FeedItemImpl({ return ( <Link - testID={`feedItem-by-${item.author.handle}`} + testID={`feedItem-by-${item.notification.author.handle}`} style={[ styles.outer, pal.view, pal.border, - item.isRead + item.notification.isRead ? undefined : { backgroundColor: pal.colors.unreadNotifBg, @@ -195,9 +192,11 @@ export const FeedItem = observer(function FeedItemImpl({ }, ]} href={itemHref} - title={itemTitle} noFeedback - accessible={(item.isLike && authors.length === 1) || item.isRepost}> + accessible={ + (item.type === 'post-like' && authors.length === 1) || + item.type === 'repost' + }> <View style={styles.layoutIcon}> {/* TODO: Prevent conditional rendering and move toward composable notifications for clearer accessibility labeling */} @@ -232,7 +231,10 @@ export const FeedItem = observer(function FeedItemImpl({ /> {authors.length > 1 ? ( <> - <Text style={[pal.text]}> and </Text> + <Text style={[pal.text, s.mr5, s.ml5]}> + {' '} + <Trans>and</Trans>{' '} + </Text> <Text style={[pal.text, s.bold]}> {formatCount(authors.length - 1)}{' '} {pluralize(authors.length - 1, 'other')} @@ -240,24 +242,26 @@ export const FeedItem = observer(function FeedItemImpl({ </> ) : undefined} <Text style={[pal.text]}> {action}</Text> - <TimeElapsed timestamp={item.indexedAt}> + <TimeElapsed timestamp={item.notification.indexedAt}> {({timeElapsed}) => ( <Text style={[pal.textLight, styles.pointer]} - title={niceDate(item.indexedAt)}> + title={niceDate(item.notification.indexedAt)}> {' ' + timeElapsed} </Text> )} </TimeElapsed> </Text> </ExpandListPressable> - {item.isLike || item.isRepost || item.isQuote ? ( - <AdditionalPostText additionalPost={item.additionalPost} /> + {item.type === 'post-like' || item.type === 'repost' ? ( + <AdditionalPostText post={item.subject} /> ) : null} </View> </Link> ) -}) +} +FeedItem = memo(FeedItem) +export {FeedItem} function ExpandListPressable({ hasMultipleAuthors, @@ -292,6 +296,8 @@ function CondensedAuthorsList({ onToggleAuthorsExpanded: () => void }) { const pal = usePalette('default') + const {_} = useLingui() + if (!visible) { return ( <View style={styles.avis}> @@ -299,7 +305,7 @@ function CondensedAuthorsList({ style={styles.expandedAuthorsCloseBtn} onPress={onToggleAuthorsExpanded} accessibilityRole="button" - accessibilityLabel="Hide user list" + accessibilityLabel={_(msg`Hide user list`)} accessibilityHint="Collapses list of users for a given notification"> <FontAwesomeIcon icon="angle-up" @@ -307,7 +313,7 @@ function CondensedAuthorsList({ style={[styles.expandedAuthorsCloseBtnIcon, pal.text]} /> <Text type="sm-medium" style={pal.text}> - Hide + <Trans>Hide</Trans> </Text> </TouchableOpacity> </View> @@ -328,7 +334,7 @@ function CondensedAuthorsList({ } return ( <TouchableOpacity - accessibilityLabel="Show users" + accessibilityLabel={_(msg`Show users`)} accessibilityHint="Opens an expanded list of users in this notification" onPress={onToggleAuthorsExpanded}> <View style={styles.avis}> @@ -417,34 +423,25 @@ function ExpandedAuthorsList({ ) } -function AdditionalPostText({ - additionalPost, -}: { - additionalPost?: PostThreadModel -}) { +function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) { const pal = usePalette('default') - if ( - !additionalPost || - !additionalPost.thread?.postRecord || - additionalPost.error - ) { - return <View /> + if (post && AppBskyFeedPost.isRecord(post?.record)) { + const text = post.record.text + const images = AppBskyEmbedImages.isView(post.embed) + ? post.embed.images + : AppBskyEmbedRecordWithMedia.isView(post.embed) && + AppBskyEmbedImages.isView(post.embed.media) + ? post.embed.media.images + : undefined + return ( + <> + {text?.length > 0 && <Text style={pal.textLight}>{text}</Text>} + {images && images?.length > 0 && ( + <ImageHorzList images={images} style={styles.additionalPostImages} /> + )} + </> + ) } - const text = additionalPost.thread?.postRecord.text - const images = AppBskyEmbedImages.isView(additionalPost.thread.post.embed) - ? additionalPost.thread.post.embed.images - : AppBskyEmbedRecordWithMedia.isView(additionalPost.thread.post.embed) && - AppBskyEmbedImages.isView(additionalPost.thread.post.embed.media) - ? additionalPost.thread.post.embed.media.images - : undefined - return ( - <> - {text?.length > 0 && <Text style={pal.textLight}>{text}</Text>} - {images && images?.length > 0 && ( - <ImageHorzList images={images} style={styles.additionalPostImages} /> - )} - </> - ) } const styles = StyleSheet.create({ diff --git a/src/view/com/notifications/InvitedUsers.tsx b/src/view/com/notifications/InvitedUsers.tsx deleted file mode 100644 index aaf358b87..000000000 --- a/src/view/com/notifications/InvitedUsers.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React from 'react' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {AppBskyActorDefs} from '@atproto/api' -import {UserAvatar} from '../util/UserAvatar' -import {Text} from '../util/text/Text' -import {Link, TextLink} from '../util/Link' -import {Button} from '../util/forms/Button' -import {FollowButton} from '../profile/FollowButton' -import {CenteredView} from '../util/Views.web' -import {useStores} from 'state/index' -import {usePalette} from 'lib/hooks/usePalette' -import {s} from 'lib/styles' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {makeProfileLink} from 'lib/routes/links' - -export const InvitedUsers = observer(function InvitedUsersImpl() { - const store = useStores() - return ( - <CenteredView> - {store.invitedUsers.profiles.map(profile => ( - <InvitedUser key={profile.did} profile={profile} /> - ))} - </CenteredView> - ) -}) - -function InvitedUser({ - profile, -}: { - profile: AppBskyActorDefs.ProfileViewDetailed -}) { - const pal = usePalette('default') - const store = useStores() - - const onPressDismiss = React.useCallback(() => { - store.invitedUsers.markSeen(profile.did) - }, [store, profile]) - - return ( - <View - testID="invitedUser" - style={[ - styles.layout, - { - backgroundColor: pal.colors.unreadNotifBg, - borderColor: pal.colors.unreadNotifBorder, - }, - ]}> - <View style={styles.layoutIcon}> - <FontAwesomeIcon - icon="user-plus" - size={24} - style={[styles.icon, s.blue3 as FontAwesomeIconStyle]} - /> - </View> - <View style={s.flex1}> - <Link href={makeProfileLink(profile)}> - <UserAvatar avatar={profile.avatar} size={35} /> - </Link> - <Text style={[styles.desc, pal.text]}> - <TextLink - type="md-bold" - style={pal.text} - href={makeProfileLink(profile)} - text={sanitizeDisplayName(profile.displayName || profile.handle)} - />{' '} - joined using your invite code! - </Text> - <View style={styles.btns}> - <FollowButton - unfollowedType="primary" - followedType="primary-light" - profile={profile} - /> - <Button - testID="dismissBtn" - type="primary-light" - label="Dismiss" - onPress={onPressDismiss} - /> - </View> - </View> - </View> - ) -} - -const styles = StyleSheet.create({ - layout: { - flexDirection: 'row', - borderTopWidth: 1, - padding: 10, - }, - layoutIcon: { - width: 70, - alignItems: 'flex-end', - paddingTop: 2, - }, - icon: { - marginRight: 10, - marginTop: 4, - }, - desc: { - paddingVertical: 6, - }, - btns: { - flexDirection: 'row', - gap: 10, - }, -}) diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx index 25755bafe..57c83f17c 100644 --- a/src/view/com/pager/FeedsTabBar.web.tsx +++ b/src/view/com/pager/FeedsTabBar.web.tsx @@ -1,50 +1,136 @@ import React from 'react' -import {StyleSheet} from 'react-native' +import {View, StyleSheet} from 'react-native' import Animated from 'react-native-reanimated' -import {observer} from 'mobx-react-lite' import {TabBar} from 'view/com/pager/TabBar' import {RenderTabBarFnProps} from 'view/com/pager/Pager' -import {useStores} from 'state/index' -import {useHomeTabs} from 'lib/hooks/useHomeTabs' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' +import {useShellLayout} from '#/state/shell/shell-layout' +import {usePinnedFeedsInfos} from '#/state/queries/feed' +import {useSession} from '#/state/session' +import {TextLink} from '#/view/com/util/Link' +import {CenteredView} from '../util/Views' +import {isWeb} from 'platform/detection' +import {useNavigation} from '@react-navigation/native' +import {NavigationProp} from 'lib/routes/types' -export const FeedsTabBar = observer(function FeedsTabBarImpl( +export function FeedsTabBar( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, ) { const {isMobile, isTablet} = useWebMediaQueries() + const {hasSession} = useSession() + if (isMobile) { return <FeedsTabBarMobile {...props} /> } else if (isTablet) { - return <FeedsTabBarTablet {...props} /> + if (hasSession) { + return <FeedsTabBarTablet {...props} /> + } else { + return <FeedsTabBarPublic /> + } } else { return null } -}) +} + +function FeedsTabBarPublic() { + const pal = usePalette('default') + const {isSandbox} = useSession() -const FeedsTabBarTablet = observer(function FeedsTabBarTabletImpl( + return ( + <CenteredView sideBorders> + <View + style={[ + pal.view, + { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 18, + paddingVertical: 12, + }, + ]}> + <TextLink + type="title-lg" + href="/" + style={[pal.text, {fontWeight: 'bold'}]} + text={ + <> + {isSandbox ? 'SANDBOX' : 'Bluesky'}{' '} + {/*hasNew && ( + <View + style={{ + top: -8, + backgroundColor: colors.blue3, + width: 8, + height: 8, + borderRadius: 4, + }} + /> + )*/} + </> + } + // onPress={emitSoftReset} + /> + </View> + </CenteredView> + ) +} + +function FeedsTabBarTablet( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, ) { - const store = useStores() - const items = useHomeTabs(store.preferences.pinnedFeeds) + const {feeds, hasPinnedCustom} = usePinnedFeedsInfos() const pal = usePalette('default') + const {hasSession} = useSession() + const navigation = useNavigation<NavigationProp>() const {headerMinimalShellTransform} = useMinimalShellMode() + const {headerHeight} = useShellLayout() + const pinnedDisplayNames = hasSession ? feeds.map(f => f.displayName) : [] + const showFeedsLinkInTabBar = hasSession && !hasPinnedCustom + const items = showFeedsLinkInTabBar + ? pinnedDisplayNames.concat('Feeds ✨') + : pinnedDisplayNames + + const onPressDiscoverFeeds = React.useCallback(() => { + if (isWeb) { + navigation.navigate('Feeds') + } else { + navigation.navigate('FeedsTab') + navigation.popToTop() + } + }, [navigation]) + + const onSelect = React.useCallback( + (index: number) => { + if (showFeedsLinkInTabBar && index === items.length - 1) { + onPressDiscoverFeeds() + } else if (props.onSelect) { + props.onSelect(index) + } + }, + [items.length, onPressDiscoverFeeds, props, showFeedsLinkInTabBar], + ) return ( // @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf <Animated.View - style={[pal.view, styles.tabBar, headerMinimalShellTransform]}> + style={[pal.view, styles.tabBar, headerMinimalShellTransform]} + onLayout={e => { + headerHeight.value = e.nativeEvent.layout.height + }}> <TabBar key={items.join(',')} {...props} + onSelect={onSelect} items={items} indicatorColor={pal.colors.link} /> </Animated.View> ) -}) +} const styles = StyleSheet.create({ tabBar: { diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx index 9848ce2d5..882b6cfc5 100644 --- a/src/view/com/pager/FeedsTabBarMobile.tsx +++ b/src/view/com/pager/FeedsTabBarMobile.tsx @@ -1,10 +1,7 @@ import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {TabBar} from 'view/com/pager/TabBar' import {RenderTabBarFnProps} from 'view/com/pager/Pager' -import {useStores} from 'state/index' -import {useHomeTabs} from 'lib/hooks/useHomeTabs' import {usePalette} from 'lib/hooks/usePalette' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {Link} from '../util/Link' @@ -14,18 +11,54 @@ import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' import {s} from 'lib/styles' import {HITSLOP_10} from 'lib/constants' import Animated from 'react-native-reanimated' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' import {useSetDrawerOpen} from '#/state/shell/drawer-open' +import {useShellLayout} from '#/state/shell/shell-layout' +import {useSession} from '#/state/session' +import {usePinnedFeedsInfos} from '#/state/queries/feed' +import {isWeb} from 'platform/detection' +import {useNavigation} from '@react-navigation/native' +import {NavigationProp} from 'lib/routes/types' -export const FeedsTabBar = observer(function FeedsTabBarImpl( +export function FeedsTabBar( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, ) { const pal = usePalette('default') - const store = useStores() + const {isSandbox, hasSession} = useSession() + const {_} = useLingui() const setDrawerOpen = useSetDrawerOpen() - const items = useHomeTabs(store.preferences.pinnedFeeds) + const navigation = useNavigation<NavigationProp>() + const {feeds, hasPinnedCustom} = usePinnedFeedsInfos() const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3) - const {minimalShellMode, headerMinimalShellTransform} = useMinimalShellMode() + const {headerHeight} = useShellLayout() + const {headerMinimalShellTransform} = useMinimalShellMode() + const pinnedDisplayNames = hasSession ? feeds.map(f => f.displayName) : [] + const showFeedsLinkInTabBar = hasSession && !hasPinnedCustom + const items = showFeedsLinkInTabBar + ? pinnedDisplayNames.concat('Feeds ✨') + : pinnedDisplayNames + + const onPressFeedsLink = React.useCallback(() => { + if (isWeb) { + navigation.navigate('Feeds') + } else { + navigation.navigate('FeedsTab') + navigation.popToTop() + } + }, [navigation]) + + const onSelect = React.useCallback( + (index: number) => { + if (showFeedsLinkInTabBar && index === items.length - 1) { + onPressFeedsLink() + } else if (props.onSelect) { + props.onSelect(index) + } + }, + [items.length, onPressFeedsLink, props, showFeedsLinkInTabBar], + ) const onPressAvi = React.useCallback(() => { setDrawerOpen(true) @@ -33,20 +66,17 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl( return ( <Animated.View - style={[ - pal.view, - pal.border, - styles.tabBar, - headerMinimalShellTransform, - minimalShellMode && styles.disabled, - ]}> + style={[pal.view, pal.border, styles.tabBar, headerMinimalShellTransform]} + onLayout={e => { + headerHeight.value = e.nativeEvent.layout.height + }}> <View style={[pal.view, styles.topBar]}> <View style={[pal.view]}> <TouchableOpacity testID="viewHeaderDrawerBtn" onPress={onPressAvi} accessibilityRole="button" - accessibilityLabel="Open navigation" + accessibilityLabel={_(msg`Open navigation`)} accessibilityHint="Access profile and other navigation links" hitSlop={HITSLOP_10}> <FontAwesomeIcon @@ -57,35 +87,40 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl( </TouchableOpacity> </View> <Text style={[brandBlue, s.bold, styles.title]}> - {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'} + {isSandbox ? 'SANDBOX' : 'Bluesky'} </Text> - <View style={[pal.view]}> - <Link - testID="viewHeaderHomeFeedPrefsBtn" - href="/settings/home-feed" - hitSlop={HITSLOP_10} - accessibilityRole="button" - accessibilityLabel="Home Feed Preferences" - accessibilityHint=""> - <FontAwesomeIcon - icon="sliders" - style={pal.textLight as FontAwesomeIconStyle} - /> - </Link> + <View style={[pal.view, {width: 18}]}> + {hasSession && ( + <Link + testID="viewHeaderHomeFeedPrefsBtn" + href="/settings/home-feed" + hitSlop={HITSLOP_10} + accessibilityRole="button" + accessibilityLabel={_(msg`Home Feed Preferences`)} + accessibilityHint=""> + <FontAwesomeIcon + icon="sliders" + style={pal.textLight as FontAwesomeIconStyle} + /> + </Link> + )} </View> </View> - <TabBar - key={items.join(',')} - onPressSelected={props.onPressSelected} - selectedPage={props.selectedPage} - onSelect={props.onSelect} - testID={props.testID} - items={items} - indicatorColor={pal.colors.link} - /> + + {items.length > 0 && ( + <TabBar + key={items.join(',')} + onPressSelected={props.onPressSelected} + selectedPage={props.selectedPage} + onSelect={onSelect} + testID={props.testID} + items={items} + indicatorColor={pal.colors.link} + /> + )} </Animated.View> ) -}) +} const styles = StyleSheet.create({ tabBar: { @@ -95,7 +130,6 @@ const styles = StyleSheet.create({ right: 0, top: 0, flexDirection: 'column', - alignItems: 'center', borderBottomWidth: 1, }, topBar: { @@ -103,14 +137,10 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 18, - paddingTop: 8, - paddingBottom: 2, + paddingVertical: 8, width: '100%', }, title: { fontSize: 21, }, - disabled: { - pointerEvents: 'none', - }, }) diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx index 531a41ee2..d70087504 100644 --- a/src/view/com/pager/Pager.tsx +++ b/src/view/com/pager/Pager.tsx @@ -26,6 +26,9 @@ interface Props { renderTabBar: RenderTabBarFn onPageSelected?: (index: number) => void onPageSelecting?: (index: number) => void + onPageScrollStateChanged?: ( + scrollState: 'idle' | 'dragging' | 'settling', + ) => void testID?: string } export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>( @@ -35,6 +38,7 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>( tabBarPosition = 'top', initialPage = 0, renderTabBar, + onPageScrollStateChanged, onPageSelected, onPageSelecting, testID, @@ -97,11 +101,12 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>( [lastOffset, lastDirection, onPageSelecting], ) - const onPageScrollStateChanged = React.useCallback( + const handlePageScrollStateChanged = React.useCallback( (e: PageScrollStateChangedNativeEvent) => { scrollState.current = e.nativeEvent.pageScrollState + onPageScrollStateChanged?.(e.nativeEvent.pageScrollState) }, - [scrollState], + [scrollState, onPageScrollStateChanged], ) const onTabBarSelect = React.useCallback( @@ -123,7 +128,7 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>( ref={pagerView} style={s.flex1} initialPage={initialPage} - onPageScrollStateChanged={onPageScrollStateChanged} + onPageScrollStateChanged={handlePageScrollStateChanged} onPageSelected={onPageSelectedInner} onPageScroll={onPageScroll}> {children} diff --git a/src/view/com/pager/Pager.web.tsx b/src/view/com/pager/Pager.web.tsx index 7ec292667..3b5e9164a 100644 --- a/src/view/com/pager/Pager.web.tsx +++ b/src/view/com/pager/Pager.web.tsx @@ -49,7 +49,18 @@ export const Pager = React.forwardRef(function PagerImpl( onSelect: onTabBarSelect, })} {React.Children.map(children, (child, i) => ( - <View style={selectedPage === i ? s.flex1 : s.hidden} key={`page-${i}`}> + <View + style={ + selectedPage === i + ? s.flex1 + : { + position: 'absolute', + pointerEvents: 'none', + // @ts-ignore web-only + visibility: 'hidden', + } + } + key={`page-${i}`}> {child} </View> ))} diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx index 701b52871..2d3b0cece 100644 --- a/src/view/com/pager/PagerWithHeader.tsx +++ b/src/view/com/pager/PagerWithHeader.tsx @@ -1,28 +1,36 @@ import * as React from 'react' import { LayoutChangeEvent, - NativeScrollEvent, + FlatList, + ScrollView, StyleSheet, View, + NativeScrollEvent, } from 'react-native' import Animated, { - Easing, - useAnimatedReaction, useAnimatedStyle, useSharedValue, - withTiming, runOnJS, + runOnUI, + scrollTo, + useAnimatedRef, + AnimatedRef, + SharedValue, } from 'react-native-reanimated' import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' import {TabBar} from './TabBar' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {OnScrollHandler} from 'lib/hooks/useOnMainScroll' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' const SCROLLED_DOWN_LIMIT = 200 -interface PagerWithHeaderChildParams { +export interface PagerWithHeaderChildParams { headerHeight: number - onScroll: (e: NativeScrollEvent) => void + isFocused: boolean + onScroll: OnScrollHandler isScrolledDown: boolean + scrollElRef: React.MutableRefObject<FlatList<any> | ScrollView | null> } export interface PagerWithHeaderProps { @@ -51,117 +59,120 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>( }: PagerWithHeaderProps, ref, ) { - const {isMobile} = useWebMediaQueries() const [currentPage, setCurrentPage] = React.useState(0) - const scrollYs = React.useRef<Record<number, number>>({}) - const scrollY = useSharedValue(scrollYs.current[currentPage] || 0) const [tabBarHeight, setTabBarHeight] = React.useState(0) const [headerOnlyHeight, setHeaderOnlyHeight] = React.useState(0) - const [isScrolledDown, setIsScrolledDown] = React.useState( - scrollYs.current[currentPage] > SCROLLED_DOWN_LIMIT, - ) - + const [isScrolledDown, setIsScrolledDown] = React.useState(false) + const scrollY = useSharedValue(0) const headerHeight = headerOnlyHeight + tabBarHeight - // react to scroll updates - function onScrollUpdate(v: number) { - // track each page's current scroll position - scrollYs.current[currentPage] = Math.min(v, headerOnlyHeight) - // update the 'is scrolled down' value - setIsScrolledDown(v > SCROLLED_DOWN_LIMIT) - } - useAnimatedReaction( - () => scrollY.value, - v => runOnJS(onScrollUpdate)(v), - ) - // capture the header bar sizing const onTabBarLayout = React.useCallback( (evt: LayoutChangeEvent) => { - setTabBarHeight(evt.nativeEvent.layout.height) + const height = evt.nativeEvent.layout.height + if (height > 0) { + setTabBarHeight(height) + } }, [setTabBarHeight], ) const onHeaderOnlyLayout = React.useCallback( (evt: LayoutChangeEvent) => { - setHeaderOnlyHeight(evt.nativeEvent.layout.height) + const height = evt.nativeEvent.layout.height + if (height > 0) { + setHeaderOnlyHeight(height) + } }, [setHeaderOnlyHeight], ) - // render the the header and tab bar - const headerTransform = useAnimatedStyle( - () => ({ - transform: [ - { - translateY: Math.min( - Math.min(scrollY.value, headerOnlyHeight) * -1, - 0, - ), - }, - ], - }), - [scrollY, headerHeight, tabBarHeight], - ) const renderTabBar = React.useCallback( (props: RenderTabBarFnProps) => { return ( - <Animated.View - style={[ - isMobile ? styles.tabBarMobile : styles.tabBarDesktop, - headerTransform, - ]}> - <View onLayout={onHeaderOnlyLayout}>{renderHeader?.()}</View> - <View - onLayout={onTabBarLayout} - style={{ - // Render it immediately to measure it early since its size doesn't depend on the content. - // However, keep it invisible until the header above stabilizes in order to prevent jumps. - opacity: isHeaderReady ? 1 : 0, - pointerEvents: isHeaderReady ? 'auto' : 'none', - }}> - <TabBar - items={items} - selectedPage={currentPage} - onSelect={props.onSelect} - onPressSelected={onCurrentPageSelected} - /> - </View> - </Animated.View> + <PagerTabBar + headerOnlyHeight={headerOnlyHeight} + items={items} + isHeaderReady={isHeaderReady} + renderHeader={renderHeader} + currentPage={currentPage} + onCurrentPageSelected={onCurrentPageSelected} + onTabBarLayout={onTabBarLayout} + onHeaderOnlyLayout={onHeaderOnlyLayout} + onSelect={props.onSelect} + scrollY={scrollY} + testID={testID} + /> ) }, [ + headerOnlyHeight, items, isHeaderReady, renderHeader, - headerTransform, currentPage, onCurrentPageSelected, - isMobile, onTabBarLayout, onHeaderOnlyLayout, + scrollY, + testID, ], ) - // Ideally we'd call useAnimatedScrollHandler here but we can't safely do that - // due to https://github.com/software-mansion/react-native-reanimated/issues/5345. - // So instead we pass down a worklet, and individual pages will have to call it. - const onScroll = React.useCallback( - (e: NativeScrollEvent) => { + const scrollRefs = useSharedValue<AnimatedRef<any>[]>([]) + const registerRef = (scrollRef: AnimatedRef<any>, index: number) => { + scrollRefs.modify(refs => { 'worklet' - scrollY.value = e.contentOffset.y - }, - [scrollY], + refs[index] = scrollRef + return refs + }) + } + + const lastForcedScrollY = useSharedValue(0) + const adjustScrollForOtherPages = () => { + 'worklet' + const currentScrollY = scrollY.value + const forcedScrollY = Math.min(currentScrollY, headerOnlyHeight) + if (lastForcedScrollY.value !== forcedScrollY) { + lastForcedScrollY.value = forcedScrollY + const refs = scrollRefs.value + for (let i = 0; i < refs.length; i++) { + if (i !== currentPage) { + // This needs to run on the UI thread. + scrollTo(refs[i], 0, forcedScrollY, false) + } + } + } + } + + const throttleTimeout = React.useRef<ReturnType<typeof setTimeout> | null>( + null, ) + const queueThrottledOnScroll = useNonReactiveCallback(() => { + if (!throttleTimeout.current) { + throttleTimeout.current = setTimeout(() => { + throttleTimeout.current = null - // props to pass into children render functions - const childProps = React.useMemo<PagerWithHeaderChildParams>(() => { - return { - headerHeight, - onScroll, - isScrolledDown, + runOnUI(adjustScrollForOtherPages)() + + const nextIsScrolledDown = scrollY.value > SCROLLED_DOWN_LIMIT + if (isScrolledDown !== nextIsScrolledDown) { + React.startTransition(() => { + setIsScrolledDown(nextIsScrolledDown) + }) + } + }, 80 /* Sync often enough you're unlikely to catch it unsynced */) } - }, [headerHeight, onScroll, isScrolledDown]) + }) + + const onScrollWorklet = React.useCallback( + (e: NativeScrollEvent) => { + 'worklet' + const nextScrollY = e.contentOffset.y + scrollY.value = nextScrollY + runOnJS(queueThrottledOnScroll)() + }, + [scrollY, queueThrottledOnScroll], + ) const onPageSelectedInner = React.useCallback( (index: number) => { @@ -171,19 +182,9 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>( [onPageSelected, setCurrentPage], ) - const onPageSelecting = React.useCallback( - (index: number) => { - setCurrentPage(index) - if (scrollY.value > headerHeight) { - scrollY.value = headerHeight - } - scrollY.value = withTiming(scrollYs.current[index] || 0, { - duration: 170, - easing: Easing.inOut(Easing.quad), - }) - }, - [scrollY, setCurrentPage, scrollYs, headerHeight], - ) + const onPageSelecting = React.useCallback((index: number) => { + setCurrentPage(index) + }, []) return ( <Pager @@ -197,20 +198,19 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>( {toArray(children) .filter(Boolean) .map((child, i) => { - let output = null - if ( - child != null && - // Defer showing content until we know it won't jump. - isHeaderReady && - headerOnlyHeight > 0 && - tabBarHeight > 0 - ) { - output = child(childProps) - } - // Pager children must be noncollapsible plain <View>s. + const isReady = + isHeaderReady && headerOnlyHeight > 0 && tabBarHeight > 0 return ( <View key={i} collapsable={false}> - {output} + <PagerItem + headerHeight={headerHeight} + isReady={isReady} + isFocused={i === currentPage} + isScrolledDown={isScrolledDown} + onScrollWorklet={i === currentPage ? onScrollWorklet : noop} + registerRef={(r: AnimatedRef<any>) => registerRef(r, i)} + renderTab={child} + /> </View> ) })} @@ -219,6 +219,107 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>( }, ) +let PagerTabBar = ({ + currentPage, + headerOnlyHeight, + isHeaderReady, + items, + scrollY, + testID, + renderHeader, + onHeaderOnlyLayout, + onTabBarLayout, + onCurrentPageSelected, + onSelect, +}: { + currentPage: number + headerOnlyHeight: number + isHeaderReady: boolean + items: string[] + testID?: string + scrollY: SharedValue<number> + renderHeader?: () => JSX.Element + onHeaderOnlyLayout: (e: LayoutChangeEvent) => void + onTabBarLayout: (e: LayoutChangeEvent) => void + onCurrentPageSelected?: (index: number) => void + onSelect?: (index: number) => void +}): React.ReactNode => { + const {isMobile} = useWebMediaQueries() + const headerTransform = useAnimatedStyle(() => ({ + transform: [ + { + translateY: Math.min(Math.min(scrollY.value, headerOnlyHeight) * -1, 0), + }, + ], + })) + return ( + <Animated.View + style={[ + isMobile ? styles.tabBarMobile : styles.tabBarDesktop, + headerTransform, + ]}> + <View onLayout={onHeaderOnlyLayout}>{renderHeader?.()}</View> + <View + onLayout={onTabBarLayout} + style={{ + // Render it immediately to measure it early since its size doesn't depend on the content. + // However, keep it invisible until the header above stabilizes in order to prevent jumps. + opacity: isHeaderReady ? 1 : 0, + pointerEvents: isHeaderReady ? 'auto' : 'none', + }}> + <TabBar + testID={testID} + items={items} + selectedPage={currentPage} + onSelect={onSelect} + onPressSelected={onCurrentPageSelected} + /> + </View> + </Animated.View> + ) +} +PagerTabBar = React.memo(PagerTabBar) + +function PagerItem({ + headerHeight, + isReady, + isFocused, + isScrolledDown, + onScrollWorklet, + renderTab, + registerRef, +}: { + headerHeight: number + isFocused: boolean + isReady: boolean + isScrolledDown: boolean + registerRef: (scrollRef: AnimatedRef<any>) => void + onScrollWorklet: (e: NativeScrollEvent) => void + renderTab: ((props: PagerWithHeaderChildParams) => JSX.Element) | null +}) { + const scrollElRef = useAnimatedRef() + registerRef(scrollElRef) + + const scrollHandler = React.useMemo( + () => ({onScroll: onScrollWorklet}), + [onScrollWorklet], + ) + + if (!isReady || renderTab == null) { + return null + } + + return renderTab({ + headerHeight, + isFocused, + isScrolledDown, + onScroll: scrollHandler, + scrollElRef: scrollElRef as React.MutableRefObject< + FlatList<any> | ScrollView | null + >, + }) +} + const styles = StyleSheet.create({ tabBarMobile: { position: 'absolute', @@ -237,6 +338,10 @@ const styles = StyleSheet.create({ }, }) +function noop() { + 'worklet' +} + function toArray<T>(v: T | T[]): T[] { if (Array.isArray(v)) { return v diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 0e08b22d8..c3a95c5c0 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -68,6 +68,7 @@ export function TabBar({ return ( <View testID={testID} style={[pal.view, styles.outer]}> <DraggableScrollView + testID={`${testID}-selector`} horizontal={true} showsHorizontalScrollIndicator={false} ref={scrollElRef} @@ -76,6 +77,7 @@ export function TabBar({ const selected = i === selectedPage return ( <PressableWithHover + testID={`${testID}-selector-${i}`} key={item} onLayout={e => onItemLayout(e, i)} style={[styles.item, selected && indicatorStyle]} diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx index 22ff035d0..60afe1f9c 100644 --- a/src/view/com/post-thread/PostLikedBy.tsx +++ b/src/view/com/post-thread/PostLikedBy.tsx @@ -1,39 +1,66 @@ -import React, {useEffect} from 'react' -import {observer} from 'mobx-react-lite' +import React, {useCallback, useMemo, useState} from 'react' import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' +import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api' import {CenteredView, FlatList} from '../util/Views' -import {LikesModel, LikeItem} from 'state/models/lists/likes' import {ErrorMessage} from '../util/error/ErrorMessage' import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {logger} from '#/logger' +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import {usePostLikedByQuery} from '#/state/queries/post-liked-by' +import {cleanError} from '#/lib/strings/errors' -export const PostLikedBy = observer(function PostLikedByImpl({ - uri, -}: { - uri: string -}) { +export function PostLikedBy({uri}: {uri: string}) { const pal = usePalette('default') - const store = useStores() - const view = React.useMemo(() => new LikesModel(store, {uri}), [store, uri]) + const [isPTRing, setIsPTRing] = useState(false) + const { + data: resolvedUri, + error: resolveError, + isFetching: isFetchingResolvedUri, + } = useResolveUriQuery(uri) + const { + data, + isFetching, + isFetched, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + isError, + error, + refetch, + } = usePostLikedByQuery(resolvedUri?.uri) + const likes = useMemo(() => { + if (data?.pages) { + return data.pages.flatMap(page => page.likes) + } + }, [data]) - useEffect(() => { - view - .loadMore() - .catch(err => logger.error('Failed to fetch likes', {error: err})) - }, [view]) + const onRefresh = useCallback(async () => { + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh likes', {error: err}) + } + setIsPTRing(false) + }, [refetch, setIsPTRing]) - const onRefresh = () => { - view.refresh() - } - const onEndReached = () => { - view - .loadMore() - .catch(err => logger.error('Failed to load more likes', {error: err})) - } + const onEndReached = useCallback(async () => { + if (isFetching || !hasNextPage || isError) return + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more likes', {error: err}) + } + }, [isFetching, hasNextPage, isError, fetchNextPage]) + + const renderItem = useCallback(({item}: {item: GetLikes.Like}) => { + return ( + <ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} /> + ) + }, []) - if (!view.hasLoaded) { + if (isFetchingResolvedUri || !isFetched) { return ( <CenteredView> <ActivityIndicator /> @@ -43,26 +70,26 @@ export const PostLikedBy = observer(function PostLikedByImpl({ // error // = - if (view.hasError) { + if (resolveError || isError) { return ( <CenteredView> - <ErrorMessage message={view.error} onPressTryAgain={onRefresh} /> + <ErrorMessage + message={cleanError(resolveError || error)} + onPressTryAgain={onRefresh} + /> </CenteredView> ) } // loaded // = - const renderItem = ({item}: {item: LikeItem}) => ( - <ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} /> - ) return ( <FlatList - data={view.likes} + data={likes} keyExtractor={item => item.actor.did} refreshControl={ <RefreshControl - refreshing={view.isRefreshing} + refreshing={isPTRing} onRefresh={onRefresh} tintColor={pal.colors.text} titleColor={pal.colors.text} @@ -75,15 +102,14 @@ export const PostLikedBy = observer(function PostLikedByImpl({ // eslint-disable-next-line react/no-unstable-nested-components ListFooterComponent={() => ( <View style={styles.footer}> - {view.isLoading && <ActivityIndicator />} + {(isFetching || isFetchingNextPage) && <ActivityIndicator />} </View> )} - extraData={view.isLoading} // @ts-ignore our .web version only -prf desktopFixedHeight /> ) -}) +} const styles = StyleSheet.create({ footer: { diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx index 29a795302..1162fec40 100644 --- a/src/view/com/post-thread/PostRepostedBy.tsx +++ b/src/view/com/post-thread/PostRepostedBy.tsx @@ -1,42 +1,67 @@ -import React, {useEffect} from 'react' -import {observer} from 'mobx-react-lite' +import React, {useMemo, useCallback, useState} from 'react' import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' +import {AppBskyActorDefs as ActorDefs} from '@atproto/api' import {CenteredView, FlatList} from '../util/Views' -import {RepostedByModel, RepostedByItem} from 'state/models/lists/reposted-by' import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' import {ErrorMessage} from '../util/error/ErrorMessage' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {logger} from '#/logger' +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import {usePostRepostedByQuery} from '#/state/queries/post-reposted-by' +import {cleanError} from '#/lib/strings/errors' -export const PostRepostedBy = observer(function PostRepostedByImpl({ - uri, -}: { - uri: string -}) { +export function PostRepostedBy({uri}: {uri: string}) { const pal = usePalette('default') - const store = useStores() - const view = React.useMemo( - () => new RepostedByModel(store, {uri}), - [store, uri], - ) + const [isPTRing, setIsPTRing] = useState(false) + const { + data: resolvedUri, + error: resolveError, + isFetching: isFetchingResolvedUri, + } = useResolveUriQuery(uri) + const { + data, + isFetching, + isFetched, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + isError, + error, + refetch, + } = usePostRepostedByQuery(resolvedUri?.uri) + const repostedBy = useMemo(() => { + if (data?.pages) { + return data.pages.flatMap(page => page.repostedBy) + } + }, [data]) - useEffect(() => { - view - .loadMore() - .catch(err => logger.error('Failed to fetch reposts', {error: err})) - }, [view]) + const onRefresh = useCallback(async () => { + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh reposts', {error: err}) + } + setIsPTRing(false) + }, [refetch, setIsPTRing]) - const onRefresh = () => { - view.refresh() - } - const onEndReached = () => { - view - .loadMore() - .catch(err => logger.error('Failed to load more reposts', {error: err})) - } + const onEndReached = useCallback(async () => { + if (isFetching || !hasNextPage || isError) return + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more reposts', {error: err}) + } + }, [isFetching, hasNextPage, isError, fetchNextPage]) + + const renderItem = useCallback( + ({item}: {item: ActorDefs.ProfileViewBasic}) => { + return <ProfileCardWithFollowBtn key={item.did} profile={item} /> + }, + [], + ) - if (!view.hasLoaded) { + if (isFetchingResolvedUri || !isFetched) { return ( <CenteredView> <ActivityIndicator /> @@ -46,26 +71,26 @@ export const PostRepostedBy = observer(function PostRepostedByImpl({ // error // = - if (view.hasError) { + if (resolveError || isError) { return ( <CenteredView> - <ErrorMessage message={view.error} onPressTryAgain={onRefresh} /> + <ErrorMessage + message={cleanError(resolveError || error)} + onPressTryAgain={onRefresh} + /> </CenteredView> ) } // loaded // = - const renderItem = ({item}: {item: RepostedByItem}) => ( - <ProfileCardWithFollowBtn key={item.did} profile={item} /> - ) return ( <FlatList - data={view.repostedBy} + data={repostedBy} keyExtractor={item => item.did} refreshControl={ <RefreshControl - refreshing={view.isRefreshing} + refreshing={isPTRing} onRefresh={onRefresh} tintColor={pal.colors.text} titleColor={pal.colors.text} @@ -78,15 +103,14 @@ export const PostRepostedBy = observer(function PostRepostedByImpl({ // eslint-disable-next-line react/no-unstable-nested-components ListFooterComponent={() => ( <View style={styles.footer}> - {view.isLoading && <ActivityIndicator />} + {(isFetching || isFetchingNextPage) && <ActivityIndicator />} </View> )} - extraData={view.isLoading} // @ts-ignore our .web version only -prf desktopFixedHeight /> ) -}) +} const styles = StyleSheet.create({ footer: { diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 4eb47b0a3..edf02e9c5 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -1,6 +1,4 @@ import React, {useRef} from 'react' -import {runInAction} from 'mobx' -import {observer} from 'mobx-react-lite' import { ActivityIndicator, Pressable, @@ -11,8 +9,6 @@ import { } from 'react-native' import {AppBskyFeedDefs} from '@atproto/api' import {CenteredView, FlatList} from '../util/Views' -import {PostThreadModel} from 'state/models/content/post-thread' -import {PostThreadItemModel} from 'state/models/content/post-thread-item' import { FontAwesomeIcon, FontAwesomeIconStyle, @@ -23,43 +19,42 @@ import {ViewHeader} from '../util/ViewHeader' import {ErrorMessage} from '../util/error/ErrorMessage' import {Text} from '../util/text/Text' import {s} from 'lib/styles' -import {isNative} from 'platform/detection' import {usePalette} from 'lib/hooks/usePalette' import {useSetTitle} from 'lib/hooks/useSetTitle' +import { + ThreadNode, + ThreadPost, + usePostThreadQuery, + sortThread, +} from '#/state/queries/post-thread' import {useNavigation} from '@react-navigation/native' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {NavigationProp} from 'lib/routes/types' import {sanitizeDisplayName} from 'lib/strings/display-names' +import {cleanError} from '#/lib/strings/errors' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import { + UsePreferencesQueryResponse, + usePreferencesQuery, +} from '#/state/queries/preferences' +import {useSession} from '#/state/session' +import {isNative} from '#/platform/detection' import {logger} from '#/logger' const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2} -const TOP_COMPONENT = { - _reactKey: '__top_component__', - _isHighlightedPost: false, -} -const PARENT_SPINNER = { - _reactKey: '__parent_spinner__', - _isHighlightedPost: false, -} -const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false} -const DELETED = {_reactKey: '__deleted__', _isHighlightedPost: false} -const BLOCKED = {_reactKey: '__blocked__', _isHighlightedPost: false} -const CHILD_SPINNER = { - _reactKey: '__child_spinner__', - _isHighlightedPost: false, -} -const LOAD_MORE = { - _reactKey: '__load_more__', - _isHighlightedPost: false, -} -const BOTTOM_COMPONENT = { - _reactKey: '__bottom_component__', - _isHighlightedPost: false, - _showBorder: true, -} +const TOP_COMPONENT = {_reactKey: '__top_component__'} +const PARENT_SPINNER = {_reactKey: '__parent_spinner__'} +const REPLY_PROMPT = {_reactKey: '__reply__'} +const DELETED = {_reactKey: '__deleted__'} +const BLOCKED = {_reactKey: '__blocked__'} +const CHILD_SPINNER = {_reactKey: '__child_spinner__'} +const LOAD_MORE = {_reactKey: '__load_more__'} +const BOTTOM_COMPONENT = {_reactKey: '__bottom_component__'} + type YieldedItem = - | PostThreadItemModel + | ThreadPost | typeof TOP_COMPONENT | typeof PARENT_SPINNER | typeof REPLY_PROMPT @@ -67,127 +62,161 @@ type YieldedItem = | typeof BLOCKED | typeof PARENT_SPINNER -export const PostThread = observer(function PostThread({ +export function PostThread({ uri, - view, onPressReply, - treeView, }: { - uri: string - view: PostThreadModel + uri: string | undefined + onPressReply: () => void +}) { + const { + isLoading, + isError, + error, + refetch, + data: thread, + } = usePostThreadQuery(uri) + const {data: preferences} = usePreferencesQuery() + const rootPost = thread?.type === 'post' ? thread.post : undefined + const rootPostRecord = thread?.type === 'post' ? thread.record : undefined + + useSetTitle( + rootPost && + `${sanitizeDisplayName( + rootPost.author.displayName || `@${rootPost.author.handle}`, + )}: "${rootPostRecord?.text}"`, + ) + + if (isError || AppBskyFeedDefs.isNotFoundPost(thread)) { + return ( + <PostThreadError + error={error} + notFound={AppBskyFeedDefs.isNotFoundPost(thread)} + onRefresh={refetch} + /> + ) + } + if (AppBskyFeedDefs.isBlockedPost(thread)) { + return <PostThreadBlocked /> + } + if (!thread || isLoading || !preferences) { + return ( + <CenteredView> + <View style={s.p20}> + <ActivityIndicator size="large" /> + </View> + </CenteredView> + ) + } + return ( + <PostThreadLoaded + thread={thread} + threadViewPrefs={preferences.threadViewPrefs} + onRefresh={refetch} + onPressReply={onPressReply} + /> + ) +} + +function PostThreadLoaded({ + thread, + threadViewPrefs, + onRefresh, + onPressReply, +}: { + thread: ThreadNode + threadViewPrefs: UsePreferencesQueryResponse['threadViewPrefs'] + onRefresh: () => void onPressReply: () => void - treeView: boolean }) { + const {hasSession} = useSession() + const {_} = useLingui() const pal = usePalette('default') const {isTablet, isDesktop} = useWebMediaQueries() const ref = useRef<FlatList>(null) - const hasScrolledIntoView = useRef<boolean>(false) - const [isRefreshing, setIsRefreshing] = React.useState(false) + const highlightedPostRef = useRef<View | null>(null) + const needsScrollAdjustment = useRef<boolean>( + !isNative || // web always uses scroll adjustment + (thread.type === 'post' && !thread.ctx.isParentLoading), // native only does it when not loading from placeholder + ) const [maxVisible, setMaxVisible] = React.useState(100) - const navigation = useNavigation<NavigationProp>() + const [isPTRing, setIsPTRing] = React.useState(false) + + // construct content const posts = React.useMemo(() => { - if (view.thread) { - let arr = [TOP_COMPONENT].concat(Array.from(flattenThread(view.thread))) - if (arr.length > maxVisible) { - arr = arr.slice(0, maxVisible).concat([LOAD_MORE]) - } - if (view.isLoadingFromCache) { - if (view.thread?.postRecord?.reply) { - arr.unshift(PARENT_SPINNER) - } - arr.push(CHILD_SPINNER) - } else { - arr.push(BOTTOM_COMPONENT) - } - return arr + let arr = [TOP_COMPONENT].concat( + Array.from(flattenThreadSkeleton(sortThread(thread, threadViewPrefs))), + ) + if (arr.length > maxVisible) { + arr = arr.slice(0, maxVisible).concat([LOAD_MORE]) } - return [] - }, [view.isLoadingFromCache, view.thread, maxVisible]) - const highlightedPostIndex = posts.findIndex(post => post._isHighlightedPost) - useSetTitle( - view.thread?.postRecord && - `${sanitizeDisplayName( - view.thread.post.author.displayName || - `@${view.thread.post.author.handle}`, - )}: "${view.thread?.postRecord?.text}"`, - ) - - // events - // = - - const onRefresh = React.useCallback(async () => { - setIsRefreshing(true) - try { - view?.refresh() - } catch (err) { - logger.error('Failed to refresh posts thread', {error: err}) + if (arr.indexOf(CHILD_SPINNER) === -1) { + arr.push(BOTTOM_COMPONENT) } - setIsRefreshing(false) - }, [view, setIsRefreshing]) + return arr + }, [thread, maxVisible, threadViewPrefs]) + /** + * NOTE + * Scroll positioning + * + * This callback is run if needsScrollAdjustment.current == true, which is... + * - On web: always + * - On native: when the placeholder cache is not being used + * + * It then only runs when viewing a reply, and the goal is to scroll the + * reply into view. + * + * On native, if the placeholder cache is being used then maintainVisibleContentPosition + * is a more effective solution, so we use that. Otherwise, typically we're loading from + * the react-query cache, so we just need to immediately scroll down to the post. + * + * On desktop, maintainVisibleContentPosition isn't supported so we just always use + * this technique. + * + * -prf + */ const onContentSizeChange = React.useCallback(() => { // only run once - if (hasScrolledIntoView.current) { + if (!needsScrollAdjustment.current) { return } // wait for loading to finish - if ( - !view.hasContent || - (view.isFromCache && view.isLoadingFromCache) || - view.isLoading - ) { - return + if (thread.type === 'post' && !!thread.parent) { + highlightedPostRef.current?.measure( + (_x, _y, _width, _height, _pageX, pageY) => { + ref.current?.scrollToOffset({ + animated: false, + offset: pageY - (isDesktop ? 0 : 50), + }) + }, + ) + needsScrollAdjustment.current = false } + }, [thread, isDesktop]) - if (highlightedPostIndex !== -1) { - ref.current?.scrollToIndex({ - index: highlightedPostIndex, - animated: false, - viewPosition: 0, - }) - hasScrolledIntoView.current = true - } - }, [ - highlightedPostIndex, - view.hasContent, - view.isFromCache, - view.isLoadingFromCache, - view.isLoading, - ]) - const onScrollToIndexFailed = React.useCallback( - (info: { - index: number - highestMeasuredFrameIndex: number - averageItemLength: number - }) => { - ref.current?.scrollToOffset({ - animated: false, - offset: info.averageItemLength * info.index, - }) - }, - [ref], - ) - - const onPressBack = React.useCallback(() => { - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Home') + const onPTR = React.useCallback(async () => { + setIsPTRing(true) + try { + await onRefresh() + } catch (err) { + logger.error('Failed to refresh posts thread', {error: err}) } - }, [navigation]) + setIsPTRing(false) + }, [setIsPTRing, onRefresh]) const renderItem = React.useCallback( ({item, index}: {item: YieldedItem; index: number}) => { if (item === TOP_COMPONENT) { - return isTablet ? <ViewHeader title="Post" /> : null + return isTablet ? <ViewHeader title={_(msg`Post`)} /> : null } else if (item === PARENT_SPINNER) { return ( <View style={styles.parentSpinner}> <ActivityIndicator /> </View> ) - } else if (item === REPLY_PROMPT) { + } else if (item === REPLY_PROMPT && hasSession) { return ( <View> {isDesktop && <ComposePrompt onPressCompose={onPressReply} />} @@ -197,7 +226,7 @@ export const PostThread = observer(function PostThread({ return ( <View style={[pal.border, pal.viewLight, styles.itemContainer]}> <Text type="lg-bold" style={pal.textLight}> - Deleted post. + <Trans>Deleted post.</Trans> </Text> </View> ) @@ -205,7 +234,7 @@ export const PostThread = observer(function PostThread({ return ( <View style={[pal.border, pal.viewLight, styles.itemContainer]}> <Text type="lg-bold" style={pal.textLight}> - Blocked post. + <Trans>Blocked post.</Trans> </Text> </View> ) @@ -214,7 +243,7 @@ export const PostThread = observer(function PostThread({ <Pressable onPress={() => setMaxVisible(n => n + 50)} style={[pal.border, pal.view, styles.itemContainer]} - accessibilityLabel="Load more posts" + accessibilityLabel={_(msg`Load more posts`)} accessibilityHint=""> <View style={[ @@ -222,7 +251,7 @@ export const PostThread = observer(function PostThread({ {paddingHorizontal: 18, paddingVertical: 14, borderRadius: 6}, ]}> <Text type="lg-medium" style={pal.text}> - Load more posts + <Trans>Load more posts</Trans> </Text> </View> </Pressable> @@ -247,22 +276,32 @@ export const PostThread = observer(function PostThread({ <ActivityIndicator /> </View> ) - } else if (item instanceof PostThreadItemModel) { - const prev = ( - index - 1 >= 0 ? posts[index - 1] : undefined - ) as PostThreadItemModel + } else if (isThreadPost(item)) { + const prev = isThreadPost(posts[index - 1]) + ? (posts[index - 1] as ThreadPost) + : undefined return ( - <PostThreadItem - item={item} - onPostReply={onRefresh} - hasPrecedingItem={prev?._showChildReplyLine} - treeView={treeView} - /> + <View + ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}> + <PostThreadItem + post={item.post} + record={item.record} + treeView={threadViewPrefs.lab_treeViewEnabled || false} + depth={item.ctx.depth} + isHighlightedPost={item.ctx.isHighlightedPost} + hasMore={item.ctx.hasMore} + showChildReplyLine={item.ctx.showChildReplyLine} + showParentReplyLine={item.ctx.showParentReplyLine} + hasPrecedingItem={!!prev?.ctx.showChildReplyLine} + onPostReply={onRefresh} + /> + </View> ) } - return <></> + return null }, [ + hasSession, isTablet, isDesktop, onPressReply, @@ -274,77 +313,117 @@ export const PostThread = observer(function PostThread({ pal.colors.border, posts, onRefresh, - treeView, + threadViewPrefs.lab_treeViewEnabled, + _, ], ) - // loading - // = - if ( - !view.hasLoaded || - (view.isLoading && !view.isRefreshing) || - view.params.uri !== uri - ) { - return ( - <CenteredView> - <View style={s.p20}> - <ActivityIndicator size="large" /> - </View> - </CenteredView> - ) - } + return ( + <FlatList + ref={ref} + data={posts} + initialNumToRender={posts.length} + maintainVisibleContentPosition={ + !needsScrollAdjustment.current + ? MAINTAIN_VISIBLE_CONTENT_POSITION + : undefined + } + keyExtractor={item => item._reactKey} + renderItem={renderItem} + refreshControl={ + <RefreshControl + refreshing={isPTRing} + onRefresh={onPTR} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + /> + } + onContentSizeChange={onContentSizeChange} + style={s.hContentRegion} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> + ) +} - // error - // = - if (view.hasError) { - if (view.notFound) { - return ( - <CenteredView> - <View style={[pal.view, pal.border, styles.notFoundContainer]}> - <Text type="title-lg" style={[pal.text, s.mb5]}> - Post not found - </Text> - <Text type="md" style={[pal.text, s.mb10]}> - The post may have been deleted. - </Text> - <TouchableOpacity - onPress={onPressBack} - accessibilityRole="button" - accessibilityLabel="Back" - accessibilityHint=""> - <Text type="2xl" style={pal.link}> - <FontAwesomeIcon - icon="angle-left" - style={[pal.link as FontAwesomeIconStyle, s.mr5]} - size={14} - /> - Back - </Text> - </TouchableOpacity> - </View> - </CenteredView> - ) +function PostThreadBlocked() { + const {_} = useLingui() + const pal = usePalette('default') + const navigation = useNavigation<NavigationProp>() + + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') } - return ( - <CenteredView> - <ErrorMessage message={view.error} onPressTryAgain={onRefresh} /> - </CenteredView> - ) - } - if (view.isBlocked) { + }, [navigation]) + + return ( + <CenteredView> + <View style={[pal.view, pal.border, styles.notFoundContainer]}> + <Text type="title-lg" style={[pal.text, s.mb5]}> + <Trans>Post hidden</Trans> + </Text> + <Text type="md" style={[pal.text, s.mb10]}> + <Trans> + You have blocked the author or you have been blocked by the author. + </Trans> + </Text> + <TouchableOpacity + onPress={onPressBack} + accessibilityRole="button" + accessibilityLabel={_(msg`Back`)} + accessibilityHint=""> + <Text type="2xl" style={pal.link}> + <FontAwesomeIcon + icon="angle-left" + style={[pal.link as FontAwesomeIconStyle, s.mr5]} + size={14} + /> + Back + </Text> + </TouchableOpacity> + </View> + </CenteredView> + ) +} + +function PostThreadError({ + onRefresh, + notFound, + error, +}: { + onRefresh: () => void + notFound: boolean + error: Error | null +}) { + const {_} = useLingui() + const pal = usePalette('default') + const navigation = useNavigation<NavigationProp>() + + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + + if (notFound) { return ( <CenteredView> <View style={[pal.view, pal.border, styles.notFoundContainer]}> <Text type="title-lg" style={[pal.text, s.mb5]}> - Post hidden + <Trans>Post not found</Trans> </Text> <Text type="md" style={[pal.text, s.mb10]}> - You have blocked the author or you have been blocked by the author. + <Trans>The post may have been deleted.</Trans> </Text> <TouchableOpacity onPress={onPressBack} accessibilityRole="button" - accessibilityLabel="Back" + accessibilityLabel={_(msg`Back`)} accessibilityHint=""> <Text type="2xl" style={pal.link}> <FontAwesomeIcon @@ -352,76 +431,48 @@ export const PostThread = observer(function PostThread({ style={[pal.link as FontAwesomeIconStyle, s.mr5]} size={14} /> - Back + <Trans>Back</Trans> </Text> </TouchableOpacity> </View> </CenteredView> ) } - - // loaded - // = return ( - <FlatList - ref={ref} - data={posts} - initialNumToRender={posts.length} - maintainVisibleContentPosition={ - isNative && view.isFromCache && view.isCachedPostAReply - ? MAINTAIN_VISIBLE_CONTENT_POSITION - : undefined - } - keyExtractor={item => item._reactKey} - renderItem={renderItem} - refreshControl={ - <RefreshControl - refreshing={isRefreshing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } - onContentSizeChange={ - isNative && view.isFromCache ? undefined : onContentSizeChange - } - onScrollToIndexFailed={onScrollToIndexFailed} - style={s.hContentRegion} - // @ts-ignore our .web version only -prf - desktopFixedHeight - /> + <CenteredView> + <ErrorMessage message={cleanError(error)} onPressTryAgain={onRefresh} /> + </CenteredView> ) -}) +} + +function isThreadPost(v: unknown): v is ThreadPost { + return !!v && typeof v === 'object' && 'type' in v && v.type === 'post' +} -function* flattenThread( - post: PostThreadItemModel, - isAscending = false, +function* flattenThreadSkeleton( + node: ThreadNode, ): Generator<YieldedItem, void> { - if (post.parent) { - if (AppBskyFeedDefs.isNotFoundPost(post.parent)) { - yield DELETED - } else if (AppBskyFeedDefs.isBlockedPost(post.parent)) { - yield BLOCKED - } else { - yield* flattenThread(post.parent as PostThreadItemModel, true) + if (node.type === 'post') { + if (node.parent) { + yield* flattenThreadSkeleton(node.parent) + } else if (node.ctx.isParentLoading) { + yield PARENT_SPINNER } - } - yield post - if (post._isHighlightedPost) { - yield REPLY_PROMPT - } - if (post.replies?.length) { - for (const reply of post.replies) { - if (AppBskyFeedDefs.isNotFoundPost(reply)) { - yield DELETED - } else { - yield* flattenThread(reply as PostThreadItemModel) + yield node + if (node.ctx.isHighlightedPost) { + yield REPLY_PROMPT + } + if (node.replies?.length) { + for (const reply of node.replies) { + yield* flattenThreadSkeleton(reply) } + } else if (node.ctx.isChildLoading) { + yield CHILD_SPINNER } - } else if (!isAscending && !post.parent && post.post.replyCount) { - runInAction(() => { - post._hasMore = true - }) + } else if (node.type === 'not-found') { + yield DELETED + } else if (node.type === 'blocked') { + yield BLOCKED } } diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 351a46706..a4b7a4a9c 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -1,18 +1,17 @@ -import React, {useMemo} from 'react' -import {observer} from 'mobx-react-lite' -import {Linking, StyleSheet, View} from 'react-native' -import Clipboard from '@react-native-clipboard/clipboard' -import {AtUri, AppBskyFeedDefs} from '@atproto/api' +import React, {memo, useMemo} from 'react' +import {StyleSheet, View} from 'react-native' import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {PostThreadItemModel} from 'state/models/content/post-thread-item' + AtUri, + AppBskyFeedDefs, + AppBskyFeedPost, + RichText as RichTextAPI, + moderatePost, + PostModeration, +} from '@atproto/api' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Link, TextLink} from '../util/Link' import {RichText} from '../util/text/RichText' import {Text} from '../util/text/Text' -import {PostDropdownBtn} from '../util/forms/PostDropdownBtn' -import * as Toast from '../util/Toast' import {PreviewableUserAvatar} from '../util/UserAvatar' import {s} from 'lib/styles' import {niceDate} from 'lib/strings/time' @@ -21,10 +20,10 @@ import {sanitizeHandle} from 'lib/strings/handles' import {countLines, pluralize} from 'lib/strings/helpers' import {isEmbedByEmbedder} from 'lib/embeds' import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers' -import {useStores} from 'state/index' import {PostMeta} from '../util/PostMeta' import {PostEmbeds} from '../util/post-embeds' import {PostCtrls} from '../util/post-ctrls/PostCtrls' +import {PostDropdownBtn} from '../util/forms/PostDropdownBtn' import {PostHider} from '../util/moderation/PostHider' import {ContentHider} from '../util/moderation/ContentHider' import {PostAlerts} from '../util/moderation/PostAlerts' @@ -36,125 +35,172 @@ import {TimeElapsed} from 'view/com/util/TimeElapsed' import {makeProfileLink} from 'lib/routes/links' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {MAX_POST_LINES} from 'lib/constants' -import {logger} from '#/logger' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useLanguagePrefs} from '#/state/preferences' +import {useComposerControls} from '#/state/shell/composer' +import {useModerationOpts} from '#/state/queries/preferences' +import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow' -export const PostThreadItem = observer(function PostThreadItem({ - item, - onPostReply, - hasPrecedingItem, +export function PostThreadItem({ + post, + record, treeView, + depth, + isHighlightedPost, + hasMore, + showChildReplyLine, + showParentReplyLine, + hasPrecedingItem, + onPostReply, }: { - item: PostThreadItemModel - onPostReply: () => void - hasPrecedingItem: boolean + post: AppBskyFeedDefs.PostView + record: AppBskyFeedPost.Record treeView: boolean + depth: number + isHighlightedPost?: boolean + hasMore?: boolean + showChildReplyLine?: boolean + showParentReplyLine?: boolean + hasPrecedingItem: boolean + onPostReply: () => void }) { + const moderationOpts = useModerationOpts() + const postShadowed = usePostShadow(post) + const richText = useMemo( + () => + new RichTextAPI({ + text: record.text, + facets: record.facets, + }), + [record], + ) + const moderation = useMemo( + () => + post && moderationOpts ? moderatePost(post, moderationOpts) : undefined, + [post, moderationOpts], + ) + if (postShadowed === POST_TOMBSTONE) { + return <PostThreadItemDeleted /> + } + if (richText && moderation) { + return ( + <PostThreadItemLoaded + post={postShadowed} + record={record} + richText={richText} + moderation={moderation} + treeView={treeView} + depth={depth} + isHighlightedPost={isHighlightedPost} + hasMore={hasMore} + showChildReplyLine={showChildReplyLine} + showParentReplyLine={showParentReplyLine} + hasPrecedingItem={hasPrecedingItem} + onPostReply={onPostReply} + /> + ) + } + return null +} + +function PostThreadItemDeleted() { + const styles = useStyles() + const pal = usePalette('default') + return ( + <View style={[styles.outer, pal.border, pal.view, s.p20, s.flexRow]}> + <FontAwesomeIcon icon={['far', 'trash-can']} color={pal.colors.icon} /> + <Text style={[pal.textLight, s.ml10]}> + <Trans>This post has been deleted.</Trans> + </Text> + </View> + ) +} + +let PostThreadItemLoaded = ({ + post, + record, + richText, + moderation, + treeView, + depth, + isHighlightedPost, + hasMore, + showChildReplyLine, + showParentReplyLine, + hasPrecedingItem, + onPostReply, +}: { + post: Shadow<AppBskyFeedDefs.PostView> + record: AppBskyFeedPost.Record + richText: RichTextAPI + moderation: PostModeration + treeView: boolean + depth: number + isHighlightedPost?: boolean + hasMore?: boolean + showChildReplyLine?: boolean + showParentReplyLine?: boolean + hasPrecedingItem: boolean + onPostReply: () => void +}): React.ReactNode => { const pal = usePalette('default') - const store = useStores() - const [deleted, setDeleted] = React.useState(false) + const langPrefs = useLanguagePrefs() + const {openComposer} = useComposerControls() const [limitLines, setLimitLines] = React.useState( - countLines(item.richText?.text) >= MAX_POST_LINES, + () => countLines(richText?.text) >= MAX_POST_LINES, ) const styles = useStyles() - const record = item.postRecord - const hasEngagement = item.post.likeCount || item.post.repostCount + const hasEngagement = post.likeCount || post.repostCount - const itemUri = item.post.uri - const itemCid = item.post.cid - const itemHref = React.useMemo(() => { - const urip = new AtUri(item.post.uri) - return makeProfileLink(item.post.author, 'post', urip.rkey) - }, [item.post.uri, item.post.author]) - const itemTitle = `Post by ${item.post.author.handle}` - const authorHref = makeProfileLink(item.post.author) - const authorTitle = item.post.author.handle - const isAuthorMuted = item.post.author.viewer?.muted + const rootUri = record.reply?.root?.uri || post.uri + const postHref = React.useMemo(() => { + const urip = new AtUri(post.uri) + return makeProfileLink(post.author, 'post', urip.rkey) + }, [post.uri, post.author]) + const itemTitle = `Post by ${post.author.handle}` + const authorHref = makeProfileLink(post.author) + const authorTitle = post.author.handle + const isAuthorMuted = post.author.viewer?.muted const likesHref = React.useMemo(() => { - const urip = new AtUri(item.post.uri) - return makeProfileLink(item.post.author, 'post', urip.rkey, 'liked-by') - }, [item.post.uri, item.post.author]) + const urip = new AtUri(post.uri) + return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by') + }, [post.uri, post.author]) const likesTitle = 'Likes on this post' const repostsHref = React.useMemo(() => { - const urip = new AtUri(item.post.uri) - return makeProfileLink(item.post.author, 'post', urip.rkey, 'reposted-by') - }, [item.post.uri, item.post.author]) + const urip = new AtUri(post.uri) + return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by') + }, [post.uri, post.author]) const repostsTitle = 'Reposts of this post' const translatorUrl = getTranslatorLink( record?.text || '', - store.preferences.primaryLanguage, + langPrefs.primaryLanguage, ) const needsTranslation = useMemo( () => Boolean( - store.preferences.primaryLanguage && - !isPostInLanguage(item.post, [store.preferences.primaryLanguage]), + langPrefs.primaryLanguage && + !isPostInLanguage(post, [langPrefs.primaryLanguage]), ), - [item.post, store.preferences.primaryLanguage], + [post, langPrefs.primaryLanguage], ) const onPressReply = React.useCallback(() => { - store.shell.openComposer({ + openComposer({ replyTo: { - uri: item.post.uri, - cid: item.post.cid, - text: record?.text as string, + uri: post.uri, + cid: post.cid, + text: record.text, author: { - handle: item.post.author.handle, - displayName: item.post.author.displayName, - avatar: item.post.author.avatar, + handle: post.author.handle, + displayName: post.author.displayName, + avatar: post.author.avatar, }, }, onPost: onPostReply, }) - }, [store, item, record, onPostReply]) - - const onPressToggleRepost = React.useCallback(() => { - return item - .toggleRepost() - .catch(e => logger.error('Failed to toggle repost', {error: e})) - }, [item]) - - const onPressToggleLike = React.useCallback(() => { - return item - .toggleLike() - .catch(e => logger.error('Failed to toggle like', {error: e})) - }, [item]) - - const onCopyPostText = React.useCallback(() => { - Clipboard.setString(record?.text || '') - Toast.show('Copied to clipboard') - }, [record]) - - const onOpenTranslate = React.useCallback(() => { - Linking.openURL(translatorUrl) - }, [translatorUrl]) - - const onToggleThreadMute = React.useCallback(async () => { - try { - await item.toggleThreadMute() - if (item.isThreadMuted) { - Toast.show('You will no longer receive notifications for this thread') - } else { - Toast.show('You will now receive notifications for this thread') - } - } catch (e) { - logger.error('Failed to toggle thread mute', {error: e}) - } - }, [item]) - - const onDeletePost = React.useCallback(() => { - item.delete().then( - () => { - setDeleted(true) - Toast.show('Post deleted') - }, - e => { - logger.error('Failed to delete post', {error: e}) - Toast.show('Failed to delete post, please try again') - }, - ) - }, [item]) + }, [openComposer, post, record, onPostReply]) const onPressShowMore = React.useCallback(() => { setLimitLines(false) @@ -164,22 +210,10 @@ export const PostThreadItem = observer(function PostThreadItem({ return <ErrorMessage message="Invalid or unsupported post record" /> } - if (deleted) { - return ( - <View style={[styles.outer, pal.border, pal.view, s.p20, s.flexRow]}> - <FontAwesomeIcon - icon={['far', 'trash-can']} - style={pal.icon as FontAwesomeIconStyle} - /> - <Text style={[pal.textLight, s.ml10]}>This post has been deleted.</Text> - </View> - ) - } - - if (item._isHighlightedPost) { + if (isHighlightedPost) { return ( <> - {item.rootUri !== item.uri && ( + {rootUri !== post.uri && ( <View style={{paddingLeft: 16, flexDirection: 'row', height: 16}}> <View style={{width: 38}}> <View @@ -196,7 +230,7 @@ export const PostThreadItem = observer(function PostThreadItem({ )} <Link - testID={`postThreadItem-by-${item.post.author.handle}`} + testID={`postThreadItem-by-${post.author.handle}`} style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]} noFeedback accessible={false}> @@ -205,10 +239,10 @@ export const PostThreadItem = observer(function PostThreadItem({ <View style={[styles.layoutAvi, {paddingBottom: 8}]}> <PreviewableUserAvatar size={52} - did={item.post.author.did} - handle={item.post.author.handle} - avatar={item.post.author.avatar} - moderation={item.moderation.avatar} + did={post.author.did} + handle={post.author.handle} + avatar={post.author.avatar} + moderation={moderation.avatar} /> </View> <View style={styles.layoutContent}> @@ -225,17 +259,17 @@ export const PostThreadItem = observer(function PostThreadItem({ numberOfLines={1} lineHeight={1.2}> {sanitizeDisplayName( - item.post.author.displayName || - sanitizeHandle(item.post.author.handle), + post.author.displayName || + sanitizeHandle(post.author.handle), )} </Text> </Link> - <TimeElapsed timestamp={item.post.indexedAt}> + <TimeElapsed timestamp={post.indexedAt}> {({timeElapsed}) => ( <Text type="md" style={[styles.metaItem, pal.textLight]} - title={niceDate(item.post.indexedAt)}> + title={niceDate(post.indexedAt)}> · {timeElapsed} </Text> )} @@ -272,23 +306,15 @@ export const PostThreadItem = observer(function PostThreadItem({ href={authorHref} title={authorTitle}> <Text type="md" style={[pal.textLight]} numberOfLines={1}> - {sanitizeHandle(item.post.author.handle, '@')} + {sanitizeHandle(post.author.handle, '@')} </Text> </Link> </View> </View> <PostDropdownBtn testID="postDropdownBtn" - itemUri={itemUri} - itemCid={itemCid} - itemHref={itemHref} - itemTitle={itemTitle} - isAuthor={item.post.author.did === store.me.did} - isThreadMuted={item.isThreadMuted} - onCopyPostText={onCopyPostText} - onOpenTranslate={onOpenTranslate} - onToggleThreadMute={onToggleThreadMute} - onDeletePost={onDeletePost} + post={post} + record={record} style={{ paddingVertical: 6, paddingHorizontal: 10, @@ -299,16 +325,16 @@ export const PostThreadItem = observer(function PostThreadItem({ </View> <View style={[s.pl10, s.pr10, s.pb10]}> <ContentHider - moderation={item.moderation.content} + moderation={moderation.content} ignoreMute style={styles.contentHider} childContainerStyle={styles.contentHiderChild}> <PostAlerts - moderation={item.moderation.content} + moderation={moderation.content} includeMute style={styles.alert} /> - {item.richText?.text ? ( + {richText?.text ? ( <View style={[ styles.postTextContainer, @@ -316,59 +342,56 @@ export const PostThreadItem = observer(function PostThreadItem({ ]}> <RichText type="post-text-lg" - richText={item.richText} + richText={richText} lineHeight={1.3} style={s.flex1} /> </View> ) : undefined} - {item.post.embed && ( + {post.embed && ( <ContentHider - moderation={item.moderation.embed} - ignoreMute={isEmbedByEmbedder( - item.post.embed, - item.post.author.did, - )} + moderation={moderation.embed} + ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)} style={s.mb10}> <PostEmbeds - embed={item.post.embed} - moderation={item.moderation.embed} + embed={post.embed} + moderation={moderation.embed} /> </ContentHider> )} </ContentHider> <ExpandedPostDetails - post={item.post} + post={post} translatorUrl={translatorUrl} needsTranslation={needsTranslation} /> {hasEngagement ? ( <View style={[styles.expandedInfo, pal.border]}> - {item.post.repostCount ? ( + {post.repostCount ? ( <Link style={styles.expandedInfoItem} href={repostsHref} title={repostsTitle}> <Text testID="repostCount" type="lg" style={pal.textLight}> <Text type="xl-bold" style={pal.text}> - {formatCount(item.post.repostCount)} + {formatCount(post.repostCount)} </Text>{' '} - {pluralize(item.post.repostCount, 'repost')} + {pluralize(post.repostCount, 'repost')} </Text> </Link> ) : ( <></> )} - {item.post.likeCount ? ( + {post.likeCount ? ( <Link style={styles.expandedInfoItem} href={likesHref} title={likesTitle}> <Text testID="likeCount" type="lg" style={pal.textLight}> <Text type="xl-bold" style={pal.text}> - {formatCount(item.post.likeCount)} + {formatCount(post.likeCount)} </Text>{' '} - {pluralize(item.post.likeCount, 'like')} + {pluralize(post.likeCount, 'like')} </Text> </Link> ) : ( @@ -381,24 +404,9 @@ export const PostThreadItem = observer(function PostThreadItem({ <View style={[s.pl10, s.pb5]}> <PostCtrls big - itemUri={itemUri} - itemCid={itemCid} - itemHref={itemHref} - itemTitle={itemTitle} - author={item.post.author} - text={item.richText?.text || record.text} - indexedAt={item.post.indexedAt} - isAuthor={item.post.author.did === store.me.did} - isReposted={!!item.post.viewer?.repost} - isLiked={!!item.post.viewer?.like} - isThreadMuted={item.isThreadMuted} + post={post} + record={record} onPressReply={onPressReply} - onPressToggleRepost={onPressToggleRepost} - onPressToggleLike={onPressToggleLike} - onCopyPostText={onCopyPostText} - onOpenTranslate={onOpenTranslate} - onToggleThreadMute={onToggleThreadMute} - onDeletePost={onDeletePost} /> </View> </View> @@ -406,17 +414,19 @@ export const PostThreadItem = observer(function PostThreadItem({ </> ) } else { - const isThreadedChild = treeView && item._depth > 1 + const isThreadedChild = treeView && depth > 1 return ( <PostOuterWrapper - item={item} - hasPrecedingItem={hasPrecedingItem} - treeView={treeView}> + post={post} + depth={depth} + showParentReplyLine={!!showParentReplyLine} + treeView={treeView} + hasPrecedingItem={hasPrecedingItem}> <PostHider - testID={`postThreadItem-by-${item.post.author.handle}`} - href={itemHref} + testID={`postThreadItem-by-${post.author.handle}`} + href={postHref} style={[pal.view]} - moderation={item.moderation.content}> + moderation={moderation.content}> <PostSandboxWarning /> <View @@ -427,7 +437,7 @@ export const PostThreadItem = observer(function PostThreadItem({ height: isThreadedChild ? 8 : 16, }}> <View style={{width: 38}}> - {!isThreadedChild && item._showParentReplyLine && ( + {!isThreadedChild && showParentReplyLine && ( <View style={[ styles.replyLine, @@ -446,21 +456,20 @@ export const PostThreadItem = observer(function PostThreadItem({ style={[ styles.layout, { - paddingBottom: - item._showChildReplyLine && !isThreadedChild ? 0 : 8, + paddingBottom: showChildReplyLine && !isThreadedChild ? 0 : 8, }, ]}> {!isThreadedChild && ( <View style={styles.layoutAvi}> <PreviewableUserAvatar size={38} - did={item.post.author.did} - handle={item.post.author.handle} - avatar={item.post.author.avatar} - moderation={item.moderation.avatar} + did={post.author.did} + handle={post.author.handle} + avatar={post.author.avatar} + moderation={moderation.avatar} /> - {item._showChildReplyLine && ( + {showChildReplyLine && ( <View style={[ styles.replyLine, @@ -477,10 +486,10 @@ export const PostThreadItem = observer(function PostThreadItem({ <View style={styles.layoutContent}> <PostMeta - author={item.post.author} - authorHasWarning={!!item.post.author.labels?.length} - timestamp={item.post.indexedAt} - postHref={itemHref} + author={post.author} + authorHasWarning={!!post.author.labels?.length} + timestamp={post.indexedAt} + postHref={postHref} showAvatar={isThreadedChild} avatarSize={26} displayNameType="md-bold" @@ -488,14 +497,14 @@ export const PostThreadItem = observer(function PostThreadItem({ style={isThreadedChild && s.mb5} /> <PostAlerts - moderation={item.moderation.content} + moderation={moderation.content} style={styles.alert} /> - {item.richText?.text ? ( + {richText?.text ? ( <View style={styles.postTextContainer}> <RichText type="post-text" - richText={item.richText} + richText={richText} style={[pal.text, s.flex1]} lineHeight={1.3} numberOfLines={limitLines ? MAX_POST_LINES : undefined} @@ -510,42 +519,24 @@ export const PostThreadItem = observer(function PostThreadItem({ href="#" /> ) : undefined} - {item.post.embed && ( + {post.embed && ( <ContentHider style={styles.contentHider} - moderation={item.moderation.embed}> + moderation={moderation.embed}> <PostEmbeds - embed={item.post.embed} - moderation={item.moderation.embed} + embed={post.embed} + moderation={moderation.embed} /> </ContentHider> )} <PostCtrls - itemUri={itemUri} - itemCid={itemCid} - itemHref={itemHref} - itemTitle={itemTitle} - author={item.post.author} - text={item.richText?.text || record.text} - indexedAt={item.post.indexedAt} - isAuthor={item.post.author.did === store.me.did} - replyCount={item.post.replyCount} - repostCount={item.post.repostCount} - likeCount={item.post.likeCount} - isReposted={!!item.post.viewer?.repost} - isLiked={!!item.post.viewer?.like} - isThreadMuted={item.isThreadMuted} + post={post} + record={record} onPressReply={onPressReply} - onPressToggleRepost={onPressToggleRepost} - onPressToggleLike={onPressToggleLike} - onCopyPostText={onCopyPostText} - onOpenTranslate={onOpenTranslate} - onToggleThreadMute={onToggleThreadMute} - onDeletePost={onDeletePost} /> </View> </View> - {item._hasMore ? ( + {hasMore ? ( <Link style={[ styles.loadMore, @@ -555,7 +546,7 @@ export const PostThreadItem = observer(function PostThreadItem({ paddingBottom: treeView ? 4 : 12, }, ]} - href={itemHref} + href={postHref} title={itemTitle} noFeedback> <Text type="sm-medium" style={pal.textLight}> @@ -572,22 +563,27 @@ export const PostThreadItem = observer(function PostThreadItem({ </PostOuterWrapper> ) } -}) +} +PostThreadItemLoaded = memo(PostThreadItemLoaded) function PostOuterWrapper({ - item, - hasPrecedingItem, + post, treeView, + depth, + showParentReplyLine, + hasPrecedingItem, children, }: React.PropsWithChildren<{ - item: PostThreadItemModel - hasPrecedingItem: boolean + post: AppBskyFeedDefs.PostView treeView: boolean + depth: number + showParentReplyLine: boolean + hasPrecedingItem: boolean }>) { const {isMobile} = useWebMediaQueries() const pal = usePalette('default') const styles = useStyles() - if (treeView && item._depth > 1) { + if (treeView && depth > 1) { return ( <View style={[ @@ -597,13 +593,13 @@ function PostOuterWrapper({ { flexDirection: 'row', paddingLeft: 20, - borderTopWidth: item._depth === 1 ? 1 : 0, - paddingTop: item._depth === 1 ? 8 : 0, + borderTopWidth: depth === 1 ? 1 : 0, + paddingTop: depth === 1 ? 8 : 0, }, ]}> - {Array.from(Array(item._depth - 1)).map((_, n: number) => ( + {Array.from(Array(depth - 1)).map((_, n: number) => ( <View - key={`${item.uri}-padding-${n}`} + key={`${post.uri}-padding-${n}`} style={{ borderLeftWidth: 2, borderLeftColor: pal.colors.border, @@ -622,7 +618,7 @@ function PostOuterWrapper({ styles.outer, pal.view, pal.border, - item._showParentReplyLine && hasPrecedingItem && styles.noTopBorder, + showParentReplyLine && hasPrecedingItem && styles.noTopBorder, styles.cursor, ]}> {children} @@ -640,14 +636,17 @@ function ExpandedPostDetails({ translatorUrl: string }) { const pal = usePalette('default') + const {_} = useLingui() return ( <View style={[s.flexRow, s.mt2, s.mb10]}> <Text style={pal.textLight}>{niceDate(post.indexedAt)}</Text> {needsTranslation && ( <> - <Text style={pal.textLight}> • </Text> - <Link href={translatorUrl} title="Translate"> - <Text style={pal.link}>Translate</Text> + <Text style={[pal.textLight, s.ml5, s.mr5]}>•</Text> + <Link href={translatorUrl} title={_(msg`Translate`)}> + <Text style={pal.link}> + <Trans>Translate</Trans> + </Text> </Link> </> )} diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 4ec9db77f..2e8019e71 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -1,19 +1,14 @@ -import React, {useState} from 'react' +import React, {useState, useMemo} from 'react' +import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import { - ActivityIndicator, - Linking, - StyleProp, - StyleSheet, - View, - ViewStyle, -} from 'react-native' -import {AppBskyFeedPost as FeedPost} from '@atproto/api' -import {observer} from 'mobx-react-lite' -import Clipboard from '@react-native-clipboard/clipboard' -import {AtUri} from '@atproto/api' + AppBskyFeedDefs, + AppBskyFeedPost, + AtUri, + moderatePost, + PostModeration, + RichText as RichTextAPI, +} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {PostThreadModel} from 'state/models/content/post-thread' -import {PostThreadItemModel} from 'state/models/content/post-thread-item' import {Link, TextLink} from '../util/Link' import {UserInfoText} from '../util/UserInfoText' import {PostMeta} from '../util/PostMeta' @@ -23,169 +18,109 @@ import {ContentHider} from '../util/moderation/ContentHider' import {PostAlerts} from '../util/moderation/PostAlerts' import {Text} from '../util/text/Text' import {RichText} from '../util/text/RichText' -import * as Toast from '../util/Toast' import {PreviewableUserAvatar} from '../util/UserAvatar' -import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' -import {getTranslatorLink} from '../../../locale/helpers' import {makeProfileLink} from 'lib/routes/links' import {MAX_POST_LINES} from 'lib/constants' import {countLines} from 'lib/strings/helpers' -import {logger} from '#/logger' +import {useModerationOpts} from '#/state/queries/preferences' +import {useComposerControls} from '#/state/shell/composer' +import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow' -export const Post = observer(function PostImpl({ - view, +export function Post({ + post, showReplyLine, - hideError, style, }: { - view: PostThreadModel + post: AppBskyFeedDefs.PostView showReplyLine?: boolean - hideError?: boolean style?: StyleProp<ViewStyle> }) { - const pal = usePalette('default') - const [deleted, setDeleted] = useState(false) - - // deleted - // = - if (deleted) { - return <View /> - } - - // loading - // = - if (!view.hasContent && view.isLoading) { - return ( - <View style={pal.view}> - <ActivityIndicator /> - </View> - ) + const moderationOpts = useModerationOpts() + const record = useMemo<AppBskyFeedPost.Record | undefined>( + () => + AppBskyFeedPost.isRecord(post.record) && + AppBskyFeedPost.validateRecord(post.record).success + ? post.record + : undefined, + [post], + ) + const postShadowed = usePostShadow(post) + const richText = useMemo( + () => + record + ? new RichTextAPI({ + text: record.text, + facets: record.facets, + }) + : undefined, + [record], + ) + const moderation = useMemo( + () => (moderationOpts ? moderatePost(post, moderationOpts) : undefined), + [moderationOpts, post], + ) + if (postShadowed === POST_TOMBSTONE) { + return null } - - // error - // = - if (view.hasError || !view.thread || !view.thread?.postRecord) { - if (hideError) { - return <View /> - } + if (record && richText && moderation) { return ( - <View style={pal.view}> - <Text>{view.error || 'Thread not found'}</Text> - </View> + <PostInner + post={postShadowed} + record={record} + richText={richText} + moderation={moderation} + showReplyLine={showReplyLine} + style={style} + /> ) } + return null +} - // loaded - // = - - return ( - <PostLoaded - item={view.thread} - record={view.thread.postRecord} - setDeleted={setDeleted} - showReplyLine={showReplyLine} - style={style} - /> - ) -}) - -const PostLoaded = observer(function PostLoadedImpl({ - item, +function PostInner({ + post, record, - setDeleted, + richText, + moderation, showReplyLine, style, }: { - item: PostThreadItemModel - record: FeedPost.Record - setDeleted: (v: boolean) => void + post: Shadow<AppBskyFeedDefs.PostView> + record: AppBskyFeedPost.Record + richText: RichTextAPI + moderation: PostModeration showReplyLine?: boolean style?: StyleProp<ViewStyle> }) { const pal = usePalette('default') - const store = useStores() - const [limitLines, setLimitLines] = React.useState( - countLines(item.richText?.text) >= MAX_POST_LINES, + const {openComposer} = useComposerControls() + const [limitLines, setLimitLines] = useState( + () => countLines(richText?.text) >= MAX_POST_LINES, ) - const itemUri = item.post.uri - const itemCid = item.post.cid - const itemUrip = new AtUri(item.post.uri) - const itemHref = makeProfileLink(item.post.author, 'post', itemUrip.rkey) - const itemTitle = `Post by ${item.post.author.handle}` + const itemUrip = new AtUri(post.uri) + const itemHref = makeProfileLink(post.author, 'post', itemUrip.rkey) let replyAuthorDid = '' if (record.reply) { const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri) replyAuthorDid = urip.hostname } - const translatorUrl = getTranslatorLink( - record?.text || '', - store.preferences.primaryLanguage, - ) - const onPressReply = React.useCallback(() => { - store.shell.openComposer({ + openComposer({ replyTo: { - uri: item.post.uri, - cid: item.post.cid, - text: record.text as string, + uri: post.uri, + cid: post.cid, + text: record.text, author: { - handle: item.post.author.handle, - displayName: item.post.author.displayName, - avatar: item.post.author.avatar, + handle: post.author.handle, + displayName: post.author.displayName, + avatar: post.author.avatar, }, }, }) - }, [store, item, record]) - - const onPressToggleRepost = React.useCallback(() => { - return item - .toggleRepost() - .catch(e => logger.error('Failed to toggle repost', {error: e})) - }, [item]) - - const onPressToggleLike = React.useCallback(() => { - return item - .toggleLike() - .catch(e => logger.error('Failed to toggle like', {error: e})) - }, [item]) - - const onCopyPostText = React.useCallback(() => { - Clipboard.setString(record.text) - Toast.show('Copied to clipboard') - }, [record]) - - const onOpenTranslate = React.useCallback(() => { - Linking.openURL(translatorUrl) - }, [translatorUrl]) - - const onToggleThreadMute = React.useCallback(async () => { - try { - await item.toggleThreadMute() - if (item.isThreadMuted) { - Toast.show('You will no longer receive notifications for this thread') - } else { - Toast.show('You will now receive notifications for this thread') - } - } catch (e) { - logger.error('Failed to toggle thread mute', {error: e}) - } - }, [item]) - - const onDeletePost = React.useCallback(() => { - item.delete().then( - () => { - setDeleted(true) - Toast.show('Post deleted') - }, - e => { - logger.error('Failed to delete post', {error: e}) - Toast.show('Failed to delete post, please try again') - }, - ) - }, [item, setDeleted]) + }, [openComposer, post, record]) const onPressShowMore = React.useCallback(() => { setLimitLines(false) @@ -198,17 +133,17 @@ const PostLoaded = observer(function PostLoadedImpl({ <View style={styles.layoutAvi}> <PreviewableUserAvatar size={52} - did={item.post.author.did} - handle={item.post.author.handle} - avatar={item.post.author.avatar} - moderation={item.moderation.avatar} + did={post.author.did} + handle={post.author.handle} + avatar={post.author.avatar} + moderation={moderation.avatar} /> </View> <View style={styles.layoutContent}> <PostMeta - author={item.post.author} - authorHasWarning={!!item.post.author.labels?.length} - timestamp={item.post.indexedAt} + author={post.author} + authorHasWarning={!!post.author.labels?.length} + timestamp={post.indexedAt} postHref={itemHref} /> {replyAuthorDid !== '' && ( @@ -234,19 +169,16 @@ const PostLoaded = observer(function PostLoadedImpl({ </View> )} <ContentHider - moderation={item.moderation.content} + moderation={moderation.content} style={styles.contentHider} childContainerStyle={styles.contentHiderChild}> - <PostAlerts - moderation={item.moderation.content} - style={styles.alert} - /> - {item.richText?.text ? ( + <PostAlerts moderation={moderation.content} style={styles.alert} /> + {richText.text ? ( <View style={styles.postTextContainer}> <RichText testID="postText" type="post-text" - richText={item.richText} + richText={richText} lineHeight={1.3} numberOfLines={limitLines ? MAX_POST_LINES : undefined} style={s.flex1} @@ -261,45 +193,20 @@ const PostLoaded = observer(function PostLoadedImpl({ href="#" /> ) : undefined} - {item.post.embed ? ( + {post.embed ? ( <ContentHider - moderation={item.moderation.embed} + moderation={moderation.embed} style={styles.contentHider}> - <PostEmbeds - embed={item.post.embed} - moderation={item.moderation.embed} - /> + <PostEmbeds embed={post.embed} moderation={moderation.embed} /> </ContentHider> ) : null} </ContentHider> - <PostCtrls - itemUri={itemUri} - itemCid={itemCid} - itemHref={itemHref} - itemTitle={itemTitle} - author={item.post.author} - indexedAt={item.post.indexedAt} - text={item.richText?.text || record.text} - isAuthor={item.post.author.did === store.me.did} - replyCount={item.post.replyCount} - repostCount={item.post.repostCount} - likeCount={item.post.likeCount} - isReposted={!!item.post.viewer?.repost} - isLiked={!!item.post.viewer?.like} - isThreadMuted={item.isThreadMuted} - onPressReply={onPressReply} - onPressToggleRepost={onPressToggleRepost} - onPressToggleLike={onPressToggleLike} - onCopyPostText={onCopyPostText} - onOpenTranslate={onOpenTranslate} - onToggleThreadMute={onToggleThreadMute} - onDeletePost={onDeletePost} - /> + <PostCtrls post={post} record={record} onPressReply={onPressReply} /> </View> </View> </Link> ) -}) +} const styles = StyleSheet.create({ outer: { diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 1ecb14912..f0f7cd919 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -1,7 +1,7 @@ -import React, {MutableRefObject} from 'react' -import {observer} from 'mobx-react-lite' +import React, {memo, MutableRefObject} from 'react' import { ActivityIndicator, + Dimensions, RefreshControl, StyleProp, StyleSheet, @@ -11,26 +11,36 @@ import { import {FlatList} from '../util/Views' import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {FeedErrorMessage} from './FeedErrorMessage' -import {PostsFeedModel} from 'state/models/feeds/posts' import {FeedSlice} from './FeedSlice' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' -import {OnScrollCb} from 'lib/hooks/useOnMainScroll' -import {s} from 'lib/styles' +import {OnScrollHandler} from 'lib/hooks/useOnMainScroll' import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' +import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {useTheme} from 'lib/ThemeContext' import {logger} from '#/logger' +import { + FeedDescriptor, + FeedParams, + usePostFeedQuery, + pollLatest, +} from '#/state/queries/post-feed' +import {useModerationOpts} from '#/state/queries/preferences' const LOADING_ITEM = {_reactKey: '__loading__'} const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} const ERROR_ITEM = {_reactKey: '__error__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} -export const Feed = observer(function Feed({ +let Feed = ({ feed, + feedParams, style, + enabled, + pollInterval, scrollElRef, onScroll, + onHasNew, scrollEventThrottle, renderEmptyState, renderEndOfFeed, @@ -40,10 +50,14 @@ export const Feed = observer(function Feed({ ListHeaderComponent, extraData, }: { - feed: PostsFeedModel + feed: FeedDescriptor + feedParams?: FeedParams style?: StyleProp<ViewStyle> + enabled?: boolean + pollInterval?: number scrollElRef?: MutableRefObject<FlatList<any> | null> - onScroll?: OnScrollCb + onHasNew?: (v: boolean) => void + onScroll?: OnScrollHandler scrollEventThrottle?: number renderEmptyState: () => JSX.Element renderEndOfFeed?: () => JSX.Element @@ -52,70 +66,110 @@ export const Feed = observer(function Feed({ desktopFixedHeightOffset?: number ListHeaderComponent?: () => JSX.Element extraData?: any -}) { +}): React.ReactNode => { const pal = usePalette('default') const theme = useTheme() const {track} = useAnalytics() - const [isRefreshing, setIsRefreshing] = React.useState(false) + const [isPTRing, setIsPTRing] = React.useState(false) + const checkForNewRef = React.useRef<(() => void) | null>(null) + + const moderationOpts = useModerationOpts() + const opts = React.useMemo(() => ({enabled}), [enabled]) + const { + data, + isFetching, + isFetched, + isError, + error, + refetch, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = usePostFeedQuery(feed, feedParams, opts) + const isEmpty = !isFetching && !data?.pages[0]?.slices.length + + const checkForNew = React.useCallback(async () => { + if (!data?.pages[0] || isFetching || !onHasNew || !enabled) { + return + } + try { + if (await pollLatest(data.pages[0])) { + onHasNew(true) + } + } catch (e) { + logger.error('Poll latest failed', {feed, error: String(e)}) + } + }, [feed, data, isFetching, onHasNew, enabled]) + + React.useEffect(() => { + // we store the interval handler in a ref to avoid needless + // reassignments of the interval + checkForNewRef.current = checkForNew + }, [checkForNew]) + React.useEffect(() => { + if (!pollInterval) { + return + } + const i = setInterval(() => checkForNewRef.current?.(), pollInterval) + return () => clearInterval(i) + }, [pollInterval]) - const data = React.useMemo(() => { - let feedItems: any[] = [] - if (feed.hasLoaded) { - if (feed.hasError) { - feedItems = feedItems.concat([ERROR_ITEM]) + const feedItems = React.useMemo(() => { + let arr: any[] = [] + if (isFetched && moderationOpts) { + if (isError && isEmpty) { + arr = arr.concat([ERROR_ITEM]) } - if (feed.isEmpty) { - feedItems = feedItems.concat([EMPTY_FEED_ITEM]) - } else { - feedItems = feedItems.concat(feed.slices) + if (isEmpty) { + arr = arr.concat([EMPTY_FEED_ITEM]) + } else if (data) { + for (const page of data?.pages) { + arr = arr.concat(page.slices) + } } - if (feed.loadMoreError) { - feedItems = feedItems.concat([LOAD_MORE_ERROR_ITEM]) + if (isError && !isEmpty) { + arr = arr.concat([LOAD_MORE_ERROR_ITEM]) } } else { - feedItems.push(LOADING_ITEM) + arr.push(LOADING_ITEM) } - return feedItems - }, [ - feed.hasError, - feed.hasLoaded, - feed.isEmpty, - feed.slices, - feed.loadMoreError, - ]) + return arr + }, [isFetched, isError, isEmpty, data, moderationOpts]) // events // = const onRefresh = React.useCallback(async () => { track('Feed:onRefresh') - setIsRefreshing(true) + setIsPTRing(true) try { - await feed.refresh() + await refetch() + onHasNew?.(false) } catch (err) { logger.error('Failed to refresh posts feed', {error: err}) } - setIsRefreshing(false) - }, [feed, track, setIsRefreshing]) + setIsPTRing(false) + }, [refetch, track, setIsPTRing, onHasNew]) const onEndReached = React.useCallback(async () => { - if (!feed.hasLoaded || !feed.hasMore) return + if (isFetching || !hasNextPage || isError) return track('Feed:onEndReached') try { - await feed.loadMore() + await fetchNextPage() } catch (err) { logger.error('Failed to load more posts', {error: err}) } - }, [feed, track]) + }, [isFetching, hasNextPage, isError, fetchNextPage, track]) const onPressTryAgain = React.useCallback(() => { - feed.refresh() - }, [feed]) + refetch() + onHasNew?.(false) + }, [refetch, onHasNew]) const onPressRetryLoadMore = React.useCallback(() => { - feed.retryLoadMore() - }, [feed]) + fetchNextPage() + }, [fetchNextPage]) // rendering // = @@ -126,7 +180,11 @@ export const Feed = observer(function Feed({ return renderEmptyState() } else if (item === ERROR_ITEM) { return ( - <FeedErrorMessage feed={feed} onPressTryAgain={onPressTryAgain} /> + <FeedErrorMessage + feedDesc={feed} + error={error} + onPressTryAgain={onPressTryAgain} + /> ) } else if (item === LOAD_MORE_ERROR_ITEM) { return ( @@ -138,47 +196,65 @@ export const Feed = observer(function Feed({ } else if (item === LOADING_ITEM) { return <PostFeedLoadingPlaceholder /> } - return <FeedSlice slice={item} /> + return ( + <FeedSlice + slice={item} + // we check for this before creating the feedItems array + moderationOpts={moderationOpts!} + /> + ) }, - [feed, onPressTryAgain, onPressRetryLoadMore, renderEmptyState], + [ + feed, + error, + onPressTryAgain, + onPressRetryLoadMore, + renderEmptyState, + moderationOpts, + ], ) + const shouldRenderEndOfFeed = + !hasNextPage && !isEmpty && !isFetching && !isError && !!renderEndOfFeed const FeedFooter = React.useCallback( () => - feed.isLoadingMore ? ( + isFetchingNextPage ? ( <View style={styles.feedFooter}> <ActivityIndicator /> </View> - ) : !feed.hasMore && !feed.isEmpty && renderEndOfFeed ? ( + ) : shouldRenderEndOfFeed ? ( renderEndOfFeed() ) : ( <View /> ), - [feed.isLoadingMore, feed.hasMore, feed.isEmpty, renderEndOfFeed], + [isFetchingNextPage, shouldRenderEndOfFeed, renderEndOfFeed], ) + const scrollHandler = useAnimatedScrollHandler(onScroll || {}) return ( <View testID={testID} style={style}> <FlatList testID={testID ? `${testID}-flatlist` : undefined} ref={scrollElRef} - data={data} + data={feedItems} keyExtractor={item => item._reactKey} renderItem={renderItem} ListFooterComponent={FeedFooter} ListHeaderComponent={ListHeaderComponent} refreshControl={ <RefreshControl - refreshing={isRefreshing} + refreshing={isPTRing} onRefresh={onRefresh} tintColor={pal.colors.text} titleColor={pal.colors.text} progressViewOffset={headerOffset} /> } - contentContainerStyle={s.contentContainer} + contentContainerStyle={{ + minHeight: Dimensions.get('window').height * 1.5, + }} style={{paddingTop: headerOffset}} - onScroll={onScroll} + onScroll={onScroll != null ? scrollHandler : undefined} scrollEventThrottle={scrollEventThrottle} indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'} onEndReached={onEndReached} @@ -193,7 +269,9 @@ export const Feed = observer(function Feed({ /> </View> ) -}) +} +Feed = memo(Feed) +export {Feed} const styles = StyleSheet.create({ feedFooter: {paddingTop: 20}, diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx index 9e75d9507..63d9d5956 100644 --- a/src/view/com/posts/FeedErrorMessage.tsx +++ b/src/view/com/posts/FeedErrorMessage.tsx @@ -1,7 +1,6 @@ import React from 'react' import {View} from 'react-native' -import {AtUri, AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api' -import {PostsFeedModel, KnownError} from 'state/models/feeds/posts' +import {AppBskyFeedGetAuthorFeed, AtUri} from '@atproto/api' import {Text} from '../util/text/Text' import {Button} from '../util/forms/Button' import * as Toast from '../util/Toast' @@ -9,67 +8,118 @@ import {ErrorMessage} from '../util/error/ErrorMessage' import {usePalette} from 'lib/hooks/usePalette' import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' -import {useStores} from 'state/index' import {logger} from '#/logger' +import {useModalControls} from '#/state/modals' +import {msg as msgLingui} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {FeedDescriptor} from '#/state/queries/post-feed' +import {EmptyState} from '../util/EmptyState' +import {cleanError} from '#/lib/strings/errors' +import {useRemoveFeedMutation} from '#/state/queries/preferences' -const MESSAGES = { - [KnownError.Unknown]: '', - [KnownError.FeedgenDoesNotExist]: `Hmmm, we're having trouble finding this feed. It may have been deleted.`, - [KnownError.FeedgenMisconfigured]: - 'Hmm, the feed server appears to be misconfigured. Please let the feed owner know about this issue.', - [KnownError.FeedgenBadResponse]: - 'Hmm, the feed server gave a bad response. Please let the feed owner know about this issue.', - [KnownError.FeedgenOffline]: - 'Hmm, the feed server appears to be offline. Please let the feed owner know about this issue.', - [KnownError.FeedgenUnknown]: - 'Hmm, some kind of issue occured when contacting the feed server. Please let the feed owner know about this issue.', +export enum KnownError { + Block = 'Block', + FeedgenDoesNotExist = 'FeedgenDoesNotExist', + FeedgenMisconfigured = 'FeedgenMisconfigured', + FeedgenBadResponse = 'FeedgenBadResponse', + FeedgenOffline = 'FeedgenOffline', + FeedgenUnknown = 'FeedgenUnknown', + FeedNSFPublic = 'FeedNSFPublic', + Unknown = 'Unknown', } export function FeedErrorMessage({ - feed, + feedDesc, + error, onPressTryAgain, }: { - feed: PostsFeedModel + feedDesc: FeedDescriptor + error: any onPressTryAgain: () => void }) { + const knownError = React.useMemo( + () => detectKnownError(feedDesc, error), + [feedDesc, error], + ) if ( - typeof feed.knownError === 'undefined' || - feed.knownError === KnownError.Unknown + typeof knownError !== 'undefined' && + knownError !== KnownError.Unknown && + feedDesc.startsWith('feedgen') ) { + return <FeedgenErrorMessage feedDesc={feedDesc} knownError={knownError} /> + } + + if (knownError === KnownError.Block) { return ( - <ErrorMessage message={feed.error} onPressTryAgain={onPressTryAgain} /> + <EmptyState + icon="ban" + message="Posts hidden" + style={{paddingVertical: 40}} + /> ) } - return <FeedgenErrorMessage feed={feed} knownError={feed.knownError} /> + return ( + <ErrorMessage + message={cleanError(error)} + onPressTryAgain={onPressTryAgain} + /> + ) } function FeedgenErrorMessage({ - feed, + feedDesc, knownError, }: { - feed: PostsFeedModel + feedDesc: FeedDescriptor knownError: KnownError }) { const pal = usePalette('default') - const store = useStores() + const {_: _l} = useLingui() const navigation = useNavigation<NavigationProp>() - const msg = MESSAGES[knownError] - const uri = (feed.params as GetCustomFeed.QueryParams).feed + const msg = React.useMemo( + () => + ({ + [KnownError.Unknown]: '', + [KnownError.Block]: '', + [KnownError.FeedgenDoesNotExist]: _l( + msgLingui`Hmmm, we're having trouble finding this feed. It may have been deleted.`, + ), + [KnownError.FeedgenMisconfigured]: _l( + msgLingui`Hmm, the feed server appears to be misconfigured. Please let the feed owner know about this issue.`, + ), + [KnownError.FeedgenBadResponse]: _l( + msgLingui`Hmm, the feed server gave a bad response. Please let the feed owner know about this issue.`, + ), + [KnownError.FeedgenOffline]: _l( + msgLingui`Hmm, the feed server appears to be offline. Please let the feed owner know about this issue.`, + ), + [KnownError.FeedNSFPublic]: _l( + msgLingui`We're sorry, but this content is not viewable without a Bluesky account.`, + ), + [KnownError.FeedgenUnknown]: _l( + msgLingui`Hmm, some kind of issue occured when contacting the feed server. Please let the feed owner know about this issue.`, + ), + }[knownError]), + [_l, knownError], + ) + const [_, uri] = feedDesc.split('|') const [ownerDid] = safeParseFeedgenUri(uri) + const {openModal, closeModal} = useModalControls() + const {mutateAsync: removeFeed} = useRemoveFeedMutation() const onViewProfile = React.useCallback(() => { navigation.navigate('Profile', {name: ownerDid}) }, [navigation, ownerDid]) const onRemoveFeed = React.useCallback(async () => { - store.shell.openModal({ + openModal({ name: 'confirm', - title: 'Remove feed', - message: 'Remove this feed from your saved feeds?', + title: _l(msgLingui`Remove feed`), + message: _l(msgLingui`Remove this feed from your saved feeds?`), async onPressConfirm() { try { - await store.preferences.removeSavedFeed(uri) + await removeFeed({uri}) } catch (err) { Toast.show( 'There was an an issue removing this feed. Please check your internet connection and try again.', @@ -78,10 +128,40 @@ function FeedgenErrorMessage({ } }, onPressCancel() { - store.shell.closeModal() + closeModal() }, }) - }, [store, uri]) + }, [openModal, closeModal, uri, removeFeed, _l]) + + const cta = React.useMemo(() => { + switch (knownError) { + case KnownError.FeedNSFPublic: { + return null + } + case KnownError.FeedgenDoesNotExist: + case KnownError.FeedgenMisconfigured: + case KnownError.FeedgenBadResponse: + case KnownError.FeedgenOffline: + case KnownError.FeedgenUnknown: { + return ( + <View style={{flexDirection: 'row', alignItems: 'center', gap: 10}}> + {knownError === KnownError.FeedgenDoesNotExist && ( + <Button + type="inverted" + label="Remove feed" + onPress={onRemoveFeed} + /> + )} + <Button + type="default-light" + label="View profile" + onPress={onViewProfile} + /> + </View> + ) + } + } + }, [knownError, onViewProfile, onRemoveFeed]) return ( <View @@ -96,16 +176,7 @@ function FeedgenErrorMessage({ }, ]}> <Text style={pal.text}>{msg}</Text> - <View style={{flexDirection: 'row', alignItems: 'center', gap: 10}}> - {knownError === KnownError.FeedgenDoesNotExist && ( - <Button type="inverted" label="Remove feed" onPress={onRemoveFeed} /> - )} - <Button - type="default-light" - label="View profile" - onPress={onViewProfile} - /> - </View> + {cta} </View> ) } @@ -118,3 +189,48 @@ function safeParseFeedgenUri(uri: string): [string, string] { return ['', ''] } } + +function detectKnownError( + feedDesc: FeedDescriptor, + error: any, +): KnownError | undefined { + if (!error) { + return undefined + } + if ( + error instanceof AppBskyFeedGetAuthorFeed.BlockedActorError || + error instanceof AppBskyFeedGetAuthorFeed.BlockedByActorError + ) { + return KnownError.Block + } + if (typeof error !== 'string') { + error = error.toString() + } + if (!feedDesc.startsWith('feedgen')) { + return KnownError.Unknown + } + if (error.includes('could not find feed')) { + return KnownError.FeedgenDoesNotExist + } + if (error.includes('feed unavailable')) { + return KnownError.FeedgenOffline + } + if (error.includes('invalid did document')) { + return KnownError.FeedgenMisconfigured + } + if (error.includes('could not resolve did document')) { + return KnownError.FeedgenMisconfigured + } + if ( + error.includes('invalid feed generator service details in did document') + ) { + return KnownError.FeedgenMisconfigured + } + if (error.includes('feed provided an invalid response')) { + return KnownError.FeedgenBadResponse + } + if (error.includes(KnownError.FeedNSFPublic)) { + return KnownError.FeedNSFPublic + } + return KnownError.FeedgenUnknown +} diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index aeee3e20a..dfb0cfcf6 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -1,14 +1,17 @@ -import React, {useMemo, useState} from 'react' -import {observer} from 'mobx-react-lite' -import {Linking, StyleSheet, View} from 'react-native' -import Clipboard from '@react-native-clipboard/clipboard' -import {AtUri} from '@atproto/api' +import React, {memo, useMemo, useState} from 'react' +import {StyleSheet, View} from 'react-native' +import { + AppBskyFeedDefs, + AppBskyFeedPost, + AtUri, + PostModeration, + RichText as RichTextAPI, +} from '@atproto/api' import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -import {PostsFeedItemModel} from 'state/models/feeds/post' -import {FeedSourceInfo} from 'lib/api/feed/types' +import {ReasonFeedSource, isReasonFeedSource} from 'lib/api/feed/types' import {Link, TextLinkOnWebOnly, TextLink} from '../util/Link' import {Text} from '../util/text/Text' import {UserInfoText} from '../util/UserInfoText' @@ -19,50 +22,96 @@ import {ContentHider} from '../util/moderation/ContentHider' import {PostAlerts} from '../util/moderation/PostAlerts' import {RichText} from '../util/text/RichText' import {PostSandboxWarning} from '../util/PostSandboxWarning' -import * as Toast from '../util/Toast' import {PreviewableUserAvatar} from '../util/UserAvatar' import {s} from 'lib/styles' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics/analytics' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' -import {getTranslatorLink} from '../../../locale/helpers' import {makeProfileLink} from 'lib/routes/links' import {isEmbedByEmbedder} from 'lib/embeds' import {MAX_POST_LINES} from 'lib/constants' import {countLines} from 'lib/strings/helpers' -import {logger} from '#/logger' +import {useComposerControls} from '#/state/shell/composer' +import {Shadow, usePostShadow, POST_TOMBSTONE} from '#/state/cache/post-shadow' -export const FeedItem = observer(function FeedItemImpl({ - item, - source, +export function FeedItem({ + post, + record, + reason, + moderation, isThreadChild, isThreadLastChild, isThreadParent, }: { - item: PostsFeedItemModel - source?: FeedSourceInfo + post: AppBskyFeedDefs.PostView + record: AppBskyFeedPost.Record + reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined + moderation: PostModeration isThreadChild?: boolean isThreadLastChild?: boolean isThreadParent?: boolean - showReplyLine?: boolean }) { - const store = useStores() + const postShadowed = usePostShadow(post) + const richText = useMemo( + () => + new RichTextAPI({ + text: record.text, + facets: record.facets, + }), + [record], + ) + if (postShadowed === POST_TOMBSTONE) { + return null + } + if (richText && moderation) { + return ( + <FeedItemInner + post={postShadowed} + record={record} + reason={reason} + richText={richText} + moderation={moderation} + isThreadChild={isThreadChild} + isThreadLastChild={isThreadLastChild} + isThreadParent={isThreadParent} + /> + ) + } + return null +} + +let FeedItemInner = ({ + post, + record, + reason, + richText, + moderation, + isThreadChild, + isThreadLastChild, + isThreadParent, +}: { + post: Shadow<AppBskyFeedDefs.PostView> + record: AppBskyFeedPost.Record + reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined + richText: RichTextAPI + moderation: PostModeration + isThreadChild?: boolean + isThreadLastChild?: boolean + isThreadParent?: boolean +}): React.ReactNode => { + const {openComposer} = useComposerControls() const pal = usePalette('default') const {track} = useAnalytics() - const [deleted, setDeleted] = useState(false) const [limitLines, setLimitLines] = useState( - countLines(item.richText?.text) >= MAX_POST_LINES, + () => countLines(richText.text) >= MAX_POST_LINES, ) - const record = item.postRecord - const itemUri = item.post.uri - const itemCid = item.post.cid - const itemHref = useMemo(() => { - const urip = new AtUri(item.post.uri) - return makeProfileLink(item.post.author, 'post', urip.rkey) - }, [item.post.uri, item.post.author]) - const itemTitle = `Post by ${item.post.author.handle}` + + const href = useMemo(() => { + const urip = new AtUri(post.uri) + return makeProfileLink(post.author, 'post', urip.rkey) + }, [post.uri, post.author]) + const replyAuthorDid = useMemo(() => { if (!record?.reply) { return '' @@ -70,77 +119,22 @@ export const FeedItem = observer(function FeedItemImpl({ const urip = new AtUri(record.reply.parent?.uri || record.reply.root.uri) return urip.hostname }, [record?.reply]) - const translatorUrl = getTranslatorLink( - record?.text || '', - store.preferences.primaryLanguage, - ) const onPressReply = React.useCallback(() => { track('FeedItem:PostReply') - store.shell.openComposer({ + openComposer({ replyTo: { - uri: item.post.uri, - cid: item.post.cid, - text: record?.text || '', + uri: post.uri, + cid: post.cid, + text: record.text || '', author: { - handle: item.post.author.handle, - displayName: item.post.author.displayName, - avatar: item.post.author.avatar, + handle: post.author.handle, + displayName: post.author.displayName, + avatar: post.author.avatar, }, }, }) - }, [item, track, record, store]) - - const onPressToggleRepost = React.useCallback(() => { - track('FeedItem:PostRepost') - return item - .toggleRepost() - .catch(e => logger.error('Failed to toggle repost', {error: e})) - }, [track, item]) - - const onPressToggleLike = React.useCallback(() => { - track('FeedItem:PostLike') - return item - .toggleLike() - .catch(e => logger.error('Failed to toggle like', {error: e})) - }, [track, item]) - - const onCopyPostText = React.useCallback(() => { - Clipboard.setString(record?.text || '') - Toast.show('Copied to clipboard') - }, [record]) - - const onOpenTranslate = React.useCallback(() => { - Linking.openURL(translatorUrl) - }, [translatorUrl]) - - const onToggleThreadMute = React.useCallback(async () => { - track('FeedItem:ThreadMute') - try { - await item.toggleThreadMute() - if (item.isThreadMuted) { - Toast.show('You will no longer receive notifications for this thread') - } else { - Toast.show('You will now receive notifications for this thread') - } - } catch (e) { - logger.error('Failed to toggle thread mute', {error: e}) - } - }, [track, item]) - - const onDeletePost = React.useCallback(() => { - track('FeedItem:PostDelete') - item.delete().then( - () => { - setDeleted(true) - Toast.show('Post deleted') - }, - e => { - logger.error('Failed to delete post', {error: e}) - Toast.show('Failed to delete post, please try again') - }, - ) - }, [track, item, setDeleted]) + }, [post, record, track, openComposer]) const onPressShowMore = React.useCallback(() => { setLimitLines(false) @@ -159,15 +153,11 @@ export const FeedItem = observer(function FeedItemImpl({ isThreadChild ? styles.outerSmallTop : undefined, ] - if (!record || deleted) { - return <View /> - } - return ( <Link - testID={`feedItem-by-${item.post.author.handle}`} + testID={`feedItem-by-${post.author.handle}`} style={outerStyles} - href={itemHref} + href={href} noFeedback accessible={false}> <PostSandboxWarning /> @@ -189,10 +179,10 @@ export const FeedItem = observer(function FeedItemImpl({ </View> <View style={{paddingTop: 12, flexShrink: 1}}> - {source ? ( + {isReasonFeedSource(reason) ? ( <Link - title={sanitizeDisplayName(source.displayName)} - href={source.uri}> + title={sanitizeDisplayName(reason.displayName)} + href={reason.uri}> <Text type="sm-bold" style={pal.textLight} @@ -204,17 +194,17 @@ export const FeedItem = observer(function FeedItemImpl({ style={pal.textLight} lineHeight={1.2} numberOfLines={1} - text={sanitizeDisplayName(source.displayName)} - href={source.uri} + text={sanitizeDisplayName(reason.displayName)} + href={reason.uri} /> </Text> </Link> - ) : item.reasonRepost ? ( + ) : AppBskyFeedDefs.isReasonRepost(reason) ? ( <Link style={styles.includeReason} - href={makeProfileLink(item.reasonRepost.by)} + href={makeProfileLink(reason.by)} title={`Reposted by ${sanitizeDisplayName( - item.reasonRepost.by.displayName || item.reasonRepost.by.handle, + reason.by.displayName || reason.by.handle, )}`}> <FontAwesomeIcon icon="retweet" @@ -236,10 +226,9 @@ export const FeedItem = observer(function FeedItemImpl({ lineHeight={1.2} numberOfLines={1} text={sanitizeDisplayName( - item.reasonRepost.by.displayName || - sanitizeHandle(item.reasonRepost.by.handle), + reason.by.displayName || sanitizeHandle(reason.by.handle), )} - href={makeProfileLink(item.reasonRepost.by)} + href={makeProfileLink(reason.by)} /> </Text> </Link> @@ -251,10 +240,10 @@ export const FeedItem = observer(function FeedItemImpl({ <View style={styles.layoutAvi}> <PreviewableUserAvatar size={52} - did={item.post.author.did} - handle={item.post.author.handle} - avatar={item.post.author.avatar} - moderation={item.moderation.avatar} + did={post.author.did} + handle={post.author.handle} + avatar={post.author.avatar} + moderation={moderation.avatar} /> {isThreadParent && ( <View @@ -271,10 +260,10 @@ export const FeedItem = observer(function FeedItemImpl({ </View> <View style={styles.layoutContent}> <PostMeta - author={item.post.author} - authorHasWarning={!!item.post.author.labels?.length} - timestamp={item.post.indexedAt} - postHref={itemHref} + author={post.author} + authorHasWarning={!!post.author.labels?.length} + timestamp={post.indexedAt} + postHref={href} /> {!isThreadChild && replyAuthorDid !== '' && ( <View style={[s.flexRow, s.mb2, s.alignCenter]}> @@ -303,19 +292,16 @@ export const FeedItem = observer(function FeedItemImpl({ )} <ContentHider testID="contentHider-post" - moderation={item.moderation.content} + moderation={moderation.content} ignoreMute childContainerStyle={styles.contentHiderChild}> - <PostAlerts - moderation={item.moderation.content} - style={styles.alert} - /> - {item.richText?.text ? ( + <PostAlerts moderation={moderation.content} style={styles.alert} /> + {richText.text ? ( <View style={styles.postTextContainer}> <RichText testID="postText" type="post-text" - richText={item.richText} + richText={richText} lineHeight={1.3} numberOfLines={limitLines ? MAX_POST_LINES : undefined} style={s.flex1} @@ -330,50 +316,23 @@ export const FeedItem = observer(function FeedItemImpl({ href="#" /> ) : undefined} - {item.post.embed ? ( + {post.embed ? ( <ContentHider testID="contentHider-embed" - moderation={item.moderation.embed} - ignoreMute={isEmbedByEmbedder( - item.post.embed, - item.post.author.did, - )} + moderation={moderation.embed} + ignoreMute={isEmbedByEmbedder(post.embed, post.author.did)} style={styles.embed}> - <PostEmbeds - embed={item.post.embed} - moderation={item.moderation.embed} - /> + <PostEmbeds embed={post.embed} moderation={moderation.embed} /> </ContentHider> ) : null} </ContentHider> - <PostCtrls - itemUri={itemUri} - itemCid={itemCid} - itemHref={itemHref} - itemTitle={itemTitle} - author={item.post.author} - text={item.richText?.text || record.text} - indexedAt={item.post.indexedAt} - isAuthor={item.post.author.did === store.me.did} - replyCount={item.post.replyCount} - repostCount={item.post.repostCount} - likeCount={item.post.likeCount} - isReposted={!!item.post.viewer?.repost} - isLiked={!!item.post.viewer?.like} - isThreadMuted={item.isThreadMuted} - onPressReply={onPressReply} - onPressToggleRepost={onPressToggleRepost} - onPressToggleLike={onPressToggleLike} - onCopyPostText={onCopyPostText} - onOpenTranslate={onOpenTranslate} - onToggleThreadMute={onToggleThreadMute} - onDeletePost={onDeletePost} - /> + <PostCtrls post={post} record={record} onPressReply={onPressReply} /> </View> </View> </Link> ) -}) +} +FeedItemInner = memo(FeedItemInner) const styles = StyleSheet.create({ outer: { diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx index 1d26f6cbd..a3bacdc1e 100644 --- a/src/view/com/posts/FeedSlice.tsx +++ b/src/view/com/posts/FeedSlice.tsx @@ -1,8 +1,7 @@ -import React from 'react' +import React, {memo} from 'react' import {StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {PostsFeedSliceModel} from 'state/models/feeds/posts-slice' -import {AtUri} from '@atproto/api' +import {FeedPostSlice} from '#/state/queries/post-feed' +import {AtUri, moderatePost, ModerationOpts} from '@atproto/api' import {Link} from '../util/Link' import {Text} from '../util/text/Text' import Svg, {Circle, Line} from 'react-native-svg' @@ -10,15 +9,27 @@ import {FeedItem} from './FeedItem' import {usePalette} from 'lib/hooks/usePalette' import {makeProfileLink} from 'lib/routes/links' -export const FeedSlice = observer(function FeedSliceImpl({ +let FeedSlice = ({ slice, ignoreFilterFor, + moderationOpts, }: { - slice: PostsFeedSliceModel + slice: FeedPostSlice ignoreFilterFor?: string -}) { - if (slice.shouldFilter(ignoreFilterFor)) { - return null + moderationOpts: ModerationOpts +}): React.ReactNode => { + const moderations = React.useMemo(() => { + return slice.items.map(item => moderatePost(item.post, moderationOpts)) + }, [slice, moderationOpts]) + + // apply moderation filter + for (let i = 0; i < slice.items.length; i++) { + if ( + moderations[i]?.content.filter && + slice.items[i].post.author.did !== ignoreFilterFor + ) { + return null + } } if (slice.isThread && slice.items.length > 3) { @@ -27,23 +38,31 @@ export const FeedSlice = observer(function FeedSliceImpl({ <> <FeedItem key={slice.items[0]._reactKey} - item={slice.items[0]} - source={slice.source} - isThreadParent={slice.isThreadParentAt(0)} - isThreadChild={slice.isThreadChildAt(0)} + post={slice.items[0].post} + record={slice.items[0].record} + reason={slice.items[0].reason} + moderation={moderations[0]} + isThreadParent={isThreadParentAt(slice.items, 0)} + isThreadChild={isThreadChildAt(slice.items, 0)} /> <FeedItem key={slice.items[1]._reactKey} - item={slice.items[1]} - isThreadParent={slice.isThreadParentAt(1)} - isThreadChild={slice.isThreadChildAt(1)} + post={slice.items[1].post} + record={slice.items[1].record} + reason={slice.items[1].reason} + moderation={moderations[1]} + isThreadParent={isThreadParentAt(slice.items, 1)} + isThreadChild={isThreadChildAt(slice.items, 1)} /> <ViewFullThread slice={slice} /> <FeedItem key={slice.items[last]._reactKey} - item={slice.items[last]} - isThreadParent={slice.isThreadParentAt(last)} - isThreadChild={slice.isThreadChildAt(last)} + post={slice.items[last].post} + record={slice.items[last].record} + reason={slice.items[last].reason} + moderation={moderations[last]} + isThreadParent={isThreadParentAt(slice.items, last)} + isThreadChild={isThreadChildAt(slice.items, last)} isThreadLastChild /> </> @@ -55,25 +74,29 @@ export const FeedSlice = observer(function FeedSliceImpl({ {slice.items.map((item, i) => ( <FeedItem key={item._reactKey} - item={item} - source={i === 0 ? slice.source : undefined} - isThreadParent={slice.isThreadParentAt(i)} - isThreadChild={slice.isThreadChildAt(i)} + post={slice.items[i].post} + record={slice.items[i].record} + reason={slice.items[i].reason} + moderation={moderations[i]} + isThreadParent={isThreadParentAt(slice.items, i)} + isThreadChild={isThreadChildAt(slice.items, i)} isThreadLastChild={ - slice.isThreadChildAt(i) && slice.items.length === i + 1 + isThreadChildAt(slice.items, i) && slice.items.length === i + 1 } /> ))} </> ) -}) +} +FeedSlice = memo(FeedSlice) +export {FeedSlice} -function ViewFullThread({slice}: {slice: PostsFeedSliceModel}) { +function ViewFullThread({slice}: {slice: FeedPostSlice}) { const pal = usePalette('default') const itemHref = React.useMemo(() => { - const urip = new AtUri(slice.rootItem.post.uri) - return makeProfileLink(slice.rootItem.post.author, 'post', urip.rkey) - }, [slice.rootItem.post.uri, slice.rootItem.post.author]) + const urip = new AtUri(slice.rootUri) + return makeProfileLink({did: urip.hostname, handle: ''}, 'post', urip.rkey) + }, [slice.rootUri]) return ( <Link @@ -115,3 +138,17 @@ const styles = StyleSheet.create({ alignItems: 'center', }, }) + +function isThreadParentAt<T>(arr: Array<T>, i: number) { + if (arr.length === 1) { + return false + } + return i < arr.length - 1 +} + +function isThreadChildAt<T>(arr: Array<T>, i: number) { + if (arr.length === 1) { + return false + } + return i > 0 +} diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx index adb496f6d..1252f8ca8 100644 --- a/src/view/com/profile/FollowButton.tsx +++ b/src/view/com/profile/FollowButton.tsx @@ -1,47 +1,65 @@ import React from 'react' import {StyleProp, TextStyle, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {AppBskyActorDefs} from '@atproto/api' import {Button, ButtonType} from '../util/forms/Button' import * as Toast from '../util/Toast' -import {FollowState} from 'state/models/cache/my-follows' -import {useFollowProfile} from 'lib/hooks/useFollowProfile' +import {useProfileFollowMutationQueue} from '#/state/queries/profile' +import {Shadow} from '#/state/cache/types' -export const FollowButton = observer(function FollowButtonImpl({ +export function FollowButton({ unfollowedType = 'inverted', followedType = 'default', profile, - onToggleFollow, labelStyle, }: { unfollowedType?: ButtonType followedType?: ButtonType - profile: AppBskyActorDefs.ProfileViewBasic - onToggleFollow?: (v: boolean) => void + profile: Shadow<AppBskyActorDefs.ProfileViewBasic> labelStyle?: StyleProp<TextStyle> }) { - const {state, following, toggle} = useFollowProfile(profile) + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) - const onPress = React.useCallback(async () => { + const onPressFollow = async () => { try { - const {following} = await toggle() - onToggleFollow?.(following) + await queueFollow() } catch (e: any) { - Toast.show('An issue occurred, please try again.') + if (e?.name !== 'AbortError') { + Toast.show(`An issue occurred, please try again.`) + } } - }, [toggle, onToggleFollow]) + } - if (state === FollowState.Unknown) { + const onPressUnfollow = async () => { + try { + await queueUnfollow() + } catch (e: any) { + if (e?.name !== 'AbortError') { + Toast.show(`An issue occurred, please try again.`) + } + } + } + + if (!profile.viewer) { return <View /> } - return ( - <Button - type={following ? followedType : unfollowedType} - labelStyle={labelStyle} - onPress={onPress} - label={following ? 'Unfollow' : 'Follow'} - withLoading={true} - /> - ) -}) + if (profile.viewer.following) { + return ( + <Button + type={followedType} + labelStyle={labelStyle} + onPress={onPressUnfollow} + label="Unfollow" + /> + ) + } else { + return ( + <Button + type={unfollowedType} + labelStyle={labelStyle} + onPress={onPressFollow} + label="Follow" + /> + ) + } +} diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index 60dda6798..b14f2833b 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -1,6 +1,5 @@ import * as React from 'react' import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' -import {observer} from 'mobx-react-lite' import { AppBskyActorDefs, moderateProfile, @@ -11,7 +10,6 @@ import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {FollowButton} from './FollowButton' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' @@ -21,10 +19,14 @@ import { getProfileModerationCauses, getModerationCauseKey, } from 'lib/moderation' +import {Shadow} from '#/state/cache/types' +import {useModerationOpts} from '#/state/queries/preferences' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useSession} from '#/state/session' -export const ProfileCard = observer(function ProfileCardImpl({ +export function ProfileCard({ testID, - profile, + profile: profileUnshadowed, noBg, noBorder, followers, @@ -36,13 +38,18 @@ export const ProfileCard = observer(function ProfileCardImpl({ noBg?: boolean noBorder?: boolean followers?: AppBskyActorDefs.ProfileView[] | undefined - renderButton?: (profile: AppBskyActorDefs.ProfileViewBasic) => React.ReactNode + renderButton?: ( + profile: Shadow<AppBskyActorDefs.ProfileViewBasic>, + ) => React.ReactNode style?: StyleProp<ViewStyle> }) { - const store = useStores() const pal = usePalette('default') - - const moderation = moderateProfile(profile, store.preferences.moderationOpts) + const profile = useProfileShadow(profileUnshadowed) + const moderationOpts = useModerationOpts() + if (!moderationOpts) { + return null + } + const moderation = moderateProfile(profile, moderationOpts) return ( <Link @@ -100,7 +107,7 @@ export const ProfileCard = observer(function ProfileCardImpl({ <FollowersList followers={followers} /> </Link> ) -}) +} function ProfileCardPills({ followedBy, @@ -142,24 +149,31 @@ function ProfileCardPills({ ) } -const FollowersList = observer(function FollowersListImpl({ +function FollowersList({ followers, }: { followers?: AppBskyActorDefs.ProfileView[] | undefined }) { - const store = useStores() const pal = usePalette('default') - if (!followers?.length) { + const moderationOpts = useModerationOpts() + + const followersWithMods = React.useMemo(() => { + if (!followers || !moderationOpts) { + return [] + } + + return followers + .map(f => ({ + f, + mod: moderateProfile(f, moderationOpts), + })) + .filter(({mod}) => !mod.account.filter) + }, [followers, moderationOpts]) + + if (!followersWithMods?.length) { return null } - const followersWithMods = followers - .map(f => ({ - f, - mod: moderateProfile(f, store.preferences.moderationOpts), - })) - .filter(({mod}) => !mod.account.filter) - return ( <View style={styles.followedBy}> <Text @@ -179,36 +193,36 @@ const FollowersList = observer(function FollowersListImpl({ ))} </View> ) -}) +} -export const ProfileCardWithFollowBtn = observer( - function ProfileCardWithFollowBtnImpl({ - profile, - noBg, - noBorder, - followers, - }: { - profile: AppBskyActorDefs.ProfileViewBasic - noBg?: boolean - noBorder?: boolean - followers?: AppBskyActorDefs.ProfileView[] | undefined - }) { - const store = useStores() - const isMe = store.me.did === profile.did +export function ProfileCardWithFollowBtn({ + profile, + noBg, + noBorder, + followers, +}: { + profile: AppBskyActorDefs.ProfileViewBasic + noBg?: boolean + noBorder?: boolean + followers?: AppBskyActorDefs.ProfileView[] | undefined +}) { + const {currentAccount} = useSession() + const isMe = profile.did === currentAccount?.did - return ( - <ProfileCard - profile={profile} - noBg={noBg} - noBorder={noBorder} - followers={followers} - renderButton={ - isMe ? undefined : () => <FollowButton profile={profile} /> - } - /> - ) - }, -) + return ( + <ProfileCard + profile={profile} + noBg={noBg} + noBorder={noBorder} + followers={followers} + renderButton={ + isMe + ? undefined + : profileShadow => <FollowButton profile={profileShadow} /> + } + /> + ) +} const styles = StyleSheet.create({ outer: { diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx index 00ea48ed6..d94f5103e 100644 --- a/src/view/com/profile/ProfileFollowers.tsx +++ b/src/view/com/profile/ProfileFollowers.tsx @@ -1,49 +1,68 @@ -import React, {useEffect} from 'react' -import {observer} from 'mobx-react-lite' +import React from 'react' import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' -import { - UserFollowersModel, - FollowerItem, -} from 'state/models/lists/user-followers' +import {AppBskyActorDefs as ActorDefs} from '@atproto/api' import {CenteredView, FlatList} from '../util/Views' import {ErrorMessage} from '../util/error/ErrorMessage' import {ProfileCardWithFollowBtn} from './ProfileCard' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' +import {useProfileFollowersQuery} from '#/state/queries/profile-followers' +import {useResolveDidQuery} from '#/state/queries/resolve-uri' import {logger} from '#/logger' +import {cleanError} from '#/lib/strings/errors' -export const ProfileFollowers = observer(function ProfileFollowers({ - name, -}: { - name: string -}) { +export function ProfileFollowers({name}: {name: string}) { const pal = usePalette('default') - const store = useStores() - const view = React.useMemo( - () => new UserFollowersModel(store, {actor: name}), - [store, name], - ) + const [isPTRing, setIsPTRing] = React.useState(false) + const { + data: resolvedDid, + error: resolveError, + isFetching: isFetchingDid, + } = useResolveDidQuery(name) + const { + data, + isFetching, + isFetched, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + isError, + error, + refetch, + } = useProfileFollowersQuery(resolvedDid) - useEffect(() => { - view - .loadMore() - .catch(err => - logger.error('Failed to fetch user followers', {error: err}), - ) - }, [view]) + const followers = React.useMemo(() => { + if (data?.pages) { + return data.pages.flatMap(page => page.followers) + } + }, [data]) - const onRefresh = () => { - view.refresh() - } - const onEndReached = () => { - view.loadMore().catch(err => - logger.error('Failed to load more followers', { - error: err, - }), - ) + const onRefresh = React.useCallback(async () => { + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh followers', {error: err}) + } + setIsPTRing(false) + }, [refetch, setIsPTRing]) + + const onEndReached = async () => { + if (isFetching || !hasNextPage || isError) return + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more followers', {error: err}) + } } - if (!view.hasLoaded) { + const renderItem = React.useCallback( + ({item}: {item: ActorDefs.ProfileViewBasic}) => ( + <ProfileCardWithFollowBtn key={item.did} profile={item} /> + ), + [], + ) + + if (isFetchingDid || !isFetched) { return ( <CenteredView> <ActivityIndicator /> @@ -53,26 +72,26 @@ export const ProfileFollowers = observer(function ProfileFollowers({ // error // = - if (view.hasError) { + if (resolveError || isError) { return ( <CenteredView> - <ErrorMessage message={view.error} onPressTryAgain={onRefresh} /> + <ErrorMessage + message={cleanError(resolveError || error)} + onPressTryAgain={onRefresh} + /> </CenteredView> ) } // loaded // = - const renderItem = ({item}: {item: FollowerItem}) => ( - <ProfileCardWithFollowBtn key={item.did} profile={item} /> - ) return ( <FlatList - data={view.followers} + data={followers} keyExtractor={item => item.did} refreshControl={ <RefreshControl - refreshing={view.isRefreshing} + refreshing={isPTRing} onRefresh={onRefresh} tintColor={pal.colors.text} titleColor={pal.colors.text} @@ -85,15 +104,14 @@ export const ProfileFollowers = observer(function ProfileFollowers({ // eslint-disable-next-line react/no-unstable-nested-components ListFooterComponent={() => ( <View style={styles.footer}> - {view.isLoading && <ActivityIndicator />} + {(isFetching || isFetchingNextPage) && <ActivityIndicator />} </View> )} - extraData={view.isLoading} // @ts-ignore our .web version only -prf desktopFixedHeight /> ) -}) +} const styles = StyleSheet.create({ footer: { diff --git a/src/view/com/profile/ProfileFollows.tsx b/src/view/com/profile/ProfileFollows.tsx index abc35398a..890c13eb2 100644 --- a/src/view/com/profile/ProfileFollows.tsx +++ b/src/view/com/profile/ProfileFollows.tsx @@ -1,42 +1,68 @@ -import React, {useEffect} from 'react' -import {observer} from 'mobx-react-lite' +import React from 'react' import {ActivityIndicator, RefreshControl, StyleSheet, View} from 'react-native' +import {AppBskyActorDefs as ActorDefs} from '@atproto/api' import {CenteredView, FlatList} from '../util/Views' -import {UserFollowsModel, FollowItem} from 'state/models/lists/user-follows' import {ErrorMessage} from '../util/error/ErrorMessage' import {ProfileCardWithFollowBtn} from './ProfileCard' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' +import {useProfileFollowsQuery} from '#/state/queries/profile-follows' +import {useResolveDidQuery} from '#/state/queries/resolve-uri' import {logger} from '#/logger' +import {cleanError} from '#/lib/strings/errors' -export const ProfileFollows = observer(function ProfileFollows({ - name, -}: { - name: string -}) { +export function ProfileFollows({name}: {name: string}) { const pal = usePalette('default') - const store = useStores() - const view = React.useMemo( - () => new UserFollowsModel(store, {actor: name}), - [store, name], - ) + const [isPTRing, setIsPTRing] = React.useState(false) + const { + data: resolvedDid, + error: resolveError, + isFetching: isFetchingDid, + } = useResolveDidQuery(name) + const { + data, + isFetching, + isFetched, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + isError, + error, + refetch, + } = useProfileFollowsQuery(resolvedDid) - useEffect(() => { - view - .loadMore() - .catch(err => logger.error('Failed to fetch user follows', err)) - }, [view]) + const follows = React.useMemo(() => { + if (data?.pages) { + return data.pages.flatMap(page => page.follows) + } + }, [data]) - const onRefresh = () => { - view.refresh() - } - const onEndReached = () => { - view - .loadMore() - .catch(err => logger.error('Failed to load more follows', err)) + const onRefresh = React.useCallback(async () => { + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh follows', {error: err}) + } + setIsPTRing(false) + }, [refetch, setIsPTRing]) + + const onEndReached = async () => { + if (isFetching || !hasNextPage || isError) return + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more follows', {error: err}) + } } - if (!view.hasLoaded) { + const renderItem = React.useCallback( + ({item}: {item: ActorDefs.ProfileViewBasic}) => ( + <ProfileCardWithFollowBtn key={item.did} profile={item} /> + ), + [], + ) + + if (isFetchingDid || !isFetched) { return ( <CenteredView> <ActivityIndicator /> @@ -46,26 +72,26 @@ export const ProfileFollows = observer(function ProfileFollows({ // error // = - if (view.hasError) { + if (resolveError || isError) { return ( <CenteredView> - <ErrorMessage message={view.error} onPressTryAgain={onRefresh} /> + <ErrorMessage + message={cleanError(resolveError || error)} + onPressTryAgain={onRefresh} + /> </CenteredView> ) } // loaded // = - const renderItem = ({item}: {item: FollowItem}) => ( - <ProfileCardWithFollowBtn key={item.did} profile={item} /> - ) return ( <FlatList - data={view.follows} + data={follows} keyExtractor={item => item.did} refreshControl={ <RefreshControl - refreshing={view.isRefreshing} + refreshing={isPTRing} onRefresh={onRefresh} tintColor={pal.colors.text} titleColor={pal.colors.text} @@ -78,15 +104,14 @@ export const ProfileFollows = observer(function ProfileFollows({ // eslint-disable-next-line react/no-unstable-nested-components ListFooterComponent={() => ( <View style={styles.footer}> - {view.isLoading && <ActivityIndicator />} + {(isFetching || isFetchingNextPage) && <ActivityIndicator />} </View> )} - extraData={view.isLoading} // @ts-ignore our .web version only -prf desktopFixedHeight /> ) -}) +} const styles = StyleSheet.create({ footer: { diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 1a1d38e4b..8058551c2 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -1,5 +1,4 @@ -import React from 'react' -import {observer} from 'mobx-react-lite' +import React, {memo} from 'react' import { StyleSheet, TouchableOpacity, @@ -8,15 +7,17 @@ import { } from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {useNavigation} from '@react-navigation/native' +import {useQueryClient} from '@tanstack/react-query' +import { + AppBskyActorDefs, + ProfileModeration, + RichText as RichTextAPI, +} from '@atproto/api' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {NavigationProp} from 'lib/routes/types' +import {isNative, isWeb} from 'platform/detection' import {BlurView} from '../util/BlurView' -import {ProfileModel} from 'state/models/content/profile' -import {useStores} from 'state/index' -import {ProfileImageLightbox} from 'state/models/ui/shell' -import {pluralize} from 'lib/strings/helpers' -import {toShareUrl} from 'lib/strings/url-helpers' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {sanitizeHandle} from 'lib/strings/handles' -import {s, colors} from 'lib/styles' import * as Toast from '../util/Toast' import {LoadingPlaceholder} from '../util/LoadingPlaceholder' import {Text} from '../util/text/Text' @@ -25,32 +26,45 @@ import {RichText} from '../util/text/RichText' import {UserAvatar} from '../util/UserAvatar' import {UserBanner} from '../util/UserBanner' import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts' +import {formatCount} from '../util/numeric/format' +import {NativeDropdown, DropdownItem} from '../util/forms/NativeDropdown' +import {Link} from '../util/Link' +import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows' +import {useModalControls} from '#/state/modals' +import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox' +import { + RQKEY as profileQueryKey, + useProfileMuteMutationQueue, + useProfileBlockMutationQueue, + useProfileFollowMutationQueue, +} from '#/state/queries/profile' import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics/analytics' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {NavigationProp} from 'lib/routes/types' -import {isNative} from 'platform/detection' -import {FollowState} from 'state/models/cache/my-follows' -import {shareUrl} from 'lib/sharing' -import {formatCount} from '../util/numeric/format' -import {NativeDropdown, DropdownItem} from '../util/forms/NativeDropdown' import {BACK_HITSLOP} from 'lib/constants' import {isInvalidHandle} from 'lib/strings/handles' import {makeProfileLink} from 'lib/routes/links' -import {Link} from '../util/Link' -import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows' +import {pluralize} from 'lib/strings/helpers' +import {toShareUrl} from 'lib/strings/url-helpers' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {sanitizeHandle} from 'lib/strings/handles' +import {shareUrl} from 'lib/sharing' +import {s, colors} from 'lib/styles' import {logger} from '#/logger' +import {useSession} from '#/state/session' +import {Shadow} from '#/state/cache/types' +import {useRequireAuth} from '#/state/session' interface Props { - view: ProfileModel - onRefreshAll: () => void + profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> | null + moderation: ProfileModeration | null hideBackButton?: boolean isProfilePreview?: boolean } -export const ProfileHeader = observer(function ProfileHeaderImpl({ - view, - onRefreshAll, +export function ProfileHeader({ + profile, + moderation, hideBackButton = false, isProfilePreview, }: Props) { @@ -58,7 +72,7 @@ export const ProfileHeader = observer(function ProfileHeaderImpl({ // loading // = - if (!view || !view.hasLoaded) { + if (!profile || !moderation) { return ( <View style={pal.view}> <LoadingPlaceholder width="100%" height={153} /> @@ -70,54 +84,65 @@ export const ProfileHeader = observer(function ProfileHeaderImpl({ <View style={[styles.buttonsLine]}> <LoadingPlaceholder width={167} height={31} style={styles.br50} /> </View> - <View> - <Text type="title-2xl" style={[pal.text, styles.title]}> - {sanitizeDisplayName( - view.displayName || sanitizeHandle(view.handle), - )} - </Text> - </View> </View> </View> ) } - // error - // = - if (view.hasError) { - return ( - <View testID="profileHeaderHasError"> - <Text>{view.error}</Text> - </View> - ) - } - // loaded // = return ( <ProfileHeaderLoaded - view={view} - onRefreshAll={onRefreshAll} + profile={profile} + moderation={moderation} hideBackButton={hideBackButton} isProfilePreview={isProfilePreview} /> ) -}) +} + +interface LoadedProps { + profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> + moderation: ProfileModeration + hideBackButton?: boolean + isProfilePreview?: boolean +} -const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ - view, - onRefreshAll, +let ProfileHeaderLoaded = ({ + profile, + moderation, hideBackButton = false, isProfilePreview, -}: Props) { +}: LoadedProps): React.ReactNode => { const pal = usePalette('default') const palInverted = usePalette('inverted') - const store = useStores() + const {currentAccount, hasSession} = useSession() + const requireAuth = useRequireAuth() + const {_} = useLingui() + const {openModal} = useModalControls() + const {openLightbox} = useLightboxControls() const navigation = useNavigation<NavigationProp>() const {track} = useAnalytics() - const invalidHandle = isInvalidHandle(view.handle) + const invalidHandle = isInvalidHandle(profile.handle) const {isDesktop} = useWebMediaQueries() const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false) + const descriptionRT = React.useMemo( + () => + profile.description + ? new RichTextAPI({text: profile.description}) + : undefined, + [profile], + ) + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) + const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile) + const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) + const queryClient = useQueryClient() + + const invalidateProfileQuery = React.useCallback(() => { + queryClient.invalidateQueries({ + queryKey: profileQueryKey(profile.did), + }) + }, [queryClient, profile.did]) const onPressBack = React.useCallback(() => { if (navigation.canGoBack()) { @@ -129,144 +154,162 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ const onPressAvi = React.useCallback(() => { if ( - view.avatar && - !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) + profile.avatar && + !(moderation.avatar.blur && moderation.avatar.noOverride) ) { - store.shell.openLightbox(new ProfileImageLightbox(view)) + openLightbox(new ProfileImageLightbox(profile)) } - }, [store, view]) + }, [openLightbox, profile, moderation]) - const onPressToggleFollow = React.useCallback(() => { - view?.toggleFollowing().then( - () => { - setShowSuggestedFollows(Boolean(view.viewer.following)) + const onPressFollow = () => { + requireAuth(async () => { + try { + track('ProfileHeader:FollowButtonClicked') + await queueFollow() Toast.show( - `${ - view.viewer.following ? 'Following' : 'No longer following' - } ${sanitizeDisplayName(view.displayName || view.handle)}`, + `Following ${sanitizeDisplayName( + profile.displayName || profile.handle, + )}`, ) - track( - view.viewer.following - ? 'ProfileHeader:FollowButtonClicked' - : 'ProfileHeader:UnfollowButtonClicked', + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to follow', {error: String(e)}) + Toast.show(`There was an issue! ${e.toString()}`) + } + } + }) + } + + const onPressUnfollow = () => { + requireAuth(async () => { + try { + track('ProfileHeader:UnfollowButtonClicked') + await queueUnfollow() + Toast.show( + `No longer following ${sanitizeDisplayName( + profile.displayName || profile.handle, + )}`, ) - }, - err => logger.error('Failed to toggle follow', {error: err}), - ) - }, [track, view, setShowSuggestedFollows]) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to unfollow', {error: String(e)}) + Toast.show(`There was an issue! ${e.toString()}`) + } + } + }) + } const onPressEditProfile = React.useCallback(() => { track('ProfileHeader:EditProfileButtonClicked') - store.shell.openModal({ + openModal({ name: 'edit-profile', - profileView: view, - onUpdate: onRefreshAll, + profile, }) - }, [track, store, view, onRefreshAll]) - - const trackPress = React.useCallback( - (f: 'Followers' | 'Follows') => { - track(`ProfileHeader:${f}ButtonClicked`, { - handle: view.handle, - }) - }, - [track, view], - ) + }, [track, openModal, profile]) const onPressShare = React.useCallback(() => { track('ProfileHeader:ShareButtonClicked') - const url = toShareUrl(makeProfileLink(view)) - shareUrl(url) - }, [track, view]) + shareUrl(toShareUrl(makeProfileLink(profile))) + }, [track, profile]) const onPressAddRemoveLists = React.useCallback(() => { track('ProfileHeader:AddToListsButtonClicked') - store.shell.openModal({ + openModal({ name: 'user-add-remove-lists', - subject: view.did, - displayName: view.displayName || view.handle, + subject: profile.did, + displayName: profile.displayName || profile.handle, + onAdd: invalidateProfileQuery, + onRemove: invalidateProfileQuery, }) - }, [track, view, store]) + }, [track, profile, openModal, invalidateProfileQuery]) const onPressMuteAccount = React.useCallback(async () => { track('ProfileHeader:MuteAccountButtonClicked') try { - await view.muteAccount() + await queueMute() Toast.show('Account muted') } catch (e: any) { - logger.error('Failed to mute account', {error: e}) - Toast.show(`There was an issue! ${e.toString()}`) + if (e?.name !== 'AbortError') { + logger.error('Failed to mute account', {error: e}) + Toast.show(`There was an issue! ${e.toString()}`) + } } - }, [track, view]) + }, [track, queueMute]) const onPressUnmuteAccount = React.useCallback(async () => { track('ProfileHeader:UnmuteAccountButtonClicked') try { - await view.unmuteAccount() + await queueUnmute() Toast.show('Account unmuted') } catch (e: any) { - logger.error('Failed to unmute account', {error: e}) - Toast.show(`There was an issue! ${e.toString()}`) + if (e?.name !== 'AbortError') { + logger.error('Failed to unmute account', {error: e}) + Toast.show(`There was an issue! ${e.toString()}`) + } } - }, [track, view]) + }, [track, queueUnmute]) const onPressBlockAccount = React.useCallback(async () => { track('ProfileHeader:BlockAccountButtonClicked') - store.shell.openModal({ + openModal({ name: 'confirm', - title: 'Block Account', - message: - 'Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.', + title: _(msg`Block Account`), + message: _( + msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, + ), onPressConfirm: async () => { try { - await view.blockAccount() - onRefreshAll() + await queueBlock() Toast.show('Account blocked') } catch (e: any) { - logger.error('Failed to block account', {error: e}) - Toast.show(`There was an issue! ${e.toString()}`) + if (e?.name !== 'AbortError') { + logger.error('Failed to block account', {error: e}) + Toast.show(`There was an issue! ${e.toString()}`) + } } }, }) - }, [track, view, store, onRefreshAll]) + }, [track, queueBlock, openModal, _]) const onPressUnblockAccount = React.useCallback(async () => { track('ProfileHeader:UnblockAccountButtonClicked') - store.shell.openModal({ + openModal({ name: 'confirm', - title: 'Unblock Account', - message: - 'The account will be able to interact with you after unblocking.', + title: _(msg`Unblock Account`), + message: _( + msg`The account will be able to interact with you after unblocking.`, + ), onPressConfirm: async () => { try { - await view.unblockAccount() - onRefreshAll() + await queueUnblock() Toast.show('Account unblocked') } catch (e: any) { - logger.error('Failed to unblock account', {error: e}) - Toast.show(`There was an issue! ${e.toString()}`) + if (e?.name !== 'AbortError') { + logger.error('Failed to unblock account', {error: e}) + Toast.show(`There was an issue! ${e.toString()}`) + } } }, }) - }, [track, view, store, onRefreshAll]) + }, [track, queueUnblock, openModal, _]) const onPressReportAccount = React.useCallback(() => { track('ProfileHeader:ReportAccountButtonClicked') - store.shell.openModal({ + openModal({ name: 'report', - did: view.did, + did: profile.did, }) - }, [track, store, view]) + }, [track, openModal, profile]) const isMe = React.useMemo( - () => store.me.did === view.did, - [store.me.did, view.did], + () => currentAccount?.did === profile.did, + [currentAccount, profile], ) const dropdownItems: DropdownItem[] = React.useMemo(() => { let items: DropdownItem[] = [ { testID: 'profileHeaderDropdownShareBtn', - label: 'Share', + label: isWeb ? _(msg`Copy link to profile`) : _(msg`Share`), onPress: onPressShare, icon: { ios: { @@ -277,71 +320,81 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ }, }, ] - items.push({label: 'separator'}) - items.push({ - testID: 'profileHeaderDropdownListAddRemoveBtn', - label: 'Add to Lists', - onPress: onPressAddRemoveLists, - icon: { - ios: { - name: 'list.bullet', + if (hasSession) { + items.push({label: 'separator'}) + items.push({ + testID: 'profileHeaderDropdownListAddRemoveBtn', + label: _(msg`Add to Lists`), + onPress: onPressAddRemoveLists, + icon: { + ios: { + name: 'list.bullet', + }, + android: 'ic_menu_add', + web: 'list', }, - android: 'ic_menu_add', - web: 'list', - }, - }) - if (!isMe) { - if (!view.viewer.blocking) { - items.push({ - testID: 'profileHeaderDropdownMuteBtn', - label: view.viewer.muted ? 'Unmute Account' : 'Mute Account', - onPress: view.viewer.muted - ? onPressUnmuteAccount - : onPressMuteAccount, - icon: { - ios: { - name: 'speaker.slash', + }) + if (!isMe) { + if (!profile.viewer?.blocking) { + if (!profile.viewer?.mutedByList) { + items.push({ + testID: 'profileHeaderDropdownMuteBtn', + label: profile.viewer?.muted + ? _(msg`Unmute Account`) + : _(msg`Mute Account`), + onPress: profile.viewer?.muted + ? onPressUnmuteAccount + : onPressMuteAccount, + icon: { + ios: { + name: 'speaker.slash', + }, + android: 'ic_lock_silent_mode', + web: 'comment-slash', + }, + }) + } + } + if (!profile.viewer?.blockingByList) { + items.push({ + testID: 'profileHeaderDropdownBlockBtn', + label: profile.viewer?.blocking + ? _(msg`Unblock Account`) + : _(msg`Block Account`), + onPress: profile.viewer?.blocking + ? onPressUnblockAccount + : onPressBlockAccount, + icon: { + ios: { + name: 'person.fill.xmark', + }, + android: 'ic_menu_close_clear_cancel', + web: 'user-slash', }, - android: 'ic_lock_silent_mode', - web: 'comment-slash', - }, - }) - } - if (!view.viewer.blockingByList) { + }) + } items.push({ - testID: 'profileHeaderDropdownBlockBtn', - label: view.viewer.blocking ? 'Unblock Account' : 'Block Account', - onPress: view.viewer.blocking - ? onPressUnblockAccount - : onPressBlockAccount, + testID: 'profileHeaderDropdownReportBtn', + label: _(msg`Report Account`), + onPress: onPressReportAccount, icon: { ios: { - name: 'person.fill.xmark', + name: 'exclamationmark.triangle', }, - android: 'ic_menu_close_clear_cancel', - web: 'user-slash', + android: 'ic_menu_report_image', + web: 'circle-exclamation', }, }) } - items.push({ - testID: 'profileHeaderDropdownReportBtn', - label: 'Report Account', - onPress: onPressReportAccount, - icon: { - ios: { - name: 'exclamationmark.triangle', - }, - android: 'ic_menu_report_image', - web: 'circle-exclamation', - }, - }) } return items }, [ isMe, - view.viewer.muted, - view.viewer.blocking, - view.viewer.blockingByList, + hasSession, + profile.viewer?.muted, + profile.viewer?.mutedByList, + profile.viewer?.blocking, + profile.viewer?.blockingByList, onPressShare, onPressUnmuteAccount, onPressMuteAccount, @@ -349,16 +402,18 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ onPressBlockAccount, onPressReportAccount, onPressAddRemoveLists, + _, ]) - const blockHide = !isMe && (view.viewer.blocking || view.viewer.blockedBy) - const following = formatCount(view.followsCount) - const followers = formatCount(view.followersCount) - const pluralizedFollowers = pluralize(view.followersCount, 'follower') + const blockHide = + !isMe && (profile.viewer?.blocking || profile.viewer?.blockedBy) + const following = formatCount(profile.followsCount || 0) + const followers = formatCount(profile.followersCount || 0) + const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower') return ( <View style={pal.view}> - <UserBanner banner={view.banner} moderation={view.moderation.avatar} /> + <UserBanner banner={profile.banner} moderation={moderation.avatar} /> <View style={styles.content}> <View style={[styles.buttonsLine]}> {isMe ? ( @@ -367,29 +422,29 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ onPress={onPressEditProfile} style={[styles.btn, styles.mainBtn, pal.btn]} accessibilityRole="button" - accessibilityLabel="Edit profile" + accessibilityLabel={_(msg`Edit profile`)} accessibilityHint="Opens editor for profile display name, avatar, background image, and description"> <Text type="button" style={pal.text}> - Edit Profile + <Trans>Edit Profile</Trans> </Text> </TouchableOpacity> - ) : view.viewer.blocking ? ( - view.viewer.blockingByList ? null : ( + ) : profile.viewer?.blocking ? ( + profile.viewer?.blockingByList ? null : ( <TouchableOpacity testID="unblockBtn" onPress={onPressUnblockAccount} style={[styles.btn, styles.mainBtn, pal.btn]} accessibilityRole="button" - accessibilityLabel="Unblock" + accessibilityLabel={_(msg`Unblock`)} accessibilityHint=""> <Text type="button" style={[pal.text, s.bold]}> - Unblock + <Trans>Unblock</Trans> </Text> </TouchableOpacity> ) - ) : !view.viewer.blockedBy ? ( + ) : !profile.viewer?.blockedBy ? ( <> - {!isProfilePreview && ( + {!isProfilePreview && hasSession && ( <TouchableOpacity testID="suggestedFollowsBtn" onPress={() => setShowSuggestedFollows(!showSuggestedFollows)} @@ -405,7 +460,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ }, ]} accessibilityRole="button" - accessibilityLabel={`Show follows similar to ${view.handle}`} + accessibilityLabel={`Show follows similar to ${profile.handle}`} accessibilityHint={`Shows a list of users similar to this user.`}> <FontAwesomeIcon icon="user-plus" @@ -413,7 +468,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ pal.text, { color: showSuggestedFollows - ? colors.white + ? pal.textInverted.color : pal.text.color, }, ]} @@ -422,38 +477,37 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ </TouchableOpacity> )} - {store.me.follows.getFollowState(view.did) === - FollowState.Following ? ( + {profile.viewer?.following ? ( <TouchableOpacity testID="unfollowBtn" - onPress={onPressToggleFollow} + onPress={onPressUnfollow} style={[styles.btn, styles.mainBtn, pal.btn]} accessibilityRole="button" - accessibilityLabel={`Unfollow ${view.handle}`} - accessibilityHint={`Hides posts from ${view.handle} in your feed`}> + accessibilityLabel={`Unfollow ${profile.handle}`} + accessibilityHint={`Hides posts from ${profile.handle} in your feed`}> <FontAwesomeIcon icon="check" style={[pal.text, s.mr5]} size={14} /> <Text type="button" style={pal.text}> - Following + <Trans>Following</Trans> </Text> </TouchableOpacity> ) : ( <TouchableOpacity testID="followBtn" - onPress={onPressToggleFollow} + onPress={onPressFollow} style={[styles.btn, styles.mainBtn, palInverted.view]} accessibilityRole="button" - accessibilityLabel={`Follow ${view.handle}`} - accessibilityHint={`Shows posts from ${view.handle} in your feed`}> + accessibilityLabel={`Follow ${profile.handle}`} + accessibilityHint={`Shows posts from ${profile.handle} in your feed`}> <FontAwesomeIcon icon="plus" style={[palInverted.text, s.mr5]} /> <Text type="button" style={[palInverted.text, s.bold]}> - Follow + <Trans>Follow</Trans> </Text> </TouchableOpacity> )} @@ -463,7 +517,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ <NativeDropdown testID="profileHeaderDropdownBtn" items={dropdownItems} - accessibilityLabel="More options" + accessibilityLabel={_(msg`More options`)} accessibilityHint=""> <View style={[styles.btn, styles.secondaryBtn, pal.btn]}> <FontAwesomeIcon icon="ellipsis" size={20} style={[pal.text]} /> @@ -477,16 +531,16 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ type="title-2xl" style={[pal.text, styles.title]}> {sanitizeDisplayName( - view.displayName || sanitizeHandle(view.handle), - view.moderation.profile, + profile.displayName || sanitizeHandle(profile.handle), + moderation.profile, )} </Text> </View> <View style={styles.handleLine}> - {view.viewer.followedBy && !blockHide ? ( + {profile.viewer?.followedBy && !blockHide ? ( <View style={[styles.pill, pal.btn, s.mr5]}> <Text type="xs" style={[pal.text]}> - Follows you + <Trans>Follows you</Trans> </Text> </View> ) : undefined} @@ -498,7 +552,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ invalidHandle ? styles.invalidHandle : undefined, styles.handle, ]}> - {invalidHandle ? '⚠Invalid Handle' : `@${view.handle}`} + {invalidHandle ? '⚠Invalid Handle' : `@${profile.handle}`} </ThemedText> </View> {!blockHide && ( @@ -507,8 +561,12 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ <Link testID="profileHeaderFollowersButton" style={[s.flexRow, s.mr10]} - href={makeProfileLink(view, 'followers')} - onPressOut={() => trackPress('Followers')} + href={makeProfileLink(profile, 'followers')} + onPressOut={() => + track(`ProfileHeader:FollowersButtonClicked`, { + handle: profile.handle, + }) + } asAnchor accessibilityLabel={`${followers} ${pluralizedFollowers}`} accessibilityHint={'Opens followers list'}> @@ -522,8 +580,12 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ <Link testID="profileHeaderFollowsButton" style={[s.flexRow, s.mr10]} - href={makeProfileLink(view, 'follows')} - onPressOut={() => trackPress('Follows')} + href={makeProfileLink(profile, 'follows')} + onPressOut={() => + track(`ProfileHeader:FollowsButtonClicked`, { + handle: profile.handle, + }) + } asAnchor accessibilityLabel={`${following} following`} accessibilityHint={'Opens following list'}> @@ -531,34 +593,32 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ {following}{' '} </Text> <Text type="md" style={[pal.textLight]}> - following + <Trans>following</Trans> </Text> </Link> <Text type="md" style={[s.bold, pal.text]}> - {formatCount(view.postsCount)}{' '} + {formatCount(profile.postsCount || 0)}{' '} <Text type="md" style={[pal.textLight]}> - {pluralize(view.postsCount, 'post')} + {pluralize(profile.postsCount || 0, 'post')} </Text> </Text> </View> - {view.description && - view.descriptionRichText && - !view.moderation.profile.blur ? ( + {descriptionRT && !moderation.profile.blur ? ( <RichText testID="profileHeaderDescription" style={[styles.description, pal.text]} numberOfLines={15} - richText={view.descriptionRichText} + richText={descriptionRT} /> ) : undefined} </> )} - <ProfileHeaderAlerts moderation={view.moderation} /> + <ProfileHeaderAlerts moderation={moderation} /> </View> {!isProfilePreview && ( <ProfileHeaderSuggestedFollows - actorDid={view.did} + actorDid={profile.did} active={showSuggestedFollows} requestDismiss={() => setShowSuggestedFollows(!showSuggestedFollows)} /> @@ -570,7 +630,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ onPress={onPressBack} hitSlop={BACK_HITSLOP} accessibilityRole="button" - accessibilityLabel="Back" + accessibilityLabel={_(msg`Back`)} accessibilityHint=""> <View style={styles.backBtnWrapper}> <BlurView style={styles.backBtn} blurType="dark"> @@ -583,20 +643,21 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ testID="profileHeaderAviButton" onPress={onPressAvi} accessibilityRole="image" - accessibilityLabel={`View ${view.handle}'s avatar`} + accessibilityLabel={`View ${profile.handle}'s avatar`} accessibilityHint=""> <View style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}> <UserAvatar size={80} - avatar={view.avatar} - moderation={view.moderation.avatar} + avatar={profile.avatar} + moderation={moderation.avatar} /> </View> </TouchableWithoutFeedback> </View> ) -}) +} +ProfileHeaderLoaded = memo(ProfileHeaderLoaded) const styles = StyleSheet.create({ banner: { diff --git a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx index cf759ddd1..f648c9801 100644 --- a/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx +++ b/src/view/com/profile/ProfileHeaderSuggestedFollows.tsx @@ -6,20 +6,16 @@ import Animated, { useAnimatedStyle, Easing, } from 'react-native-reanimated' -import {useQuery} from '@tanstack/react-query' import {AppBskyActorDefs, moderateProfile} from '@atproto/api' -import {observer} from 'mobx-react-lite' import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import * as Toast from '../util/Toast' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {Text} from 'view/com/util/text/Text' import {UserAvatar} from 'view/com/util/UserAvatar' -import {useFollowProfile} from 'lib/hooks/useFollowProfile' import {Button} from 'view/com/util/forms/Button' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' @@ -27,6 +23,10 @@ import {makeProfileLink} from 'lib/routes/links' import {Link} from 'view/com/util/Link' import {useAnalytics} from 'lib/analytics/analytics' import {isWeb} from 'platform/detection' +import {useModerationOpts} from '#/state/queries/preferences' +import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useProfileFollowMutationQueue} from '#/state/queries/profile' const OUTER_PADDING = 10 const INNER_PADDING = 14 @@ -43,7 +43,6 @@ export function ProfileHeaderSuggestedFollows({ }) { const {track} = useAnalytics() const pal = usePalette('default') - const store = useStores() const animatedHeight = useSharedValue(0) const animatedStyles = useAnimatedStyle(() => ({ opacity: animatedHeight.value / TOTAL_HEIGHT, @@ -66,31 +65,8 @@ export function ProfileHeaderSuggestedFollows({ } }, [active, animatedHeight, track]) - const {isLoading, data: suggestedFollows} = useQuery({ - enabled: active, - cacheTime: 0, - staleTime: 0, - queryKey: ['suggested_follows_by_actor', actorDid], - async queryFn() { - try { - const { - data: {suggestions}, - success, - } = await store.agent.app.bsky.graph.getSuggestedFollowsByActor({ - actor: actorDid, - }) - - if (!success) { - return [] - } - - store.me.follows.hydrateMany(suggestions) - - return suggestions - } catch (e) { - return [] - } - }, + const {isLoading, data} = useSuggestedFollowsByActorQuery({ + did: actorDid, }) return ( @@ -149,8 +125,8 @@ export function ProfileHeaderSuggestedFollows({ <SuggestedFollowSkeleton /> <SuggestedFollowSkeleton /> </> - ) : suggestedFollows ? ( - suggestedFollows.map(profile => ( + ) : data ? ( + data.suggestions.map(profile => ( <SuggestedFollow key={profile.did} profile={profile} /> )) ) : ( @@ -214,29 +190,43 @@ function SuggestedFollowSkeleton() { ) } -const SuggestedFollow = observer(function SuggestedFollowImpl({ - profile, +function SuggestedFollow({ + profile: profileUnshadowed, }: { profile: AppBskyActorDefs.ProfileView }) { const {track} = useAnalytics() const pal = usePalette('default') - const store = useStores() - const {following, toggle} = useFollowProfile(profile) - const moderation = moderateProfile(profile, store.preferences.moderationOpts) + const moderationOpts = useModerationOpts() + const profile = useProfileShadow(profileUnshadowed) + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) - const onPress = React.useCallback(async () => { + const onPressFollow = React.useCallback(async () => { try { - const {following: isFollowing} = await toggle() - - if (isFollowing) { - track('ProfileHeader:SuggestedFollowFollowed') + track('ProfileHeader:SuggestedFollowFollowed') + await queueFollow() + } catch (e: any) { + if (e?.name !== 'AbortError') { + Toast.show('An issue occurred, please try again.') } + } + }, [queueFollow, track]) + + const onPressUnfollow = React.useCallback(async () => { + try { + await queueUnfollow() } catch (e: any) { - Toast.show('An issue occurred, please try again.') + if (e?.name !== 'AbortError') { + Toast.show('An issue occurred, please try again.') + } } - }, [toggle, track]) + }, [queueUnfollow]) + if (!moderationOpts) { + return null + } + const moderation = moderateProfile(profile, moderationOpts) + const following = profile.viewer?.following return ( <Link href={makeProfileLink(profile)} @@ -278,13 +268,12 @@ const SuggestedFollow = observer(function SuggestedFollowImpl({ label={following ? 'Unfollow' : 'Follow'} type="inverted" labelStyle={{textAlign: 'center'}} - onPress={onPress} - withLoading + onPress={following ? onPressUnfollow : onPressFollow} /> </View> </Link> ) -}) +} const styles = StyleSheet.create({ suggestedFollowCardOuter: { diff --git a/src/view/com/profile/ProfileSubpageHeader.tsx b/src/view/com/profile/ProfileSubpageHeader.tsx index 0b8015aa9..0e245f0f4 100644 --- a/src/view/com/profile/ProfileSubpageHeader.tsx +++ b/src/view/com/profile/ProfileSubpageHeader.tsx @@ -1,6 +1,5 @@ import React from 'react' import {Pressable, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {useNavigation} from '@react-navigation/native' import {usePalette} from 'lib/hooks/usePalette' @@ -12,14 +11,16 @@ import {LoadingPlaceholder} from '../util/LoadingPlaceholder' import {CenteredView} from '../util/Views' import {sanitizeHandle} from 'lib/strings/handles' import {makeProfileLink} from 'lib/routes/links' -import {useStores} from 'state/index' import {NavigationProp} from 'lib/routes/types' import {BACK_HITSLOP} from 'lib/constants' import {isNative} from 'platform/detection' -import {ImagesLightbox} from 'state/models/ui/shell' +import {useLightboxControls, ImagesLightbox} from '#/state/lightbox' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' import {useSetDrawerOpen} from '#/state/shell' +import {emitSoftReset} from '#/state/events' -export const ProfileSubpageHeader = observer(function HeaderImpl({ +export function ProfileSubpageHeader({ isLoading, href, title, @@ -42,10 +43,11 @@ export const ProfileSubpageHeader = observer(function HeaderImpl({ | undefined avatarType: UserAvatarType }>) { - const store = useStores() const setDrawerOpen = useSetDrawerOpen() const navigation = useNavigation<NavigationProp>() + const {_} = useLingui() const {isMobile} = useWebMediaQueries() + const {openLightbox} = useLightboxControls() const pal = usePalette('default') const canGoBack = navigation.canGoBack() @@ -65,9 +67,9 @@ export const ProfileSubpageHeader = observer(function HeaderImpl({ if ( avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) ) { - store.shell.openLightbox(new ImagesLightbox([{uri: avatar}], 0)) + openLightbox(new ImagesLightbox([{uri: avatar}], 0)) } - }, [store, avatar]) + }, [openLightbox, avatar]) return ( <CenteredView style={pal.view}> @@ -123,7 +125,7 @@ export const ProfileSubpageHeader = observer(function HeaderImpl({ testID="headerAviButton" onPress={onPressAvi} accessibilityRole="image" - accessibilityLabel="View the avatar" + accessibilityLabel={_(msg`View the avatar`)} accessibilityHint="" style={{width: 58}}> <UserAvatar type={avatarType} size={58} avatar={avatar} /> @@ -142,7 +144,7 @@ export const ProfileSubpageHeader = observer(function HeaderImpl({ href={href} style={[pal.text, {fontWeight: 'bold'}]} text={title || ''} - onPress={() => store.emitScreenSoftReset()} + onPress={emitSoftReset} numberOfLines={4} /> )} @@ -178,7 +180,7 @@ export const ProfileSubpageHeader = observer(function HeaderImpl({ </View> </CenteredView> ) -}) +} const styles = StyleSheet.create({ backBtn: { diff --git a/src/view/com/search/HeaderWithInput.tsx b/src/view/com/search/HeaderWithInput.tsx deleted file mode 100644 index 1a6b427c6..000000000 --- a/src/view/com/search/HeaderWithInput.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import React from 'react' -import {StyleSheet, TextInput, TouchableOpacity, View} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {Text} from 'view/com/util/text/Text' -import {MagnifyingGlassIcon} from 'lib/icons' -import {useTheme} from 'lib/ThemeContext' -import {usePalette} from 'lib/hooks/usePalette' -import {useAnalytics} from 'lib/analytics/analytics' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {HITSLOP_10} from 'lib/constants' -import {useSetDrawerOpen} from '#/state/shell' - -interface Props { - isInputFocused: boolean - query: string - setIsInputFocused: (v: boolean) => void - onChangeQuery: (v: string) => void - onPressClearQuery: () => void - onPressCancelSearch: () => void - onSubmitQuery: () => void - showMenu?: boolean -} -export function HeaderWithInput({ - isInputFocused, - query, - setIsInputFocused, - onChangeQuery, - onPressClearQuery, - onPressCancelSearch, - onSubmitQuery, - showMenu = true, -}: Props) { - const setDrawerOpen = useSetDrawerOpen() - const theme = useTheme() - const pal = usePalette('default') - const {track} = useAnalytics() - const textInput = React.useRef<TextInput>(null) - const {isMobile} = useWebMediaQueries() - - const onPressMenu = React.useCallback(() => { - track('ViewHeader:MenuButtonClicked') - setDrawerOpen(true) - }, [track, setDrawerOpen]) - - const onPressCancelSearchInner = React.useCallback(() => { - onPressCancelSearch() - textInput.current?.blur() - }, [onPressCancelSearch, textInput]) - - return ( - <View - style={[ - pal.view, - pal.border, - styles.header, - !isMobile && styles.headerDesktop, - ]}> - {showMenu && isMobile ? ( - <TouchableOpacity - testID="viewHeaderBackOrMenuBtn" - onPress={onPressMenu} - hitSlop={HITSLOP_10} - style={styles.headerMenuBtn} - accessibilityRole="button" - accessibilityLabel="Menu" - accessibilityHint="Access navigation links and settings"> - <FontAwesomeIcon icon="bars" size={18} color={pal.colors.textLight} /> - </TouchableOpacity> - ) : null} - <View - style={[ - {backgroundColor: pal.colors.backgroundLight}, - styles.headerSearchContainer, - ]}> - <MagnifyingGlassIcon - style={[pal.icon, styles.headerSearchIcon]} - size={21} - /> - <TextInput - testID="searchTextInput" - ref={textInput} - placeholder="Search" - placeholderTextColor={pal.colors.textLight} - selectTextOnFocus - returnKeyType="search" - value={query} - style={[pal.text, styles.headerSearchInput]} - keyboardAppearance={theme.colorScheme} - onFocus={() => setIsInputFocused(true)} - onBlur={() => setIsInputFocused(false)} - onChangeText={onChangeQuery} - onSubmitEditing={onSubmitQuery} - autoFocus={false} - accessibilityRole="search" - accessibilityLabel="Search" - accessibilityHint="" - autoCorrect={false} - autoCapitalize="none" - /> - {query ? ( - <TouchableOpacity - testID="searchTextInputClearBtn" - onPress={onPressClearQuery} - accessibilityRole="button" - accessibilityLabel="Clear search query" - accessibilityHint=""> - <FontAwesomeIcon - icon="xmark" - size={16} - style={pal.textLight as FontAwesomeIconStyle} - /> - </TouchableOpacity> - ) : undefined} - </View> - {query || isInputFocused ? ( - <View style={styles.headerCancelBtn}> - <TouchableOpacity - onPress={onPressCancelSearchInner} - accessibilityRole="button"> - <Text style={pal.text}>Cancel</Text> - </TouchableOpacity> - </View> - ) : undefined} - </View> - ) -} - -const styles = StyleSheet.create({ - header: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 12, - paddingVertical: 4, - }, - headerDesktop: { - borderWidth: 1, - borderTopWidth: 0, - paddingVertical: 10, - }, - headerMenuBtn: { - width: 30, - height: 30, - borderRadius: 30, - marginRight: 6, - paddingBottom: 2, - alignItems: 'center', - justifyContent: 'center', - }, - headerSearchContainer: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - borderRadius: 30, - paddingHorizontal: 12, - paddingVertical: 8, - }, - headerSearchIcon: { - marginRight: 6, - alignSelf: 'center', - }, - headerSearchInput: { - flex: 1, - fontSize: 17, - }, - headerCancelBtn: { - paddingLeft: 10, - }, - - searchPrompt: { - textAlign: 'center', - paddingTop: 10, - }, - - suggestions: { - marginBottom: 8, - }, -}) diff --git a/src/view/com/search/SearchResults.tsx b/src/view/com/search/SearchResults.tsx deleted file mode 100644 index 87378bba7..000000000 --- a/src/view/com/search/SearchResults.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import React from 'react' -import {StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {SearchUIModel} from 'state/models/ui/search' -import {CenteredView, ScrollView} from '../util/Views' -import {Pager, RenderTabBarFnProps} from 'view/com/pager/Pager' -import {TabBar} from 'view/com/pager/TabBar' -import {Post} from 'view/com/post/Post' -import {ProfileCardWithFollowBtn} from 'view/com/profile/ProfileCard' -import { - PostFeedLoadingPlaceholder, - ProfileCardFeedLoadingPlaceholder, -} from 'view/com/util/LoadingPlaceholder' -import {Text} from 'view/com/util/text/Text' -import {usePalette} from 'lib/hooks/usePalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {s} from 'lib/styles' - -const SECTIONS = ['Posts', 'Users'] - -export const SearchResults = observer(function SearchResultsImpl({ - model, -}: { - model: SearchUIModel -}) { - const pal = usePalette('default') - const {isMobile} = useWebMediaQueries() - - const renderTabBar = React.useCallback( - (props: RenderTabBarFnProps) => { - return ( - <CenteredView style={[pal.border, pal.view, styles.tabBar]}> - <TabBar - items={SECTIONS} - {...props} - key={SECTIONS.join()} - indicatorColor={pal.colors.link} - /> - </CenteredView> - ) - }, - [pal], - ) - - return ( - <Pager renderTabBar={renderTabBar} tabBarPosition="top" initialPage={0}> - <View - style={{ - paddingTop: isMobile ? 42 : 50, - }}> - <PostResults key="0" model={model} /> - </View> - <View - style={{ - paddingTop: isMobile ? 42 : 50, - }}> - <Profiles key="1" model={model} /> - </View> - </Pager> - ) -}) - -const PostResults = observer(function PostResultsImpl({ - model, -}: { - model: SearchUIModel -}) { - const pal = usePalette('default') - if (model.isPostsLoading) { - return ( - <CenteredView> - <PostFeedLoadingPlaceholder /> - </CenteredView> - ) - } - - if (model.posts.length === 0) { - return ( - <CenteredView> - <Text type="xl" style={[styles.empty, pal.text]}> - No posts found for "{model.query}" - </Text> - </CenteredView> - ) - } - - return ( - <ScrollView style={[pal.view]}> - {model.posts.map(post => ( - <Post key={post.resolvedUri} view={post} hideError /> - ))} - <View style={s.footerSpacer} /> - <View style={s.footerSpacer} /> - <View style={s.footerSpacer} /> - </ScrollView> - ) -}) - -const Profiles = observer(function ProfilesImpl({ - model, -}: { - model: SearchUIModel -}) { - const pal = usePalette('default') - if (model.isProfilesLoading) { - return ( - <CenteredView> - <ProfileCardFeedLoadingPlaceholder /> - </CenteredView> - ) - } - - if (model.profiles.length === 0) { - return ( - <CenteredView> - <Text type="xl" style={[styles.empty, pal.text]}> - No users found for "{model.query}" - </Text> - </CenteredView> - ) - } - - return ( - <ScrollView style={pal.view}> - {model.profiles.map(item => ( - <ProfileCardWithFollowBtn key={item.did} profile={item} /> - ))} - <View style={s.footerSpacer} /> - <View style={s.footerSpacer} /> - <View style={s.footerSpacer} /> - </ScrollView> - ) -}) - -const styles = StyleSheet.create({ - tabBar: { - borderBottomWidth: 1, - position: 'absolute', - zIndex: 1, - left: 0, - right: 0, - top: 0, - flexDirection: 'column', - alignItems: 'center', - }, - empty: { - paddingHorizontal: 14, - paddingVertical: 16, - }, -}) diff --git a/src/view/com/search/Suggestions.tsx b/src/view/com/search/Suggestions.tsx deleted file mode 100644 index 2a80d10ae..000000000 --- a/src/view/com/search/Suggestions.tsx +++ /dev/null @@ -1,265 +0,0 @@ -import React, {forwardRef, ForwardedRef} from 'react' -import {RefreshControl, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {AppBskyActorDefs} from '@atproto/api' -import {FlatList} from '../util/Views' -import {FoafsModel} from 'state/models/discovery/foafs' -import { - SuggestedActorsModel, - SuggestedActor, -} from 'state/models/discovery/suggested-actors' -import {Text} from '../util/text/Text' -import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' -import {ProfileCardLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {sanitizeHandle} from 'lib/strings/handles' -import {RefWithInfoAndFollowers} from 'state/models/discovery/foafs' -import {usePalette} from 'lib/hooks/usePalette' -import {s} from 'lib/styles' - -interface Heading { - _reactKey: string - type: 'heading' - title: string -} -interface RefWrapper { - _reactKey: string - type: 'ref' - ref: RefWithInfoAndFollowers -} -interface SuggestWrapper { - _reactKey: string - type: 'suggested' - suggested: SuggestedActor -} -interface ProfileView { - _reactKey: string - type: 'profile-view' - view: AppBskyActorDefs.ProfileViewBasic -} -interface LoadingPlaceholder { - _reactKey: string - type: 'loading-placeholder' -} -type Item = - | Heading - | RefWrapper - | SuggestWrapper - | ProfileView - | LoadingPlaceholder - -// FIXME(dan): Figure out why the false positives -/* eslint-disable react/prop-types */ - -export const Suggestions = observer( - forwardRef(function SuggestionsImpl( - { - foafs, - suggestedActors, - }: { - foafs: FoafsModel - suggestedActors: SuggestedActorsModel - }, - flatListRef: ForwardedRef<FlatList>, - ) { - const pal = usePalette('default') - const [refreshing, setRefreshing] = React.useState(false) - const data = React.useMemo(() => { - let items: Item[] = [] - - if (suggestedActors.hasContent) { - items = items - .concat([ - { - _reactKey: '__suggested_heading__', - type: 'heading', - title: 'Suggested Follows', - }, - ]) - .concat( - suggestedActors.suggestions.map(suggested => ({ - _reactKey: `suggested-${suggested.did}`, - type: 'suggested', - suggested, - })), - ) - } else if (suggestedActors.isLoading) { - items = items.concat([ - { - _reactKey: '__suggested_heading__', - type: 'heading', - title: 'Suggested Follows', - }, - {_reactKey: '__suggested_loading__', type: 'loading-placeholder'}, - ]) - } - if (foafs.isLoading) { - items = items.concat([ - { - _reactKey: '__popular_heading__', - type: 'heading', - title: 'In Your Network', - }, - {_reactKey: '__foafs_loading__', type: 'loading-placeholder'}, - ]) - } else { - if (foafs.popular.length > 0) { - items = items - .concat([ - { - _reactKey: '__popular_heading__', - type: 'heading', - title: 'In Your Network', - }, - ]) - .concat( - foafs.popular.map(ref => ({ - _reactKey: `popular-${ref.did}`, - type: 'ref', - ref, - })), - ) - } - for (const source of foafs.sources) { - const item = foafs.foafs.get(source) - if (!item || item.follows.length === 0) { - continue - } - items = items - .concat([ - { - _reactKey: `__${item.did}_heading__`, - type: 'heading', - title: `Followed by ${sanitizeDisplayName( - item.displayName || sanitizeHandle(item.handle), - )}`, - }, - ]) - .concat( - item.follows.slice(0, 10).map(view => ({ - _reactKey: `${item.did}-${view.did}`, - type: 'profile-view', - view, - })), - ) - } - } - - return items - }, [ - foafs.isLoading, - foafs.popular, - suggestedActors.isLoading, - suggestedActors.hasContent, - suggestedActors.suggestions, - foafs.sources, - foafs.foafs, - ]) - - const onRefresh = React.useCallback(async () => { - setRefreshing(true) - try { - await foafs.fetch() - } finally { - setRefreshing(false) - } - }, [foafs, setRefreshing]) - - const renderItem = React.useCallback( - ({item}: {item: Item}) => { - if (item.type === 'heading') { - return ( - <Text type="title" style={[styles.heading, pal.text]}> - {item.title} - </Text> - ) - } - if (item.type === 'ref') { - return ( - <View style={[styles.card, pal.view, pal.border]}> - <ProfileCardWithFollowBtn - key={item.ref.did} - profile={item.ref} - noBg - noBorder - followers={ - item.ref.followers - ? (item.ref.followers as AppBskyActorDefs.ProfileView[]) - : undefined - } - /> - </View> - ) - } - if (item.type === 'profile-view') { - return ( - <View style={[styles.card, pal.view, pal.border]}> - <ProfileCardWithFollowBtn - key={item.view.did} - profile={item.view} - noBg - noBorder - /> - </View> - ) - } - if (item.type === 'suggested') { - return ( - <View style={[styles.card, pal.view, pal.border]}> - <ProfileCardWithFollowBtn - key={item.suggested.did} - profile={item.suggested} - noBg - noBorder - /> - </View> - ) - } - if (item.type === 'loading-placeholder') { - return ( - <View> - <ProfileCardLoadingPlaceholder /> - <ProfileCardLoadingPlaceholder /> - <ProfileCardLoadingPlaceholder /> - <ProfileCardLoadingPlaceholder /> - </View> - ) - } - return null - }, - [pal], - ) - - return ( - <FlatList - ref={flatListRef} - data={data} - keyExtractor={item => item._reactKey} - refreshControl={ - <RefreshControl - refreshing={refreshing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } - renderItem={renderItem} - initialNumToRender={15} - contentContainerStyle={s.contentContainer} - /> - ) - }), -) - -const styles = StyleSheet.create({ - heading: { - fontWeight: 'bold', - paddingHorizontal: 12, - paddingBottom: 8, - paddingTop: 16, - }, - - card: { - borderTopWidth: 1, - }, -}) diff --git a/src/view/com/testing/TestCtrls.e2e.tsx b/src/view/com/testing/TestCtrls.e2e.tsx index db9b6b4bf..41abc25d3 100644 --- a/src/view/com/testing/TestCtrls.e2e.tsx +++ b/src/view/com/testing/TestCtrls.e2e.tsx @@ -1,7 +1,10 @@ import React from 'react' import {Pressable, View} from 'react-native' -import {useStores} from 'state/index' import {navigate} from '../../../Navigation' +import {useModalControls} from '#/state/modals' +import {useQueryClient} from '@tanstack/react-query' +import {useSessionApi} from '#/state/session' +import {useSetFeedViewPreferencesMutation} from '#/state/queries/preferences' /** * This utility component is only included in the test simulator @@ -12,16 +15,19 @@ import {navigate} from '../../../Navigation' const BTN = {height: 1, width: 1, backgroundColor: 'red'} export function TestCtrls() { - const store = useStores() + const queryClient = useQueryClient() + const {logout, login} = useSessionApi() + const {openModal} = useModalControls() + const {mutate: setFeedViewPref} = useSetFeedViewPreferencesMutation() const onPressSignInAlice = async () => { - await store.session.login({ + await login({ service: 'http://localhost:3000', identifier: 'alice.test', password: 'hunter2', }) } const onPressSignInBob = async () => { - await store.session.login({ + await login({ service: 'http://localhost:3000', identifier: 'bob.test', password: 'hunter2', @@ -43,7 +49,7 @@ export function TestCtrls() { /> <Pressable testID="e2eSignOut" - onPress={() => store.session.logout()} + onPress={() => logout()} accessibilityRole="button" style={BTN} /> @@ -73,19 +79,19 @@ export function TestCtrls() { /> <Pressable testID="e2eToggleMergefeed" - onPress={() => store.preferences.toggleHomeFeedMergeFeedEnabled()} + onPress={() => setFeedViewPref({lab_mergeFeedEnabled: true})} accessibilityRole="button" style={BTN} /> <Pressable testID="e2eRefreshHome" - onPress={() => store.me.mainFeed.refresh()} + onPress={() => queryClient.invalidateQueries({queryKey: ['post-feed']})} accessibilityRole="button" style={BTN} /> <Pressable testID="e2eOpenInviteCodesModal" - onPress={() => store.shell.openModal({name: 'invite-codes'})} + onPress={() => openModal({name: 'invite-codes'})} accessibilityRole="button" style={BTN} /> diff --git a/src/view/com/util/AccountDropdownBtn.tsx b/src/view/com/util/AccountDropdownBtn.tsx index 29571696b..76d493886 100644 --- a/src/view/com/util/AccountDropdownBtn.tsx +++ b/src/view/com/util/AccountDropdownBtn.tsx @@ -5,19 +5,23 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {s} from 'lib/styles' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {DropdownItem, NativeDropdown} from './forms/NativeDropdown' import * as Toast from '../../com/util/Toast' +import {useSessionApi, SessionAccount} from '#/state/session' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' -export function AccountDropdownBtn({handle}: {handle: string}) { - const store = useStores() +export function AccountDropdownBtn({account}: {account: SessionAccount}) { const pal = usePalette('default') + const {removeAccount} = useSessionApi() + const {_} = useLingui() + const items: DropdownItem[] = [ { - label: 'Remove account', + label: _(msg`Remove account`), onPress: () => { - store.session.removeAccount(handle) + removeAccount(account) Toast.show('Account removed from quick access') }, icon: { @@ -34,7 +38,7 @@ export function AccountDropdownBtn({handle}: {handle: string}) { <NativeDropdown testID="accountSettingsDropdownBtn" items={items} - accessibilityLabel="Account options" + accessibilityLabel={_(msg`Account options`)} accessibilityHint=""> <FontAwesomeIcon icon="ellipsis-h" diff --git a/src/view/com/util/BottomSheetCustomBackdrop.tsx b/src/view/com/util/BottomSheetCustomBackdrop.tsx index 91379f1c9..ed5a2f165 100644 --- a/src/view/com/util/BottomSheetCustomBackdrop.tsx +++ b/src/view/com/util/BottomSheetCustomBackdrop.tsx @@ -6,6 +6,7 @@ import Animated, { interpolate, useAnimatedStyle, } from 'react-native-reanimated' +import {t} from '@lingui/macro' export function createCustomBackdrop( onClose?: (() => void) | undefined, @@ -29,7 +30,7 @@ export function createCustomBackdrop( return ( <TouchableWithoutFeedback onPress={onClose} - accessibilityLabel="Close bottom drawer" + accessibilityLabel={t`Close bottom drawer`} accessibilityHint="" onAccessibilityEscape={() => { if (onClose !== undefined) { diff --git a/src/view/com/util/ErrorBoundary.tsx b/src/view/com/util/ErrorBoundary.tsx index 529435cf1..397588cfb 100644 --- a/src/view/com/util/ErrorBoundary.tsx +++ b/src/view/com/util/ErrorBoundary.tsx @@ -1,6 +1,7 @@ import React, {Component, ErrorInfo, ReactNode} from 'react' import {ErrorScreen} from './error/ErrorScreen' import {CenteredView} from './Views' +import {t} from '@lingui/macro' interface Props { children?: ReactNode @@ -30,8 +31,8 @@ export class ErrorBoundary extends Component<Props, State> { return ( <CenteredView style={{height: '100%', flex: 1}}> <ErrorScreen - title="Oh no!" - message="There was an unexpected issue in the application. Please let us know if this happened to you!" + title={t`Oh no!`} + message={t`There was an unexpected issue in the application. Please let us know if this happened to you!`} details={this.state.error.toString()} /> </CenteredView> diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index 1777f6659..dcbec7cb4 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -21,7 +21,6 @@ import {Text} from './text/Text' import {TypographyVariant} from 'lib/ThemeContext' import {NavigationProp} from 'lib/routes/types' import {router} from '../../../routes' -import {useStores, RootStoreModel} from 'state/index' import { convertBskyAppUrlIfNeeded, isExternalUrl, @@ -31,6 +30,7 @@ import {isAndroid, isWeb} from 'platform/detection' import {sanitizeUrl} from '@braintree/sanitize-url' import {PressableWithHover} from './PressableWithHover' import FixedTouchableHighlight from '../pager/FixedTouchableHighlight' +import {useModalControls} from '#/state/modals' type Event = | React.MouseEvent<HTMLAnchorElement, MouseEvent> @@ -46,6 +46,7 @@ interface Props extends ComponentProps<typeof TouchableOpacity> { noFeedback?: boolean asAnchor?: boolean anchorNoUnderline?: boolean + navigationAction?: 'push' | 'replace' | 'navigate' } export const Link = memo(function Link({ @@ -58,19 +59,26 @@ export const Link = memo(function Link({ asAnchor, accessible, anchorNoUnderline, + navigationAction, ...props }: Props) { - const store = useStores() + const {closeModal} = useModalControls() const navigation = useNavigation<NavigationProp>() const anchorHref = asAnchor ? sanitizeUrl(href) : undefined const onPress = React.useCallback( (e?: Event) => { if (typeof href === 'string') { - return onPressInner(store, navigation, sanitizeUrl(href), e) + return onPressInner( + closeModal, + navigation, + sanitizeUrl(href), + navigationAction, + e, + ) } }, - [store, navigation, href], + [closeModal, navigation, navigationAction, href], ) if (noFeedback) { @@ -146,6 +154,7 @@ export const TextLink = memo(function TextLink({ title, onPress, warnOnMismatchingLabel, + navigationAction, ...orgProps }: { testID?: string @@ -158,10 +167,11 @@ export const TextLink = memo(function TextLink({ dataSet?: any title?: string warnOnMismatchingLabel?: boolean + navigationAction?: 'push' | 'replace' | 'navigate' } & TextProps) { const {...props} = useLinkProps({to: sanitizeUrl(href)}) - const store = useStores() const navigation = useNavigation<NavigationProp>() + const {openModal, closeModal} = useModalControls() if (warnOnMismatchingLabel && typeof text !== 'string') { console.error('Unable to detect mismatching label') @@ -174,7 +184,7 @@ export const TextLink = memo(function TextLink({ linkRequiresWarning(href, typeof text === 'string' ? text : '') if (requiresWarning) { e?.preventDefault?.() - store.shell.openModal({ + openModal({ name: 'link-warning', text: typeof text === 'string' ? text : '', href, @@ -185,9 +195,24 @@ export const TextLink = memo(function TextLink({ // @ts-ignore function signature differs by platform -prf return onPress() } - return onPressInner(store, navigation, sanitizeUrl(href), e) + return onPressInner( + closeModal, + navigation, + sanitizeUrl(href), + navigationAction, + e, + ) }, - [onPress, store, navigation, href, text, warnOnMismatchingLabel], + [ + onPress, + closeModal, + openModal, + navigation, + href, + text, + warnOnMismatchingLabel, + navigationAction, + ], ) const hrefAttrs = useMemo(() => { const isExternal = isExternalUrl(href) @@ -233,6 +258,7 @@ interface TextLinkOnWebOnlyProps extends TextProps { accessibilityLabel?: string accessibilityHint?: string title?: string + navigationAction?: 'push' | 'replace' | 'navigate' } export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({ testID, @@ -242,6 +268,7 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({ text, numberOfLines, lineHeight, + navigationAction, ...props }: TextLinkOnWebOnlyProps) { if (isWeb) { @@ -255,6 +282,7 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({ numberOfLines={numberOfLines} lineHeight={lineHeight} title={props.title} + navigationAction={navigationAction} {...props} /> ) @@ -285,9 +313,10 @@ export const TextLinkOnWebOnly = memo(function DesktopWebTextLink({ // needed customizations // -prf function onPressInner( - store: RootStoreModel, + closeModal = () => {}, navigation: NavigationProp, href: string, + navigationAction: 'push' | 'replace' | 'navigate' = 'push', e?: Event, ) { let shouldHandle = false @@ -318,10 +347,20 @@ function onPressInner( if (newTab || href.startsWith('http') || href.startsWith('mailto')) { Linking.openURL(href) } else { - store.shell.closeModal() // close any active modals + closeModal() // close any active modals - // @ts-ignore we're not able to type check on this one -prf - navigation.dispatch(StackActions.push(...router.matchPath(href))) + if (navigationAction === 'push') { + // @ts-ignore we're not able to type check on this one -prf + navigation.dispatch(StackActions.push(...router.matchPath(href))) + } else if (navigationAction === 'replace') { + // @ts-ignore we're not able to type check on this one -prf + navigation.dispatch(StackActions.replace(...router.matchPath(href))) + } else if (navigationAction === 'navigate') { + // @ts-ignore we're not able to type check on this one -prf + navigation.navigate(...router.matchPath(href)) + } else { + throw Error('Unsupported navigator action.') + } } } } diff --git a/src/view/com/util/LoadingPlaceholder.tsx b/src/view/com/util/LoadingPlaceholder.tsx index 461cbcbe5..74e36ff7b 100644 --- a/src/view/com/util/LoadingPlaceholder.tsx +++ b/src/view/com/util/LoadingPlaceholder.tsx @@ -171,14 +171,22 @@ export function ProfileCardFeedLoadingPlaceholder() { export function FeedLoadingPlaceholder({ style, + showLowerPlaceholder = true, + showTopBorder = true, }: { style?: StyleProp<ViewStyle> + showTopBorder?: boolean + showLowerPlaceholder?: boolean }) { const pal = usePalette('default') return ( <View style={[ - {paddingHorizontal: 12, paddingVertical: 18, borderTopWidth: 1}, + { + paddingHorizontal: 12, + paddingVertical: 18, + borderTopWidth: showTopBorder ? 1 : 0, + }, pal.border, style, ]}> @@ -193,14 +201,16 @@ export function FeedLoadingPlaceholder({ <LoadingPlaceholder width={120} height={8} /> </View> </View> - <View style={{paddingHorizontal: 5}}> - <LoadingPlaceholder - width={260} - height={8} - style={{marginVertical: 12}} - /> - <LoadingPlaceholder width={120} height={8} /> - </View> + {showLowerPlaceholder && ( + <View style={{paddingHorizontal: 5}}> + <LoadingPlaceholder + width={260} + height={8} + style={{marginVertical: 12}} + /> + <LoadingPlaceholder width={120} height={8} /> + </View> + )} </View> ) } diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index c5e438f8d..fa5f12f6b 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -6,7 +6,6 @@ import {niceDate} from 'lib/strings/time' import {usePalette} from 'lib/hooks/usePalette' import {TypographyVariant} from 'lib/ThemeContext' import {UserAvatar} from './UserAvatar' -import {observer} from 'mobx-react-lite' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {isAndroid} from 'platform/detection' @@ -30,7 +29,7 @@ interface PostMetaOpts { style?: StyleProp<ViewStyle> } -export const PostMeta = observer(function PostMetaImpl(opts: PostMetaOpts) { +export function PostMeta(opts: PostMetaOpts) { const pal = usePalette('default') const displayName = opts.author.displayName || opts.author.handle const handle = opts.author.handle @@ -92,7 +91,7 @@ export const PostMeta = observer(function PostMetaImpl(opts: PostMetaOpts) { </TimeElapsed> </View> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/com/util/PostSandboxWarning.tsx b/src/view/com/util/PostSandboxWarning.tsx index 21f5f7b90..b2375c703 100644 --- a/src/view/com/util/PostSandboxWarning.tsx +++ b/src/view/com/util/PostSandboxWarning.tsx @@ -1,13 +1,13 @@ import React from 'react' import {StyleSheet, View} from 'react-native' import {Text} from './text/Text' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' +import {useSession} from '#/state/session' export function PostSandboxWarning() { - const store = useStores() + const {isSandbox} = useSession() const pal = usePalette('default') - if (store.session.isSandbox) { + if (isSandbox) { return ( <View style={styles.container}> <Text diff --git a/src/view/com/util/SimpleViewHeader.tsx b/src/view/com/util/SimpleViewHeader.tsx index c871d9404..e86e37565 100644 --- a/src/view/com/util/SimpleViewHeader.tsx +++ b/src/view/com/util/SimpleViewHeader.tsx @@ -1,5 +1,4 @@ import React from 'react' -import {observer} from 'mobx-react-lite' import { StyleProp, StyleSheet, @@ -18,7 +17,7 @@ import {useSetDrawerOpen} from '#/state/shell' const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20} -export const SimpleViewHeader = observer(function SimpleViewHeaderImpl({ +export function SimpleViewHeader({ showBackButton = true, style, children, @@ -76,7 +75,7 @@ export const SimpleViewHeader = observer(function SimpleViewHeaderImpl({ {children} </Container> ) -}) +} const styles = StyleSheet.create({ header: { diff --git a/src/view/com/util/TimeElapsed.tsx b/src/view/com/util/TimeElapsed.tsx index 0765f65b2..aa3a09223 100644 --- a/src/view/com/util/TimeElapsed.tsx +++ b/src/view/com/util/TimeElapsed.tsx @@ -1,24 +1,22 @@ import React from 'react' -import {observer} from 'mobx-react-lite' import {ago} from 'lib/strings/time' -import {useStores} from 'state/index' +import {useTickEveryMinute} from '#/state/shell' // FIXME(dan): Figure out why the false positives -/* eslint-disable react/prop-types */ -export const TimeElapsed = observer(function TimeElapsed({ +export function TimeElapsed({ timestamp, children, }: { timestamp: string children: ({timeElapsed}: {timeElapsed: string}) => JSX.Element }) { - const stores = useStores() + const tick = useTickEveryMinute() const [timeElapsed, setTimeAgo] = React.useState(ago(timestamp)) React.useEffect(() => { setTimeAgo(ago(timestamp)) - }, [timestamp, setTimeAgo, stores.shell.tickEveryMinute]) + }, [timestamp, setTimeAgo, tick]) return children({timeElapsed}) -}) +} diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx index 4c9045d1e..c7134febe 100644 --- a/src/view/com/util/Toast.tsx +++ b/src/view/com/util/Toast.tsx @@ -1,6 +1,7 @@ import RootSiblings from 'react-native-root-siblings' import React from 'react' import {Animated, StyleSheet, View} from 'react-native' +import {Props as FontAwesomeProps} from '@fortawesome/react-native-fontawesome' import {Text} from './text/Text' import {colors} from 'lib/styles' import {useTheme} from 'lib/ThemeContext' @@ -9,7 +10,10 @@ import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' const TIMEOUT = 4e3 -export function show(message: string) { +export function show( + message: string, + _icon: FontAwesomeProps['icon'] = 'check', +) { const item = new RootSiblings(<Toast message={message} />) setTimeout(() => { item.destroy() diff --git a/src/view/com/util/Toast.web.tsx b/src/view/com/util/Toast.web.tsx index c295bad69..beb67c30c 100644 --- a/src/view/com/util/Toast.web.tsx +++ b/src/view/com/util/Toast.web.tsx @@ -7,12 +7,14 @@ import {StyleSheet, Text, View} from 'react-native' import { FontAwesomeIcon, FontAwesomeIconStyle, + Props as FontAwesomeProps, } from '@fortawesome/react-native-fontawesome' const DURATION = 3500 interface ActiveToast { text: string + icon: FontAwesomeProps['icon'] } type GlobalSetActiveToast = (_activeToast: ActiveToast | undefined) => void @@ -36,7 +38,7 @@ export const ToastContainer: React.FC<ToastContainerProps> = ({}) => { {activeToast && ( <View style={styles.container}> <FontAwesomeIcon - icon="check" + icon={activeToast.icon} size={24} style={styles.icon as FontAwesomeIconStyle} /> @@ -49,11 +51,12 @@ export const ToastContainer: React.FC<ToastContainerProps> = ({}) => { // methods // = -export function show(text: string) { + +export function show(text: string, icon: FontAwesomeProps['icon'] = 'check') { if (toastTimeout) { clearTimeout(toastTimeout) } - globalSetActiveToast?.({text}) + globalSetActiveToast?.({text, icon}) toastTimeout = setTimeout(() => { globalSetActiveToast?.(undefined) }, DURATION) diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 9db457325..395e9eb3a 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -9,13 +9,14 @@ import { usePhotoLibraryPermission, useCameraPermission, } from 'lib/hooks/usePermissions' -import {useStores} from 'state/index' import {colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {isWeb, isAndroid} from 'platform/detection' import {Image as RNImage} from 'react-native-image-crop-picker' import {UserPreviewLink} from './UserPreviewLink' import {DropdownItem, NativeDropdown} from './forms/NativeDropdown' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' export type UserAvatarType = 'user' | 'algo' | 'list' @@ -42,7 +43,13 @@ interface PreviewableUserAvatarProps extends BaseUserAvatarProps { const BLUR_AMOUNT = isWeb ? 5 : 100 -function DefaultAvatar({type, size}: {type: UserAvatarType; size: number}) { +export function DefaultAvatar({ + type, + size, +}: { + type: UserAvatarType + size: number +}) { if (type === 'algo') { // Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. return ( @@ -182,8 +189,8 @@ export function EditableUserAvatar({ avatar, onSelectNewAvatar, }: EditableUserAvatarProps) { - const store = useStores() const pal = usePalette('default') + const {_} = useLingui() const {requestCameraAccessIfNeeded} = useCameraPermission() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() @@ -207,7 +214,7 @@ export function EditableUserAvatar({ [ !isWeb && { testID: 'changeAvatarCameraBtn', - label: 'Camera', + label: _(msg`Camera`), icon: { ios: { name: 'camera', @@ -221,7 +228,7 @@ export function EditableUserAvatar({ } onSelectNewAvatar( - await openCamera(store, { + await openCamera({ width: 1000, height: 1000, cropperCircleOverlay: true, @@ -231,7 +238,7 @@ export function EditableUserAvatar({ }, { testID: 'changeAvatarLibraryBtn', - label: 'Library', + label: _(msg`Library`), icon: { ios: { name: 'photo.on.rectangle.angled', @@ -252,7 +259,7 @@ export function EditableUserAvatar({ return } - const croppedImage = await openCropper(store, { + const croppedImage = await openCropper({ mediaType: 'photo', cropperCircleOverlay: true, height: item.height, @@ -268,7 +275,7 @@ export function EditableUserAvatar({ }, !!avatar && { testID: 'changeAvatarRemoveBtn', - label: 'Remove', + label: _(msg`Remove`), icon: { ios: { name: 'trash', @@ -286,7 +293,7 @@ export function EditableUserAvatar({ onSelectNewAvatar, requestCameraAccessIfNeeded, requestPhotoAccessIfNeeded, - store, + _, ], ) @@ -294,7 +301,7 @@ export function EditableUserAvatar({ <NativeDropdown testID="changeAvatarBtn" items={dropdownItems} - accessibilityLabel="Image options" + accessibilityLabel={_(msg`Image options`)} accessibilityHint=""> {avatar ? ( <HighPriorityImage diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 4bdfad06c..b31d7e551 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -5,7 +5,6 @@ import {ModerationUI} from '@atproto/api' import {Image} from 'expo-image' import {colors} from 'lib/styles' import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' -import {useStores} from 'state/index' import { usePhotoLibraryPermission, useCameraPermission, @@ -14,6 +13,8 @@ import {usePalette} from 'lib/hooks/usePalette' import {isWeb, isAndroid} from 'platform/detection' import {Image as RNImage} from 'react-native-image-crop-picker' import {NativeDropdown, DropdownItem} from './forms/NativeDropdown' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' export function UserBanner({ banner, @@ -24,8 +25,8 @@ export function UserBanner({ moderation?: ModerationUI onSelectNewBanner?: (img: RNImage | null) => void }) { - const store = useStores() const pal = usePalette('default') + const {_} = useLingui() const {requestCameraAccessIfNeeded} = useCameraPermission() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() @@ -34,7 +35,7 @@ export function UserBanner({ [ !isWeb && { testID: 'changeBannerCameraBtn', - label: 'Camera', + label: _(msg`Camera`), icon: { ios: { name: 'camera', @@ -47,7 +48,7 @@ export function UserBanner({ return } onSelectNewBanner?.( - await openCamera(store, { + await openCamera({ width: 3000, height: 1000, }), @@ -56,7 +57,7 @@ export function UserBanner({ }, { testID: 'changeBannerLibraryBtn', - label: 'Library', + label: _(msg`Library`), icon: { ios: { name: 'photo.on.rectangle.angled', @@ -74,7 +75,7 @@ export function UserBanner({ } onSelectNewBanner?.( - await openCropper(store, { + await openCropper({ mediaType: 'photo', path: items[0].path, width: 3000, @@ -85,7 +86,7 @@ export function UserBanner({ }, !!banner && { testID: 'changeBannerRemoveBtn', - label: 'Remove', + label: _(msg`Remove`), icon: { ios: { name: 'trash', @@ -103,7 +104,7 @@ export function UserBanner({ onSelectNewBanner, requestCameraAccessIfNeeded, requestPhotoAccessIfNeeded, - store, + _, ], ) @@ -112,7 +113,7 @@ export function UserBanner({ <NativeDropdown testID="changeBannerBtn" items={dropdownItems} - accessibilityLabel="Image options" + accessibilityLabel={_(msg`Image options`)} accessibilityHint=""> {banner ? ( <Image diff --git a/src/view/com/util/UserInfoText.tsx b/src/view/com/util/UserInfoText.tsx index e4ca981d9..e5d2ceb03 100644 --- a/src/view/com/util/UserInfoText.tsx +++ b/src/view/com/util/UserInfoText.tsx @@ -1,14 +1,14 @@ -import React, {useState, useEffect} from 'react' +import React from 'react' import {AppBskyActorGetProfile as GetProfile} from '@atproto/api' import {StyleProp, StyleSheet, TextStyle} from 'react-native' import {TextLinkOnWebOnly} from './Link' import {Text} from './text/Text' import {LoadingPlaceholder} from './LoadingPlaceholder' -import {useStores} from 'state/index' import {TypographyVariant} from 'lib/ThemeContext' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {makeProfileLink} from 'lib/routes/links' +import {useProfileQuery} from '#/state/queries/profile' export function UserInfoText({ type = 'md', @@ -29,35 +29,10 @@ export function UserInfoText({ attr = attr || 'handle' failed = failed || 'user' - const store = useStores() - const [profile, setProfile] = useState<undefined | GetProfile.OutputSchema>( - undefined, - ) - const [didFail, setFailed] = useState<boolean>(false) - - useEffect(() => { - let aborted = false - store.profiles.getProfile(did).then( - v => { - if (aborted) { - return - } - setProfile(v.data) - }, - _err => { - if (aborted) { - return - } - setFailed(true) - }, - ) - return () => { - aborted = true - } - }, [did, store.profiles]) + const {data: profile, isError} = useProfileQuery({did}) let inner - if (didFail) { + if (isError) { inner = ( <Text type={type} style={style} numberOfLines={1}> {failed} diff --git a/src/view/com/util/UserPreviewLink.tsx b/src/view/com/util/UserPreviewLink.tsx index f43f9e80b..9c5efe55e 100644 --- a/src/view/com/util/UserPreviewLink.tsx +++ b/src/view/com/util/UserPreviewLink.tsx @@ -1,9 +1,9 @@ import React from 'react' import {Pressable, StyleProp, ViewStyle} from 'react-native' -import {useStores} from 'state/index' import {Link} from './Link' import {isWeb} from 'platform/detection' import {makeProfileLink} from 'lib/routes/links' +import {useModalControls} from '#/state/modals' interface UserPreviewLinkProps { did: string @@ -13,7 +13,7 @@ interface UserPreviewLinkProps { export function UserPreviewLink( props: React.PropsWithChildren<UserPreviewLinkProps>, ) { - const store = useStores() + const {openModal} = useModalControls() if (isWeb) { return ( @@ -29,7 +29,7 @@ export function UserPreviewLink( return ( <Pressable onPress={() => - store.shell.openModal({ + openModal({ name: 'profile-preview', did: props.did, }) diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx index adf2e4f08..082cae59c 100644 --- a/src/view/com/util/ViewHeader.tsx +++ b/src/view/com/util/ViewHeader.tsx @@ -1,5 +1,4 @@ import React from 'react' -import {observer} from 'mobx-react-lite' import {StyleSheet, TouchableOpacity, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {useNavigation} from '@react-navigation/native' @@ -15,7 +14,7 @@ import {useSetDrawerOpen} from '#/state/shell' const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20} -export const ViewHeader = observer(function ViewHeaderImpl({ +export function ViewHeader({ title, canGoBack, showBackButton = true, @@ -108,7 +107,7 @@ export const ViewHeader = observer(function ViewHeaderImpl({ </Container> ) } -}) +} function DesktopWebHeader({ title, @@ -140,7 +139,7 @@ function DesktopWebHeader({ ) } -const Container = observer(function ContainerImpl({ +function Container({ children, hideOnScroll, showBorder, @@ -178,7 +177,7 @@ const Container = observer(function ContainerImpl({ {children} </Animated.View> ) -}) +} const styles = StyleSheet.create({ header: { diff --git a/src/view/com/util/Views.web.tsx b/src/view/com/util/Views.web.tsx index 1c2edc0cc..5a4f266fd 100644 --- a/src/view/com/util/Views.web.tsx +++ b/src/view/com/util/Views.web.tsx @@ -108,9 +108,9 @@ export const FlatList = React.forwardRef(function FlatListImpl<ItemT>( <Animated.FlatList ref={ref} contentContainerStyle={[ + styles.contentContainer, contentContainerStyle, pal.border, - styles.contentContainer, ]} style={style} contentOffset={contentOffset} @@ -135,9 +135,9 @@ export const ScrollView = React.forwardRef(function ScrollViewImpl( return ( <Animated.ScrollView contentContainerStyle={[ + styles.contentContainer, contentContainerStyle, pal.border, - styles.contentContainer, ]} // @ts-ignore something is wrong with the reanimated types -prf ref={ref} diff --git a/src/view/com/util/error/ErrorMessage.tsx b/src/view/com/util/error/ErrorMessage.tsx index 370f10ae3..b4adbb557 100644 --- a/src/view/com/util/error/ErrorMessage.tsx +++ b/src/view/com/util/error/ErrorMessage.tsx @@ -13,6 +13,8 @@ import { import {Text} from '../text/Text' import {useTheme} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' export function ErrorMessage({ message, @@ -27,6 +29,7 @@ export function ErrorMessage({ }) { const theme = useTheme() const pal = usePalette('error') + const {_} = useLingui() return ( <View testID="errorMessageView" style={[styles.outer, pal.view, style]}> <View @@ -49,7 +52,7 @@ export function ErrorMessage({ style={styles.btn} onPress={onPressTryAgain} accessibilityRole="button" - accessibilityLabel="Retry" + accessibilityLabel={_(msg`Retry`)} accessibilityHint="Retries the last action, which errored out"> <FontAwesomeIcon icon="arrows-rotate" diff --git a/src/view/com/util/error/ErrorScreen.tsx b/src/view/com/util/error/ErrorScreen.tsx index a5deeb18f..4cd6dd4b4 100644 --- a/src/view/com/util/error/ErrorScreen.tsx +++ b/src/view/com/util/error/ErrorScreen.tsx @@ -9,6 +9,8 @@ import {useTheme} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' import {Button} from '../forms/Button' import {CenteredView} from '../Views' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' export function ErrorScreen({ title, @@ -25,6 +27,8 @@ export function ErrorScreen({ }) { const theme = useTheme() const pal = usePalette('default') + const {_} = useLingui() + return ( <CenteredView testID={testID} style={[styles.outer, pal.view]}> <View style={styles.errorIconContainer}> @@ -58,7 +62,7 @@ export function ErrorScreen({ type="default" style={[styles.btn]} onPress={onPressTryAgain} - accessibilityLabel="Retry" + accessibilityLabel={_(msg`Retry`)} accessibilityHint="Retries the last action, which errored out"> <FontAwesomeIcon icon="arrows-rotate" @@ -66,7 +70,7 @@ export function ErrorScreen({ size={16} /> <Text type="button" style={[styles.btnText, pal.link]}> - Try again + <Trans>Try again</Trans> </Text> </Button> </View> diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx index 5b1d5d888..9787d92fb 100644 --- a/src/view/com/util/fab/FABInner.tsx +++ b/src/view/com/util/fab/FABInner.tsx @@ -1,5 +1,4 @@ import React, {ComponentProps} from 'react' -import {observer} from 'mobx-react-lite' import {StyleSheet, TouchableWithoutFeedback} from 'react-native' import LinearGradient from 'react-native-linear-gradient' import {gradients} from 'lib/styles' @@ -15,11 +14,7 @@ export interface FABProps icon: JSX.Element } -export const FABInner = observer(function FABInnerImpl({ - testID, - icon, - ...props -}: FABProps) { +export function FABInner({testID, icon, ...props}: FABProps) { const insets = useSafeAreaInsets() const {isMobile, isTablet} = useWebMediaQueries() const {fabMinimalShellTransform} = useMinimalShellMode() @@ -55,7 +50,7 @@ export const FABInner = observer(function FABInnerImpl({ </Animated.View> </TouchableWithoutFeedback> ) -}) +} const styles = StyleSheet.create({ sizeRegular: { diff --git a/src/view/com/util/forms/Button.tsx b/src/view/com/util/forms/Button.tsx index 270d98317..8f24f8288 100644 --- a/src/view/com/util/forms/Button.tsx +++ b/src/view/com/util/forms/Button.tsx @@ -52,6 +52,7 @@ export function Button({ accessibilityLabelledBy, onAccessibilityEscape, withLoading = false, + disabled = false, }: React.PropsWithChildren<{ type?: ButtonType label?: string @@ -65,6 +66,7 @@ export function Button({ accessibilityLabelledBy?: string onAccessibilityEscape?: () => void withLoading?: boolean + disabled?: boolean }>) { const theme = useTheme() const typeOuterStyle = choose<ViewStyle, Record<ButtonType, ViewStyle>>( @@ -198,7 +200,7 @@ export function Button({ <Pressable style={getStyle} onPress={onPressWrapped} - disabled={isLoading} + disabled={disabled || isLoading} testID={testID} accessibilityRole="button" accessibilityLabel={accessibilityLabel} diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx index 1bed60b5d..ad8f50f5e 100644 --- a/src/view/com/util/forms/DropdownButton.tsx +++ b/src/view/com/util/forms/DropdownButton.tsx @@ -17,6 +17,8 @@ import {colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {useTheme} from 'lib/ThemeContext' import {HITSLOP_10} from 'lib/constants' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' const ESTIMATED_BTN_HEIGHT = 50 const ESTIMATED_SEP_HEIGHT = 16 @@ -207,6 +209,7 @@ const DropdownItems = ({ }: DropDownItemProps) => { const pal = usePalette('default') const theme = useTheme() + const {_} = useLingui() const dropDownBackgroundColor = theme.colorScheme === 'dark' ? pal.btn : pal.view const separatorColor = @@ -224,7 +227,7 @@ const DropdownItems = ({ {/* This TouchableWithoutFeedback renders the background so if the user clicks outside, the dropdown closes */} <TouchableWithoutFeedback onPress={onOuterPress} - accessibilityLabel="Toggle dropdown" + accessibilityLabel={_(msg`Toggle dropdown`)} accessibilityHint=""> <View style={[styles.bg]} /> </TouchableWithoutFeedback> diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 1fffa3123..1ba5ae8ae 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -1,49 +1,101 @@ import React from 'react' -import {StyleProp, View, ViewStyle} from 'react-native' +import {Linking, StyleProp, View, ViewStyle} from 'react-native' +import Clipboard from '@react-native-clipboard/clipboard' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {AppBskyFeedDefs, AppBskyFeedPost, AtUri} from '@atproto/api' import {toShareUrl} from 'lib/strings/url-helpers' -import {useStores} from 'state/index' import {useTheme} from 'lib/ThemeContext' import {shareUrl} from 'lib/sharing' import { NativeDropdown, DropdownItem as NativeDropdownItem, } from './NativeDropdown' +import * as Toast from '../Toast' import {EventStopper} from '../EventStopper' +import {useModalControls} from '#/state/modals' +import {makeProfileLink} from '#/lib/routes/links' +import {getTranslatorLink} from '#/locale/helpers' +import {usePostDeleteMutation} from '#/state/queries/post' +import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads' +import {useLanguagePrefs} from '#/state/preferences' +import {logger} from '#/logger' +import {Shadow} from '#/state/cache/types' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useSession} from '#/state/session' +import {isWeb} from '#/platform/detection' export function PostDropdownBtn({ testID, - itemUri, - itemCid, - itemHref, - isAuthor, - isThreadMuted, - onCopyPostText, - onOpenTranslate, - onToggleThreadMute, - onDeletePost, + post, + record, style, }: { testID: string - itemUri: string - itemCid: string - itemHref: string - itemTitle: string - isAuthor: boolean - isThreadMuted: boolean - onCopyPostText: () => void - onOpenTranslate: () => void - onToggleThreadMute: () => void - onDeletePost: () => void + post: Shadow<AppBskyFeedDefs.PostView> + record: AppBskyFeedPost.Record style?: StyleProp<ViewStyle> }) { - const store = useStores() + const {hasSession, currentAccount} = useSession() const theme = useTheme() + const {_} = useLingui() const defaultCtrlColor = theme.palette.default.postCtrl + const {openModal} = useModalControls() + const langPrefs = useLanguagePrefs() + const mutedThreads = useMutedThreads() + const toggleThreadMute = useToggleThreadMute() + const postDeleteMutation = usePostDeleteMutation() + + const rootUri = record.reply?.root?.uri || post.uri + const isThreadMuted = mutedThreads.includes(rootUri) + const isAuthor = post.author.did === currentAccount?.did + const href = React.useMemo(() => { + const urip = new AtUri(post.uri) + return makeProfileLink(post.author, 'post', urip.rkey) + }, [post.uri, post.author]) + + const translatorUrl = getTranslatorLink( + record.text, + langPrefs.primaryLanguage, + ) + + const onDeletePost = React.useCallback(() => { + postDeleteMutation.mutateAsync({uri: post.uri}).then( + () => { + Toast.show('Post deleted') + }, + e => { + logger.error('Failed to delete post', {error: e}) + Toast.show('Failed to delete post, please try again') + }, + ) + }, [post, postDeleteMutation]) + + const onToggleThreadMute = React.useCallback(() => { + try { + const muted = toggleThreadMute(rootUri) + if (muted) { + Toast.show('You will no longer receive notifications for this thread') + } else { + Toast.show('You will now receive notifications for this thread') + } + } catch (e) { + logger.error('Failed to toggle thread mute', {error: e}) + } + }, [rootUri, toggleThreadMute]) + + const onCopyPostText = React.useCallback(() => { + Clipboard.setString(record?.text || '') + Toast.show('Copied to clipboard') + }, [record]) + + const onOpenTranslate = React.useCallback(() => { + Linking.openURL(translatorUrl) + }, [translatorUrl]) const dropdownItems: NativeDropdownItem[] = [ { - label: 'Translate', + label: _(msg`Translate`), onPress() { onOpenTranslate() }, @@ -57,7 +109,7 @@ export function PostDropdownBtn({ }, }, { - label: 'Copy post text', + label: _(msg`Copy post text`), onPress() { onCopyPostText() }, @@ -71,9 +123,9 @@ export function PostDropdownBtn({ }, }, { - label: 'Share', + label: isWeb ? _(msg`Copy link to post`) : _(msg`Share`), onPress() { - const url = toShareUrl(itemHref) + const url = toShareUrl(href) shareUrl(url) }, testID: 'postDropdownShareBtn', @@ -85,11 +137,11 @@ export function PostDropdownBtn({ web: 'share', }, }, - { + hasSession && { label: 'separator', }, - { - label: isThreadMuted ? 'Unmute thread' : 'Mute thread', + hasSession && { + label: isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`), onPress() { onToggleThreadMute() }, @@ -102,37 +154,38 @@ export function PostDropdownBtn({ web: 'comment-slash', }, }, - { + hasSession && { label: 'separator', }, - !isAuthor && { - label: 'Report post', - onPress() { - store.shell.openModal({ - name: 'report', - uri: itemUri, - cid: itemCid, - }) - }, - testID: 'postDropdownReportBtn', - icon: { - ios: { - name: 'exclamationmark.triangle', + !isAuthor && + hasSession && { + label: _(msg`Report post`), + onPress() { + openModal({ + name: 'report', + uri: post.uri, + cid: post.cid, + }) + }, + testID: 'postDropdownReportBtn', + icon: { + ios: { + name: 'exclamationmark.triangle', + }, + android: 'ic_menu_report_image', + web: 'circle-exclamation', }, - android: 'ic_menu_report_image', - web: 'circle-exclamation', }, - }, isAuthor && { label: 'separator', }, isAuthor && { - label: 'Delete post', + label: _(msg`Delete post`), onPress() { - store.shell.openModal({ + openModal({ name: 'confirm', - title: 'Delete this post?', - message: 'Are you sure? This can not be undone.', + title: _(msg`Delete this post?`), + message: _(msg`Are you sure? This cannot be undone.`), onPressConfirm: onDeletePost, }) }, diff --git a/src/view/com/util/forms/SearchInput.tsx b/src/view/com/util/forms/SearchInput.tsx index c1eb82bd4..02b462b55 100644 --- a/src/view/com/util/forms/SearchInput.tsx +++ b/src/view/com/util/forms/SearchInput.tsx @@ -14,6 +14,8 @@ import { import {MagnifyingGlassIcon} from 'lib/icons' import {useTheme} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' interface Props { query: string @@ -33,6 +35,7 @@ export function SearchInput({ }: Props) { const theme = useTheme() const pal = usePalette('default') + const {_} = useLingui() const textInput = React.useRef<TextInput>(null) const onPressCancelSearchInner = React.useCallback(() => { @@ -58,7 +61,7 @@ export function SearchInput({ onChangeText={onChangeQuery} onSubmitEditing={onSubmitQuery} accessibilityRole="search" - accessibilityLabel="Search" + accessibilityLabel={_(msg`Search`)} accessibilityHint="" autoCorrect={false} autoCapitalize="none" @@ -67,7 +70,7 @@ export function SearchInput({ <TouchableOpacity onPress={onPressCancelSearchInner} accessibilityRole="button" - accessibilityLabel="Clear search query" + accessibilityLabel={_(msg`Clear search query`)} accessibilityHint=""> <FontAwesomeIcon icon="xmark" diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx index 6cbcddc32..b5b6c1b52 100644 --- a/src/view/com/util/images/AutoSizedImage.tsx +++ b/src/view/com/util/images/AutoSizedImage.tsx @@ -2,8 +2,8 @@ import React from 'react' import {StyleProp, StyleSheet, Pressable, View, ViewStyle} from 'react-native' import {Image} from 'expo-image' import {clamp} from 'lib/numbers' -import {useStores} from 'state/index' import {Dimensions} from 'lib/media/types' +import * as imageSizes from 'lib/media/image-sizes' const MIN_ASPECT_RATIO = 0.33 // 1/3 const MAX_ASPECT_RATIO = 5 // 5/1 @@ -29,9 +29,8 @@ export function AutoSizedImage({ style, children = null, }: Props) { - const store = useStores() const [dim, setDim] = React.useState<Dimensions | undefined>( - dimensionsHint || store.imageSizes.get(uri), + dimensionsHint || imageSizes.get(uri), ) const [aspectRatio, setAspectRatio] = React.useState<number>( dim ? calc(dim) : 1, @@ -41,14 +40,14 @@ export function AutoSizedImage({ if (dim) { return } - store.imageSizes.fetch(uri).then(newDim => { + imageSizes.fetch(uri).then(newDim => { if (aborted) { return } setDim(newDim) setAspectRatio(calc(newDim)) }) - }, [dim, setDim, setAspectRatio, store, uri]) + }, [dim, setDim, setAspectRatio, uri]) if (onPress || onLongPress || onPressIn) { return ( diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx index 4aa6f28de..23e807b6a 100644 --- a/src/view/com/util/images/ImageLayoutGrid.tsx +++ b/src/view/com/util/images/ImageLayoutGrid.tsx @@ -69,12 +69,12 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { <GalleryItem {...props} index={0} imageStyle={styles.image} /> </View> <View style={styles.smallItem}> - <GalleryItem {...props} index={2} imageStyle={styles.image} /> + <GalleryItem {...props} index={1} imageStyle={styles.image} /> </View> </View> <View style={styles.flexRow}> <View style={styles.smallItem}> - <GalleryItem {...props} index={1} imageStyle={styles.image} /> + <GalleryItem {...props} index={2} imageStyle={styles.image} /> </View> <View style={styles.smallItem}> <GalleryItem {...props} index={3} imageStyle={styles.image} /> diff --git a/src/view/com/util/layouts/Breakpoints.web.tsx b/src/view/com/util/layouts/Breakpoints.web.tsx index 5cf73df0c..5106e3e1f 100644 --- a/src/view/com/util/layouts/Breakpoints.web.tsx +++ b/src/view/com/util/layouts/Breakpoints.web.tsx @@ -8,13 +8,13 @@ export const TabletOrDesktop = ({children}: React.PropsWithChildren<{}>) => ( <MediaQuery minWidth={800}>{children}</MediaQuery> ) export const Tablet = ({children}: React.PropsWithChildren<{}>) => ( - <MediaQuery minWidth={800} maxWidth={1300}> + <MediaQuery minWidth={800} maxWidth={1300 - 1}> {children} </MediaQuery> ) export const TabletOrMobile = ({children}: React.PropsWithChildren<{}>) => ( - <MediaQuery maxWidth={1300}>{children}</MediaQuery> + <MediaQuery maxWidth={1300 - 1}>{children}</MediaQuery> ) export const Mobile = ({children}: React.PropsWithChildren<{}>) => ( - <MediaQuery maxWidth={800}>{children}</MediaQuery> + <MediaQuery maxWidth={800 - 1}>{children}</MediaQuery> ) diff --git a/src/view/com/util/load-latest/LoadLatestBtn.tsx b/src/view/com/util/load-latest/LoadLatestBtn.tsx index f9a9387bb..970d3a73a 100644 --- a/src/view/com/util/load-latest/LoadLatestBtn.tsx +++ b/src/view/com/util/load-latest/LoadLatestBtn.tsx @@ -1,6 +1,5 @@ import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' @@ -12,7 +11,7 @@ const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity) import {isWeb} from 'platform/detection' -export const LoadLatestBtn = observer(function LoadLatestBtnImpl({ +export function LoadLatestBtn({ onPress, label, showIndicator, @@ -44,7 +43,7 @@ export const LoadLatestBtn = observer(function LoadLatestBtnImpl({ {showIndicator && <View style={[styles.indicator, pal.borderDark]} />} </AnimatedTouchableOpacity> ) -}) +} const styles = StyleSheet.create({ loadLatest: { diff --git a/src/view/com/util/moderation/ContentHider.tsx b/src/view/com/util/moderation/ContentHider.tsx index 4f917844a..a13aae2b5 100644 --- a/src/view/com/util/moderation/ContentHider.tsx +++ b/src/view/com/util/moderation/ContentHider.tsx @@ -6,7 +6,9 @@ import {ModerationUI} from '@atproto/api' import {Text} from '../text/Text' import {ShieldExclamation} from 'lib/icons' import {describeModerationCause} from 'lib/moderation' -import {useStores} from 'state/index' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import {useModalControls} from '#/state/modals' export function ContentHider({ testID, @@ -22,10 +24,11 @@ export function ContentHider({ style?: StyleProp<ViewStyle> childContainerStyle?: StyleProp<ViewStyle> }>) { - const store = useStores() const pal = usePalette('default') + const {_} = useLingui() const {isMobile} = useWebMediaQueries() const [override, setOverride] = React.useState(false) + const {openModal} = useModalControls() if (!moderation.blur || (ignoreMute && moderation.cause?.type === 'muted')) { return ( @@ -43,7 +46,7 @@ export function ContentHider({ if (!moderation.noOverride) { setOverride(v => !v) } else { - store.shell.openModal({ + openModal({ name: 'moderation-details', context: 'content', moderation, @@ -62,14 +65,14 @@ export function ContentHider({ ]}> <Pressable onPress={() => { - store.shell.openModal({ + openModal({ name: 'moderation-details', context: 'content', moderation, }) }} accessibilityRole="button" - accessibilityLabel="Learn more about this warning" + accessibilityLabel={_(msg`Learn more about this warning`)} accessibilityHint=""> <ShieldExclamation size={18} style={pal.text} /> </Pressable> diff --git a/src/view/com/util/moderation/PostAlerts.tsx b/src/view/com/util/moderation/PostAlerts.tsx index 0dba367fc..bc5bf9b32 100644 --- a/src/view/com/util/moderation/PostAlerts.tsx +++ b/src/view/com/util/moderation/PostAlerts.tsx @@ -5,7 +5,9 @@ import {Text} from '../text/Text' import {usePalette} from 'lib/hooks/usePalette' import {ShieldExclamation} from 'lib/icons' import {describeModerationCause} from 'lib/moderation' -import {useStores} from 'state/index' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' export function PostAlerts({ moderation, @@ -15,8 +17,9 @@ export function PostAlerts({ includeMute?: boolean style?: StyleProp<ViewStyle> }) { - const store = useStores() const pal = usePalette('default') + const {_} = useLingui() + const {openModal} = useModalControls() const shouldAlert = !!moderation.cause && moderation.alert if (!shouldAlert) { @@ -27,21 +30,21 @@ export function PostAlerts({ return ( <Pressable onPress={() => { - store.shell.openModal({ + openModal({ name: 'moderation-details', context: 'content', moderation, }) }} accessibilityRole="button" - accessibilityLabel="Learn more about this warning" + accessibilityLabel={_(msg`Learn more about this warning`)} accessibilityHint="" style={[styles.container, pal.viewLight, style]}> <ShieldExclamation style={pal.text} size={16} /> <Text type="lg" style={[pal.text]}> {desc.name}{' '} <Text type="lg" style={[pal.link, styles.learnMoreBtn]}> - Learn More + <Trans>Learn More</Trans> </Text> </Text> </Pressable> diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx index d224286b0..c2b857f54 100644 --- a/src/view/com/util/moderation/PostHider.tsx +++ b/src/view/com/util/moderation/PostHider.tsx @@ -8,7 +8,9 @@ import {Text} from '../text/Text' import {addStyle} from 'lib/styles' import {describeModerationCause} from 'lib/moderation' import {ShieldExclamation} from 'lib/icons' -import {useStores} from 'state/index' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import {useModalControls} from '#/state/modals' interface Props extends ComponentProps<typeof Link> { // testID?: string @@ -25,10 +27,11 @@ export function PostHider({ children, ...props }: Props) { - const store = useStores() const pal = usePalette('default') + const {_} = useLingui() const {isMobile} = useWebMediaQueries() const [override, setOverride] = React.useState(false) + const {openModal} = useModalControls() if (!moderation.blur) { return ( @@ -63,14 +66,14 @@ export function PostHider({ ]}> <Pressable onPress={() => { - store.shell.openModal({ + openModal({ name: 'moderation-details', context: 'content', moderation, }) }} accessibilityRole="button" - accessibilityLabel="Learn more about this warning" + accessibilityLabel={_(msg`Learn more about this warning`)} accessibilityHint=""> <ShieldExclamation size={18} style={pal.text} /> </Pressable> diff --git a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx index 6b7f4e7ec..d2675ca54 100644 --- a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx +++ b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx @@ -8,7 +8,9 @@ import { describeModerationCause, getProfileModerationCauses, } from 'lib/moderation' -import {useStores} from 'state/index' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' export function ProfileHeaderAlerts({ moderation, @@ -17,8 +19,9 @@ export function ProfileHeaderAlerts({ moderation: ProfileModeration style?: StyleProp<ViewStyle> }) { - const store = useStores() const pal = usePalette('default') + const {_} = useLingui() + const {openModal} = useModalControls() const causes = getProfileModerationCauses(moderation) if (!causes.length) { @@ -34,14 +37,14 @@ export function ProfileHeaderAlerts({ testID="profileHeaderAlert" key={desc.name} onPress={() => { - store.shell.openModal({ + openModal({ name: 'moderation-details', context: 'content', moderation: {cause}, }) }} accessibilityRole="button" - accessibilityLabel="Learn more about this warning" + accessibilityLabel={_(msg`Learn more about this warning`)} accessibilityHint="" style={[styles.container, pal.viewLight, style]}> <ShieldExclamation style={pal.text} size={24} /> @@ -49,7 +52,7 @@ export function ProfileHeaderAlerts({ {desc.name} </Text> <Text type="lg" style={[pal.link, styles.learnMoreBtn]}> - Learn More + <Trans>Learn More</Trans> </Text> </Pressable> ) diff --git a/src/view/com/util/moderation/ScreenHider.tsx b/src/view/com/util/moderation/ScreenHider.tsx index 0224b9fee..946f937e9 100644 --- a/src/view/com/util/moderation/ScreenHider.tsx +++ b/src/view/com/util/moderation/ScreenHider.tsx @@ -18,7 +18,10 @@ import {NavigationProp} from 'lib/routes/types' import {Text} from '../text/Text' import {Button} from '../forms/Button' import {describeModerationCause} from 'lib/moderation' -import {useStores} from 'state/index' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import {s} from '#/lib/styles' export function ScreenHider({ testID, @@ -34,12 +37,13 @@ export function ScreenHider({ style?: StyleProp<ViewStyle> containerStyle?: StyleProp<ViewStyle> }>) { - const store = useStores() const pal = usePalette('default') const palInverted = usePalette('inverted') + const {_} = useLingui() const [override, setOverride] = React.useState(false) const navigation = useNavigation<NavigationProp>() const {isMobile} = useWebMediaQueries() + const {openModal} = useModalControls() if (!moderation.blur || override) { return ( @@ -62,27 +66,26 @@ export function ScreenHider({ </View> </View> <Text type="title-2xl" style={[styles.title, pal.text]}> - Content Warning + <Trans>Content Warning</Trans> </Text> <Text type="2xl" style={[styles.description, pal.textLight]}> - This {screenDescription} has been flagged:{' '} - <Text type="2xl-medium" style={pal.text}> - {desc.name} + <Trans>This {screenDescription} has been flagged:</Trans> + <Text type="2xl-medium" style={[pal.text, s.ml5]}> + {desc.name}. </Text> - .{' '} <TouchableWithoutFeedback onPress={() => { - store.shell.openModal({ + openModal({ name: 'moderation-details', context: 'account', moderation, }) }} accessibilityRole="button" - accessibilityLabel="Learn more about this warning" + accessibilityLabel={_(msg`Learn more about this warning`)} accessibilityHint=""> <Text type="2xl" style={pal.link}> - Learn More + <Trans>Learn More</Trans> </Text> </TouchableWithoutFeedback> </Text> @@ -99,7 +102,7 @@ export function ScreenHider({ }} style={styles.btn}> <Text type="button-lg" style={pal.textInverted}> - Go back + <Trans>Go back</Trans> </Text> </Button> {!moderation.noOverride && ( @@ -108,7 +111,7 @@ export function ScreenHider({ onPress={() => setOverride(v => !v)} style={styles.btn}> <Text type="button-lg" style={pal.text}> - Show anyway + <Trans>Show anyway</Trans> </Text> </Button> )} diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index 5769a478b..e548c45f7 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -6,168 +6,174 @@ import { View, ViewStyle, } from 'react-native' +import {AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api' import {Text} from '../text/Text' import {PostDropdownBtn} from '../forms/PostDropdownBtn' import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons' import {s, colors} from 'lib/styles' import {pluralize} from 'lib/strings/helpers' import {useTheme} from 'lib/ThemeContext' -import {useStores} from 'state/index' import {RepostButton} from './RepostButton' import {Haptics} from 'lib/haptics' import {HITSLOP_10, HITSLOP_20} from 'lib/constants' +import {useModalControls} from '#/state/modals' +import { + usePostLikeMutation, + usePostUnlikeMutation, + usePostRepostMutation, + usePostUnrepostMutation, +} from '#/state/queries/post' +import {useComposerControls} from '#/state/shell/composer' +import {Shadow} from '#/state/cache/types' +import {useRequireAuth} from '#/state/session' -interface PostCtrlsOpts { - itemUri: string - itemCid: string - itemHref: string - itemTitle: string - isAuthor: boolean - author: { - did: string - handle: string - displayName?: string | undefined - avatar?: string | undefined - } - text: string - indexedAt: string +export function PostCtrls({ + big, + post, + record, + style, + onPressReply, +}: { big?: boolean + post: Shadow<AppBskyFeedDefs.PostView> + record: AppBskyFeedPost.Record style?: StyleProp<ViewStyle> - replyCount?: number - repostCount?: number - likeCount?: number - isReposted: boolean - isLiked: boolean - isThreadMuted: boolean onPressReply: () => void - onPressToggleRepost: () => Promise<void> - onPressToggleLike: () => Promise<void> - onCopyPostText: () => void - onOpenTranslate: () => void - onToggleThreadMute: () => void - onDeletePost: () => void -} - -export function PostCtrls(opts: PostCtrlsOpts) { - const store = useStores() +}) { const theme = useTheme() + const {openComposer} = useComposerControls() + const {closeModal} = useModalControls() + const postLikeMutation = usePostLikeMutation() + const postUnlikeMutation = usePostUnlikeMutation() + const postRepostMutation = usePostRepostMutation() + const postUnrepostMutation = usePostUnrepostMutation() + const requireAuth = useRequireAuth() + const defaultCtrlColor = React.useMemo( () => ({ color: theme.palette.default.postCtrl, }), [theme], ) as StyleProp<ViewStyle> + + const onPressToggleLike = React.useCallback(async () => { + if (!post.viewer?.like) { + Haptics.default() + postLikeMutation.mutate({ + uri: post.uri, + cid: post.cid, + likeCount: post.likeCount || 0, + }) + } else { + postUnlikeMutation.mutate({ + postUri: post.uri, + likeUri: post.viewer.like, + likeCount: post.likeCount || 0, + }) + } + }, [post, postLikeMutation, postUnlikeMutation]) + const onRepost = useCallback(() => { - store.shell.closeModal() - if (!opts.isReposted) { + closeModal() + if (!post.viewer?.repost) { Haptics.default() - opts.onPressToggleRepost().catch(_e => undefined) + postRepostMutation.mutate({ + uri: post.uri, + cid: post.cid, + repostCount: post.repostCount || 0, + }) } else { - opts.onPressToggleRepost().catch(_e => undefined) + postUnrepostMutation.mutate({ + postUri: post.uri, + repostUri: post.viewer.repost, + repostCount: post.repostCount || 0, + }) } - }, [opts, store.shell]) + }, [post, closeModal, postRepostMutation, postUnrepostMutation]) const onQuote = useCallback(() => { - store.shell.closeModal() - store.shell.openComposer({ + closeModal() + openComposer({ quote: { - uri: opts.itemUri, - cid: opts.itemCid, - text: opts.text, - author: opts.author, - indexedAt: opts.indexedAt, + uri: post.uri, + cid: post.cid, + text: record.text, + author: post.author, + indexedAt: post.indexedAt, }, }) Haptics.default() - }, [ - opts.author, - opts.indexedAt, - opts.itemCid, - opts.itemUri, - opts.text, - store.shell, - ]) - - const onPressToggleLikeWrapper = async () => { - if (!opts.isLiked) { - Haptics.default() - await opts.onPressToggleLike().catch(_e => undefined) - } else { - await opts.onPressToggleLike().catch(_e => undefined) - } - } - + }, [post, record, openComposer, closeModal]) return ( - <View style={[styles.ctrls, opts.style]}> + <View style={[styles.ctrls, style]}> <TouchableOpacity testID="replyBtn" - style={[styles.ctrl, !opts.big && styles.ctrlPad, {paddingLeft: 0}]} - onPress={opts.onPressReply} + style={[styles.ctrl, !big && styles.ctrlPad, {paddingLeft: 0}]} + onPress={() => { + requireAuth(() => onPressReply()) + }} accessibilityRole="button" - accessibilityLabel={`Reply (${opts.replyCount} ${ - opts.replyCount === 1 ? 'reply' : 'replies' + accessibilityLabel={`Reply (${post.replyCount} ${ + post.replyCount === 1 ? 'reply' : 'replies' })`} accessibilityHint="" - hitSlop={opts.big ? HITSLOP_20 : HITSLOP_10}> + hitSlop={big ? HITSLOP_20 : HITSLOP_10}> <CommentBottomArrow - style={[defaultCtrlColor, opts.big ? s.mt2 : styles.mt1]} + style={[defaultCtrlColor, big ? s.mt2 : styles.mt1]} strokeWidth={3} - size={opts.big ? 20 : 15} + size={big ? 20 : 15} /> - {typeof opts.replyCount !== 'undefined' ? ( + {typeof post.replyCount !== 'undefined' ? ( <Text style={[defaultCtrlColor, s.ml5, s.f15]}> - {opts.replyCount} + {post.replyCount} </Text> ) : undefined} </TouchableOpacity> - <RepostButton {...opts} onRepost={onRepost} onQuote={onQuote} /> + <RepostButton + big={big} + isReposted={!!post.viewer?.repost} + repostCount={post.repostCount} + onRepost={onRepost} + onQuote={onQuote} + /> <TouchableOpacity testID="likeBtn" - style={[styles.ctrl, !opts.big && styles.ctrlPad]} - onPress={onPressToggleLikeWrapper} + style={[styles.ctrl, !big && styles.ctrlPad]} + onPress={() => { + requireAuth(() => onPressToggleLike()) + }} accessibilityRole="button" - accessibilityLabel={`${opts.isLiked ? 'Unlike' : 'Like'} (${ - opts.likeCount - } ${pluralize(opts.likeCount || 0, 'like')})`} + accessibilityLabel={`${post.viewer?.like ? 'Unlike' : 'Like'} (${ + post.likeCount + } ${pluralize(post.likeCount || 0, 'like')})`} accessibilityHint="" - hitSlop={opts.big ? HITSLOP_20 : HITSLOP_10}> - {opts.isLiked ? ( - <HeartIconSolid - style={styles.ctrlIconLiked} - size={opts.big ? 22 : 16} - /> + hitSlop={big ? HITSLOP_20 : HITSLOP_10}> + {post.viewer?.like ? ( + <HeartIconSolid style={styles.ctrlIconLiked} size={big ? 22 : 16} /> ) : ( <HeartIcon - style={[defaultCtrlColor, opts.big ? styles.mt1 : undefined]} + style={[defaultCtrlColor, big ? styles.mt1 : undefined]} strokeWidth={3} - size={opts.big ? 20 : 16} + size={big ? 20 : 16} /> )} - {typeof opts.likeCount !== 'undefined' ? ( + {typeof post.likeCount !== 'undefined' ? ( <Text testID="likeCount" style={ - opts.isLiked + post.viewer?.like ? [s.bold, s.red3, s.f15, s.ml5] : [defaultCtrlColor, s.f15, s.ml5] }> - {opts.likeCount} + {post.likeCount} </Text> ) : undefined} </TouchableOpacity> - {opts.big ? undefined : ( + {big ? undefined : ( <PostDropdownBtn testID="postDropdownBtn" - itemUri={opts.itemUri} - itemCid={opts.itemCid} - itemHref={opts.itemHref} - itemTitle={opts.itemTitle} - isAuthor={opts.isAuthor} - isThreadMuted={opts.isThreadMuted} - onCopyPostText={opts.onCopyPostText} - onOpenTranslate={opts.onOpenTranslate} - onToggleThreadMute={opts.onToggleThreadMute} - onDeletePost={opts.onDeletePost} + post={post} + record={record} style={styles.ctrlPad} /> )} diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx index 9c4ed8e5d..1d34a88ab 100644 --- a/src/view/com/util/post-ctrls/RepostButton.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.tsx @@ -5,8 +5,9 @@ import {s, colors} from 'lib/styles' import {useTheme} from 'lib/ThemeContext' import {Text} from '../text/Text' import {pluralize} from 'lib/strings/helpers' -import {useStores} from 'state/index' import {HITSLOP_10, HITSLOP_20} from 'lib/constants' +import {useModalControls} from '#/state/modals' +import {useRequireAuth} from '#/state/session' interface Props { isReposted: boolean @@ -23,8 +24,9 @@ export const RepostButton = ({ onRepost, onQuote, }: Props) => { - const store = useStores() const theme = useTheme() + const {openModal} = useModalControls() + const requireAuth = useRequireAuth() const defaultControlColor = React.useMemo( () => ({ @@ -34,18 +36,20 @@ export const RepostButton = ({ ) const onPressToggleRepostWrapper = useCallback(() => { - store.shell.openModal({ + openModal({ name: 'repost', onRepost: onRepost, onQuote: onQuote, isReposted, }) - }, [onRepost, onQuote, isReposted, store.shell]) + }, [onRepost, onQuote, isReposted, openModal]) return ( <TouchableOpacity testID="repostBtn" - onPress={onPressToggleRepostWrapper} + onPress={() => { + requireAuth(() => onPressToggleRepostWrapper()) + }} style={[styles.control, !big && styles.controlPad]} accessibilityRole="button" accessibilityLabel={`${ diff --git a/src/view/com/util/post-ctrls/RepostButton.web.tsx b/src/view/com/util/post-ctrls/RepostButton.web.tsx index 57f544d41..329382132 100644 --- a/src/view/com/util/post-ctrls/RepostButton.web.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.web.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' +import {StyleProp, StyleSheet, View, ViewStyle, Pressable} from 'react-native' import {RepostIcon} from 'lib/icons' import {colors} from 'lib/styles' import {useTheme} from 'lib/ThemeContext' @@ -10,6 +10,10 @@ import { DropdownItem as NativeDropdownItem, } from '../forms/NativeDropdown' import {EventStopper} from '../EventStopper' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import {useRequireAuth} from '#/state/session' +import {useSession} from '#/state/session' interface Props { isReposted: boolean @@ -28,6 +32,9 @@ export const RepostButton = ({ onQuote, }: Props) => { const theme = useTheme() + const {_} = useLingui() + const {hasSession} = useSession() + const requireAuth = useRequireAuth() const defaultControlColor = React.useMemo( () => ({ @@ -38,7 +45,7 @@ export const RepostButton = ({ const dropdownItems: NativeDropdownItem[] = [ { - label: isReposted ? 'Undo repost' : 'Repost', + label: isReposted ? _(msg`Undo repost`) : _(msg`Repost`), testID: 'repostDropdownRepostBtn', icon: { ios: {name: 'repeat'}, @@ -48,7 +55,7 @@ export const RepostButton = ({ onPress: onRepost, }, { - label: 'Quote post', + label: _(msg`Quote post`), testID: 'repostDropdownQuoteBtn', icon: { ios: {name: 'quote.bubble'}, @@ -59,32 +66,46 @@ export const RepostButton = ({ }, ] - return ( + const inner = ( + <View + style={[ + styles.control, + !big && styles.controlPad, + (isReposted + ? styles.reposted + : defaultControlColor) as StyleProp<ViewStyle>, + ]}> + <RepostIcon strokeWidth={2.2} size={big ? 24 : 20} /> + {typeof repostCount !== 'undefined' ? ( + <Text + testID="repostCount" + type={isReposted ? 'md-bold' : 'md'} + style={styles.repostCount}> + {repostCount ?? 0} + </Text> + ) : undefined} + </View> + ) + + return hasSession ? ( <EventStopper> <NativeDropdown items={dropdownItems} - accessibilityLabel="Repost or quote post" + accessibilityLabel={_(msg`Repost or quote post`)} accessibilityHint=""> - <View - style={[ - styles.control, - !big && styles.controlPad, - (isReposted - ? styles.reposted - : defaultControlColor) as StyleProp<ViewStyle>, - ]}> - <RepostIcon strokeWidth={2.2} size={big ? 24 : 20} /> - {typeof repostCount !== 'undefined' ? ( - <Text - testID="repostCount" - type={isReposted ? 'md-bold' : 'md'} - style={styles.repostCount}> - {repostCount ?? 0} - </Text> - ) : undefined} - </View> + {inner} </NativeDropdown> </EventStopper> + ) : ( + <Pressable + accessibilityRole="button" + onPress={() => { + requireAuth(() => {}) + }} + accessibilityLabel={_(msg`Repost or quote post`)} + accessibilityHint=""> + {inner} + </Pressable> ) } diff --git a/src/view/com/util/post-embeds/CustomFeedEmbed.tsx b/src/view/com/util/post-embeds/CustomFeedEmbed.tsx deleted file mode 100644 index 624157436..000000000 --- a/src/view/com/util/post-embeds/CustomFeedEmbed.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React, {useMemo} from 'react' -import {AppBskyFeedDefs} from '@atproto/api' -import {usePalette} from 'lib/hooks/usePalette' -import {StyleSheet} from 'react-native' -import {useStores} from 'state/index' -import {FeedSourceModel} from 'state/models/content/feed-source' -import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' - -export function CustomFeedEmbed({ - record, -}: { - record: AppBskyFeedDefs.GeneratorView -}) { - const pal = usePalette('default') - const store = useStores() - const item = useMemo(() => { - const model = new FeedSourceModel(store, record.uri) - model.hydrateFeedGenerator(record) - return model - }, [store, record]) - return ( - <FeedSourceCard - item={item} - style={[pal.view, pal.border, styles.customFeedOuter]} - showLikes - /> - ) -} - -const styles = StyleSheet.create({ - customFeedOuter: { - borderWidth: 1, - borderRadius: 8, - marginTop: 4, - paddingHorizontal: 12, - paddingVertical: 12, - }, -}) diff --git a/src/view/com/util/post-embeds/ListEmbed.tsx b/src/view/com/util/post-embeds/ListEmbed.tsx index dbf350039..fc5ad270f 100644 --- a/src/view/com/util/post-embeds/ListEmbed.tsx +++ b/src/view/com/util/post-embeds/ListEmbed.tsx @@ -1,12 +1,11 @@ import React from 'react' import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {usePalette} from 'lib/hooks/usePalette' -import {observer} from 'mobx-react-lite' import {ListCard} from 'view/com/lists/ListCard' import {AppBskyGraphDefs} from '@atproto/api' import {s} from 'lib/styles' -export const ListEmbed = observer(function ListEmbedImpl({ +export function ListEmbed({ item, style, }: { @@ -20,7 +19,7 @@ export const ListEmbed = observer(function ListEmbedImpl({ <ListCard list={item} style={[style, styles.card]} /> </View> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index f82b5b7df..e793f983e 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -12,7 +12,7 @@ import {PostMeta} from '../PostMeta' import {Link} from '../Link' import {Text} from '../text/Text' import {usePalette} from 'lib/hooks/usePalette' -import {ComposerOptsQuote} from 'state/models/ui/shell' +import {ComposerOptsQuote} from 'state/shell/composer' import {PostEmbeds} from '.' import {PostAlerts} from '../moderation/PostAlerts' import {makeProfileLink} from 'lib/routes/links' diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 6c13bc2bb..ca3bf1104 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -19,8 +19,7 @@ import { } from '@atproto/api' import {Link} from '../Link' import {ImageLayoutGrid} from '../images/ImageLayoutGrid' -import {ImagesLightbox} from 'state/models/ui/shell' -import {useStores} from 'state/index' +import {useLightboxControls, ImagesLightbox} from '#/state/lightbox' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {YoutubeEmbed} from './YoutubeEmbed' @@ -28,9 +27,9 @@ import {ExternalLinkEmbed} from './ExternalLinkEmbed' import {getYoutubeVideoId} from 'lib/strings/url-helpers' import {MaybeQuoteEmbed} from './QuoteEmbed' import {AutoSizedImage} from '../images/AutoSizedImage' -import {CustomFeedEmbed} from './CustomFeedEmbed' import {ListEmbed} from './ListEmbed' import {isCauseALabelOnUri} from 'lib/moderation' +import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' type Embed = | AppBskyEmbedRecord.View @@ -49,7 +48,7 @@ export function PostEmbeds({ style?: StyleProp<ViewStyle> }) { const pal = usePalette('default') - const store = useStores() + const {openLightbox} = useLightboxControls() const {isMobile} = useWebMediaQueries() // quote post with media @@ -72,7 +71,13 @@ export function PostEmbeds({ // custom feed embed (i.e. generator view) // = if (AppBskyFeedDefs.isGeneratorView(embed.record)) { - return <CustomFeedEmbed record={embed.record} /> + return ( + <FeedSourceCard + feedUri={embed.record.uri} + style={[pal.view, pal.border, styles.customFeedOuter]} + showLikes + /> + ) } // list embed @@ -98,8 +103,8 @@ export function PostEmbeds({ alt: img.alt, aspectRatio: img.aspectRatio, })) - const openLightbox = (index: number) => { - store.shell.openLightbox(new ImagesLightbox(items, index)) + const _openLightbox = (index: number) => { + openLightbox(new ImagesLightbox(items, index)) } const onPressIn = (_: number) => { InteractionManager.runAfterInteractions(() => { @@ -115,7 +120,7 @@ export function PostEmbeds({ alt={alt} uri={thumb} dimensionsHint={aspectRatio} - onPress={() => openLightbox(0)} + onPress={() => _openLightbox(0)} onPressIn={() => onPressIn(0)} style={[ styles.singleImage, @@ -137,7 +142,7 @@ export function PostEmbeds({ <View style={[styles.imagesContainer, style]}> <ImageLayoutGrid images={embed.images} - onPress={openLightbox} + onPress={_openLightbox} onPressIn={onPressIn} style={ embed.images.length === 1 @@ -206,4 +211,11 @@ const styles = StyleSheet.create({ fontSize: 10, fontWeight: 'bold', }, + customFeedOuter: { + borderWidth: 1, + borderRadius: 8, + marginTop: 4, + paddingHorizontal: 12, + paddingVertical: 12, + }, }) diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx index 74d293ef4..154035f22 100644 --- a/src/view/screens/AppPasswords.tsx +++ b/src/view/screens/AppPasswords.tsx @@ -1,80 +1,114 @@ import React from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' +import { + ActivityIndicator, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {ScrollView} from 'react-native-gesture-handler' import {Text} from '../com/util/text/Text' import {Button} from '../com/util/forms/Button' import * as Toast from '../com/util/Toast' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {observer} from 'mobx-react-lite' import {NativeStackScreenProps} from '@react-navigation/native-stack' import {CommonNavigatorParams} from 'lib/routes/types' import {useAnalytics} from 'lib/analytics/analytics' import {useFocusEffect} from '@react-navigation/native' import {ViewHeader} from '../com/util/ViewHeader' import {CenteredView} from 'view/com/util/Views' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {useSetMinimalShellMode} from '#/state/shell' +import {useModalControls} from '#/state/modals' +import {useLanguagePrefs} from '#/state/preferences' +import { + useAppPasswordsQuery, + useAppPasswordDeleteMutation, +} from '#/state/queries/app-passwords' +import {ErrorScreen} from '../com/util/error/ErrorScreen' +import {cleanError} from '#/lib/strings/errors' type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppPasswords'> -export const AppPasswords = withAuthRequired( - observer(function AppPasswordsImpl({}: Props) { - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {screen} = useAnalytics() - const {isTabletOrDesktop} = useWebMediaQueries() +export function AppPasswords({}: Props) { + const pal = usePalette('default') + const setMinimalShellMode = useSetMinimalShellMode() + const {screen} = useAnalytics() + const {isTabletOrDesktop} = useWebMediaQueries() + const {openModal} = useModalControls() + const {data: appPasswords, error} = useAppPasswordsQuery() - useFocusEffect( - React.useCallback(() => { - screen('AppPasswords') - setMinimalShellMode(false) - }, [screen, setMinimalShellMode]), - ) + useFocusEffect( + React.useCallback(() => { + screen('AppPasswords') + setMinimalShellMode(false) + }, [screen, setMinimalShellMode]), + ) - const onAdd = React.useCallback(async () => { - store.shell.openModal({name: 'add-app-password'}) - }, [store]) + const onAdd = React.useCallback(async () => { + openModal({name: 'add-app-password'}) + }, [openModal]) - // no app passwords (empty) state - if (store.me.appPasswords.length === 0) { - return ( - <CenteredView - style={[ - styles.container, - isTabletOrDesktop && styles.containerDesktop, - pal.view, - pal.border, - ]} - testID="appPasswordsScreen"> - <AppPasswordsHeader /> - <View style={[styles.empty, pal.viewLight]}> - <Text type="lg" style={[pal.text, styles.emptyText]}> + if (error) { + return ( + <CenteredView + style={[ + styles.container, + isTabletOrDesktop && styles.containerDesktop, + pal.view, + pal.border, + ]} + testID="appPasswordsScreen"> + <ErrorScreen + title="Oops!" + message="There was an issue with fetching your app passwords" + details={cleanError(error)} + /> + </CenteredView> + ) + } + + // no app passwords (empty) state + if (appPasswords?.length === 0) { + return ( + <CenteredView + style={[ + styles.container, + isTabletOrDesktop && styles.containerDesktop, + pal.view, + pal.border, + ]} + testID="appPasswordsScreen"> + <AppPasswordsHeader /> + <View style={[styles.empty, pal.viewLight]}> + <Text type="lg" style={[pal.text, styles.emptyText]}> + <Trans> You have not created any app passwords yet. You can create one by pressing the button below. - </Text> - </View> - {!isTabletOrDesktop && <View style={styles.flex1} />} - <View - style={[ - styles.btnContainer, - isTabletOrDesktop && styles.btnContainerDesktop, - ]}> - <Button - testID="appPasswordBtn" - type="primary" - label="Add App Password" - style={styles.btn} - labelStyle={styles.btnLabel} - onPress={onAdd} - /> - </View> - </CenteredView> - ) - } + </Trans> + </Text> + </View> + {!isTabletOrDesktop && <View style={styles.flex1} />} + <View + style={[ + styles.btnContainer, + isTabletOrDesktop && styles.btnContainerDesktop, + ]}> + <Button + testID="appPasswordBtn" + type="primary" + label="Add App Password" + style={styles.btn} + labelStyle={styles.btnLabel} + onPress={onAdd} + /> + </View> + </CenteredView> + ) + } + if (appPasswords?.length) { // has app passwords return ( <CenteredView @@ -92,7 +126,7 @@ export const AppPasswords = withAuthRequired( pal.border, !isTabletOrDesktop && styles.flex1, ]}> - {store.me.appPasswords.map((password, i) => ( + {appPasswords.map((password, i) => ( <AppPassword key={password.name} testID={`appPassword-${i}`} @@ -127,15 +161,29 @@ export const AppPasswords = withAuthRequired( )} </CenteredView> ) - }), -) + } + + return ( + <CenteredView + style={[ + styles.container, + isTabletOrDesktop && styles.containerDesktop, + pal.view, + pal.border, + ]} + testID="appPasswordsScreen"> + <ActivityIndicator /> + </CenteredView> + ) +} function AppPasswordsHeader() { const {isTabletOrDesktop} = useWebMediaQueries() const pal = usePalette('default') + const {_} = useLingui() return ( <> - <ViewHeader title="App Passwords" showOnDesktop /> + <ViewHeader title={_(msg`App Passwords`)} showOnDesktop /> <Text type="sm" style={[ @@ -143,8 +191,10 @@ function AppPasswordsHeader() { pal.text, isTabletOrDesktop && styles.descriptionDesktop, ]}> - Use app passwords to login to other Bluesky clients without giving full - access to your account or password. + <Trans> + Use app passwords to login to other Bluesky clients without giving + full access to your account or password. + </Trans> </Text> </> ) @@ -160,21 +210,24 @@ function AppPassword({ createdAt: string }) { const pal = usePalette('default') - const store = useStores() + const {_} = useLingui() + const {openModal} = useModalControls() + const {contentLanguages} = useLanguagePrefs() + const deleteMutation = useAppPasswordDeleteMutation() const onDelete = React.useCallback(async () => { - store.shell.openModal({ + openModal({ name: 'confirm', - title: 'Delete App Password', - message: `Are you sure you want to delete the app password "${name}"?`, + title: _(msg`Delete app password`), + message: _( + msg`Are you sure you want to delete the app password "${name}"?`, + ), async onPressConfirm() { - await store.me.deleteAppPassword(name) + await deleteMutation.mutateAsync({name}) Toast.show('App password deleted') }, }) - }, [store, name]) - - const {contentLanguages} = store.preferences + }, [deleteMutation, openModal, name, _]) const primaryLocale = contentLanguages.length > 0 ? contentLanguages[0] : 'en-US' @@ -185,22 +238,24 @@ function AppPassword({ style={[styles.item, pal.border]} onPress={onDelete} accessibilityRole="button" - accessibilityLabel="Delete app password" + accessibilityLabel={_(msg`Delete app password`)} accessibilityHint=""> <View> <Text type="md-bold" style={pal.text}> {name} </Text> <Text type="md" style={[pal.text, styles.pr10]} numberOfLines={1}> - Created{' '} - {Intl.DateTimeFormat(primaryLocale, { - year: 'numeric', - month: 'numeric', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }).format(new Date(createdAt))} + <Trans> + Created{' '} + {Intl.DateTimeFormat(primaryLocale, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).format(new Date(createdAt))} + </Trans> </Text> </View> <FontAwesomeIcon icon={['far', 'trash-can']} style={styles.trashIcon} /> diff --git a/src/view/screens/CommunityGuidelines.tsx b/src/view/screens/CommunityGuidelines.tsx index 712172c3b..1931c6f13 100644 --- a/src/view/screens/CommunityGuidelines.tsx +++ b/src/view/screens/CommunityGuidelines.tsx @@ -9,6 +9,8 @@ import {ScrollView} from 'view/com/util/Views' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type Props = NativeStackScreenProps< CommonNavigatorParams, @@ -16,6 +18,7 @@ type Props = NativeStackScreenProps< > export const CommunityGuidelinesScreen = (_props: Props) => { const pal = usePalette('default') + const {_} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() useFocusEffect( @@ -26,16 +29,18 @@ export const CommunityGuidelinesScreen = (_props: Props) => { return ( <View> - <ViewHeader title="Community Guidelines" /> + <ViewHeader title={_(msg`Community Guidelines`)} /> <ScrollView style={[s.hContentRegion, pal.view]}> <View style={[s.p20]}> <Text style={pal.text}> - The Community Guidelines have been moved to{' '} - <TextLink - style={pal.link} - href="https://blueskyweb.xyz/support/community-guidelines" - text="blueskyweb.xyz/support/community-guidelines" - /> + <Trans> + The Community Guidelines have been moved to{' '} + <TextLink + style={pal.link} + href="https://blueskyweb.xyz/support/community-guidelines" + text="blueskyweb.xyz/support/community-guidelines" + /> + </Trans> </Text> </View> <View style={s.footerSpacer} /> diff --git a/src/view/screens/CopyrightPolicy.tsx b/src/view/screens/CopyrightPolicy.tsx index 816c1c1ee..2026f28c6 100644 --- a/src/view/screens/CopyrightPolicy.tsx +++ b/src/view/screens/CopyrightPolicy.tsx @@ -9,10 +9,13 @@ import {ScrollView} from 'view/com/util/Views' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type Props = NativeStackScreenProps<CommonNavigatorParams, 'CopyrightPolicy'> export const CopyrightPolicyScreen = (_props: Props) => { const pal = usePalette('default') + const {_} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() useFocusEffect( @@ -23,16 +26,18 @@ export const CopyrightPolicyScreen = (_props: Props) => { return ( <View> - <ViewHeader title="Copyright Policy" /> + <ViewHeader title={_(msg`Copyright Policy`)} /> <ScrollView style={[s.hContentRegion, pal.view]}> <View style={[s.p20]}> <Text style={pal.text}> - The Copyright Policy has been moved to{' '} - <TextLink - style={pal.link} - href="https://blueskyweb.xyz/support/community-guidelines" - text="blueskyweb.xyz/support/community-guidelines" - /> + <Trans> + The Copyright Policy has been moved to{' '} + <TextLink + style={pal.link} + href="https://blueskyweb.xyz/support/community-guidelines" + text="blueskyweb.xyz/support/community-guidelines" + /> + </Trans> </Text> </View> <View style={s.footerSpacer} /> diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index 169660a8f..f319fbc39 100644 --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -1,15 +1,12 @@ import React from 'react' -import {ActivityIndicator, StyleSheet, RefreshControl, View} from 'react-native' +import {ActivityIndicator, StyleSheet, View, RefreshControl} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from 'view/com/util/ViewHeader' import {FAB} from 'view/com/util/fab/FAB' import {Link} from 'view/com/util/Link' import {NativeStackScreenProps, FeedsTabNavigatorParams} from 'lib/routes/types' -import {observer} from 'mobx-react-lite' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {ComposeIcon2, CogIcon} from 'lib/icons' import {s} from 'lib/styles' @@ -22,255 +19,525 @@ import { import {ErrorMessage} from 'view/com/util/error/ErrorMessage' import debounce from 'lodash.debounce' import {Text} from 'view/com/util/text/Text' -import {MyFeedsItem} from 'state/models/ui/my-feeds' -import {FeedSourceModel} from 'state/models/content/feed-source' import {FlatList} from 'view/com/util/Views' import {useFocusEffect} from '@react-navigation/native' import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {useSetMinimalShellMode} from '#/state/shell' +import {usePreferencesQuery} from '#/state/queries/preferences' +import { + useFeedSourceInfoQuery, + useGetPopularFeedsQuery, + useSearchPopularFeedsMutation, +} from '#/state/queries/feed' +import {cleanError} from 'lib/strings/errors' +import {useComposerControls} from '#/state/shell/composer' +import {useSession} from '#/state/session' type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'> -export const FeedsScreen = withAuthRequired( - observer<Props>(function FeedsScreenImpl({}: Props) { - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {isMobile, isTabletOrDesktop} = useWebMediaQueries() - const myFeeds = store.me.myFeeds - const [query, setQuery] = React.useState<string>('') - const debouncedSearchFeeds = React.useMemo( - () => debounce(q => myFeeds.discovery.search(q), 500), // debounce for 500ms - [myFeeds], - ) - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - myFeeds.setup() +type FlatlistSlice = + | { + type: 'error' + key: string + error: string + } + | { + type: 'savedFeedsHeader' + key: string + } + | { + type: 'savedFeedsLoading' + key: string + // pendingItems: number, + } + | { + type: 'savedFeedNoResults' + key: string + } + | { + type: 'savedFeed' + key: string + feedUri: string + } + | { + type: 'savedFeedsLoadMore' + key: string + } + | { + type: 'popularFeedsHeader' + key: string + } + | { + type: 'popularFeedsLoading' + key: string + } + | { + type: 'popularFeedsNoResults' + key: string + } + | { + type: 'popularFeed' + key: string + feedUri: string + } + | { + type: 'popularFeedsLoadingMore' + key: string + } - const softResetSub = store.onScreenSoftReset(() => myFeeds.refresh()) - return () => { - softResetSub.remove() - } - }, [store, myFeeds, setMinimalShellMode]), +export function FeedsScreen(_props: Props) { + const pal = usePalette('default') + const {openComposer} = useComposerControls() + const {isMobile, isTabletOrDesktop} = useWebMediaQueries() + const [query, setQuery] = React.useState('') + const [isPTR, setIsPTR] = React.useState(false) + const { + data: preferences, + isLoading: isPreferencesLoading, + error: preferencesError, + } = usePreferencesQuery() + const { + data: popularFeeds, + isFetching: isPopularFeedsFetching, + error: popularFeedsError, + refetch: refetchPopularFeeds, + fetchNextPage: fetchNextPopularFeedsPage, + isFetchingNextPage: isPopularFeedsFetchingNextPage, + hasNextPage: hasNextPopularFeedsPage, + } = useGetPopularFeedsQuery() + const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() + const { + data: searchResults, + mutate: search, + reset: resetSearch, + isPending: isSearchPending, + error: searchError, + } = useSearchPopularFeedsMutation() + const {hasSession} = useSession() + + /** + * A search query is present. We may not have search results yet. + */ + const isUserSearching = query.length > 1 + const debouncedSearch = React.useMemo( + () => debounce(q => search(q), 500), // debounce for 500ms + [search], + ) + const onPressCompose = React.useCallback(() => { + openComposer({}) + }, [openComposer]) + const onChangeQuery = React.useCallback( + (text: string) => { + setQuery(text) + if (text.length > 1) { + debouncedSearch(text) + } else { + refetchPopularFeeds() + resetSearch() + } + }, + [setQuery, refetchPopularFeeds, debouncedSearch, resetSearch], + ) + const onPressCancelSearch = React.useCallback(() => { + setQuery('') + refetchPopularFeeds() + resetSearch() + }, [refetchPopularFeeds, setQuery, resetSearch]) + const onSubmitQuery = React.useCallback(() => { + debouncedSearch(query) + }, [query, debouncedSearch]) + const onPullToRefresh = React.useCallback(async () => { + setIsPTR(true) + await refetchPopularFeeds() + setIsPTR(false) + }, [setIsPTR, refetchPopularFeeds]) + const onEndReached = React.useCallback(() => { + if ( + isPopularFeedsFetching || + isUserSearching || + !hasNextPopularFeedsPage || + popularFeedsError ) - React.useEffect(() => { - // watch for changes to saved/pinned feeds - return myFeeds.registerListeners() - }, [myFeeds]) + return + fetchNextPopularFeedsPage() + }, [ + isPopularFeedsFetching, + isUserSearching, + popularFeedsError, + hasNextPopularFeedsPage, + fetchNextPopularFeedsPage, + ]) + + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) + + const items = React.useMemo(() => { + let slices: FlatlistSlice[] = [] - const onPressCompose = React.useCallback(() => { - store.shell.openComposer({}) - }, [store]) - const onChangeQuery = React.useCallback( - (text: string) => { - setQuery(text) - if (text.length > 1) { - debouncedSearchFeeds(text) + if (hasSession) { + slices.push({ + key: 'savedFeedsHeader', + type: 'savedFeedsHeader', + }) + + if (preferencesError) { + slices.push({ + key: 'savedFeedsError', + type: 'error', + error: cleanError(preferencesError.toString()), + }) + } else { + if (isPreferencesLoading || !preferences?.feeds?.saved) { + slices.push({ + key: 'savedFeedsLoading', + type: 'savedFeedsLoading', + // pendingItems: this.rootStore.preferences.savedFeeds.length || 3, + }) } else { - myFeeds.discovery.refresh() - } - }, - [debouncedSearchFeeds, myFeeds.discovery], - ) - const onPressCancelSearch = React.useCallback(() => { - setQuery('') - myFeeds.discovery.refresh() - }, [myFeeds]) - const onSubmitQuery = React.useCallback(() => { - debouncedSearchFeeds(query) - debouncedSearchFeeds.flush() - }, [debouncedSearchFeeds, query]) + if (preferences?.feeds?.saved.length === 0) { + slices.push({ + key: 'savedFeedNoResults', + type: 'savedFeedNoResults', + }) + } else { + const {saved, pinned} = preferences.feeds - const renderHeaderBtn = React.useCallback(() => { - return ( - <Link - href="/settings/saved-feeds" - hitSlop={10} - accessibilityRole="button" - accessibilityLabel="Edit Saved Feeds" - accessibilityHint="Opens screen to edit Saved Feeds"> - <CogIcon size={22} strokeWidth={2} style={pal.textLight} /> - </Link> - ) - }, [pal]) + slices = slices.concat( + pinned.map(uri => ({ + key: `savedFeed:${uri}`, + type: 'savedFeed', + feedUri: uri, + })), + ) - const onRefresh = React.useCallback(() => { - myFeeds.refresh() - }, [myFeeds]) + slices = slices.concat( + saved + .filter(uri => !pinned.includes(uri)) + .map(uri => ({ + key: `savedFeed:${uri}`, + type: 'savedFeed', + feedUri: uri, + })), + ) + } + } + } + } - const renderItem = React.useCallback( - ({item}: {item: MyFeedsItem}) => { - if (item.type === 'discover-feeds-loading') { - return <FeedFeedLoadingPlaceholder /> - } else if (item.type === 'spinner') { - return ( - <View style={s.p10}> - <ActivityIndicator /> - </View> - ) - } else if (item.type === 'error') { - return <ErrorMessage message={item.error} /> - } else if (item.type === 'saved-feeds-header') { - if (!isMobile) { - return ( - <View - style={[ - pal.view, - styles.header, - pal.border, - { - borderBottomWidth: 1, - }, - ]}> - <Text type="title-lg" style={[pal.text, s.bold]}> - My Feeds - </Text> - <Link - href="/settings/saved-feeds" - accessibilityLabel="Edit My Feeds" - accessibilityHint=""> - <CogIcon strokeWidth={1.5} style={pal.icon} size={28} /> - </Link> - </View> + slices.push({ + key: 'popularFeedsHeader', + type: 'popularFeedsHeader', + }) + + if (popularFeedsError || searchError) { + slices.push({ + key: 'popularFeedsError', + type: 'error', + error: cleanError( + popularFeedsError?.toString() ?? searchError?.toString() ?? '', + ), + }) + } else { + if (isUserSearching) { + if (isSearchPending || !searchResults) { + slices.push({ + key: 'popularFeedsLoading', + type: 'popularFeedsLoading', + }) + } else { + if (!searchResults || searchResults?.length === 0) { + slices.push({ + key: 'popularFeedsNoResults', + type: 'popularFeedsNoResults', + }) + } else { + slices = slices.concat( + searchResults.map(feed => ({ + key: `popularFeed:${feed.uri}`, + type: 'popularFeed', + feedUri: feed.uri, + })), ) } - return <View /> - } else if (item.type === 'saved-feeds-loading') { - return ( - <> - {Array.from(Array(item.numItems)).map((_, i) => ( - <SavedFeedLoadingPlaceholder key={`placeholder-${i}`} /> - ))} - </> - ) - } else if (item.type === 'saved-feed') { - return <SavedFeed feed={item.feed} /> - } else if (item.type === 'discover-feeds-header') { - return ( - <> - <View - style={[ - pal.view, - styles.header, - { - marginTop: 16, - paddingLeft: isMobile ? 12 : undefined, - paddingRight: 10, - paddingBottom: isMobile ? 6 : undefined, - }, - ]}> - <Text type="title-lg" style={[pal.text, s.bold]}> - Discover new feeds - </Text> - {!isMobile && ( - <SearchInput - query={query} - onChangeQuery={onChangeQuery} - onPressCancelSearch={onPressCancelSearch} - onSubmitQuery={onSubmitQuery} - style={{flex: 1, maxWidth: 250}} - /> - )} - </View> - {isMobile && ( - <View style={{paddingHorizontal: 8, paddingBottom: 10}}> - <SearchInput - query={query} - onChangeQuery={onChangeQuery} - onPressCancelSearch={onPressCancelSearch} - onSubmitQuery={onSubmitQuery} - /> - </View> - )} - </> - ) - } else if (item.type === 'discover-feed') { - return ( - <FeedSourceCard - item={item.feed} - showSaveBtn - showDescription - showLikes - /> - ) - } else if (item.type === 'discover-feeds-no-results') { + } + } else { + if (isPopularFeedsFetching && !popularFeeds?.pages) { + slices.push({ + key: 'popularFeedsLoading', + type: 'popularFeedsLoading', + }) + } else { + if ( + !popularFeeds?.pages || + popularFeeds?.pages[0]?.feeds?.length === 0 + ) { + slices.push({ + key: 'popularFeedsNoResults', + type: 'popularFeedsNoResults', + }) + } else { + for (const page of popularFeeds.pages || []) { + slices = slices.concat( + page.feeds + .filter(feed => !preferences?.feeds?.saved.includes(feed.uri)) + .map(feed => ({ + key: `popularFeed:${feed.uri}`, + type: 'popularFeed', + feedUri: feed.uri, + })), + ) + } + + if (isPopularFeedsFetchingNextPage) { + slices.push({ + key: 'popularFeedsLoadingMore', + type: 'popularFeedsLoadingMore', + }) + } + } + } + } + } + + return slices + }, [ + hasSession, + preferences, + isPreferencesLoading, + preferencesError, + popularFeeds, + isPopularFeedsFetching, + popularFeedsError, + isPopularFeedsFetchingNextPage, + searchResults, + isSearchPending, + searchError, + isUserSearching, + ]) + + const renderHeaderBtn = React.useCallback(() => { + return ( + <Link + href="/settings/saved-feeds" + hitSlop={10} + accessibilityRole="button" + accessibilityLabel={_(msg`Edit Saved Feeds`)} + accessibilityHint="Opens screen to edit Saved Feeds"> + <CogIcon size={22} strokeWidth={2} style={pal.textLight} /> + </Link> + ) + }, [pal, _]) + + const renderItem = React.useCallback( + ({item}: {item: FlatlistSlice}) => { + if (item.type === 'error') { + return <ErrorMessage message={item.error} /> + } else if ( + item.type === 'popularFeedsLoadingMore' || + item.type === 'savedFeedsLoading' + ) { + return ( + <View style={s.p10}> + <ActivityIndicator /> + </View> + ) + } else if (item.type === 'savedFeedsHeader') { + if (!isMobile) { return ( <View - style={{ - paddingHorizontal: 16, - paddingTop: 10, - paddingBottom: '150%', - }}> - <Text type="lg" style={pal.textLight}> - No results found for "{query}" + style={[ + pal.view, + styles.header, + pal.border, + { + borderBottomWidth: 1, + }, + ]}> + <Text type="title-lg" style={[pal.text, s.bold]}> + <Trans>My Feeds</Trans> </Text> + <Link + href="/settings/saved-feeds" + accessibilityLabel={_(msg`Edit My Feeds`)} + accessibilityHint=""> + <CogIcon strokeWidth={1.5} style={pal.icon} size={28} /> + </Link> </View> ) } - return null - }, - [isMobile, pal, query, onChangeQuery, onPressCancelSearch, onSubmitQuery], - ) + return <View /> + } else if (item.type === 'savedFeedNoResults') { + return ( + <View + style={{ + paddingHorizontal: 16, + paddingTop: 10, + }}> + <Text type="lg" style={pal.textLight}> + <Trans>You don't have any saved feeds!</Trans> + </Text> + </View> + ) + } else if (item.type === 'savedFeed') { + return <SavedFeed feedUri={item.feedUri} /> + } else if (item.type === 'popularFeedsHeader') { + return ( + <> + <View + style={[ + pal.view, + styles.header, + { + // This is first in the flatlist without a session -esb + marginTop: hasSession ? 16 : 0, + paddingLeft: isMobile ? 12 : undefined, + paddingRight: 10, + paddingBottom: isMobile ? 6 : undefined, + }, + ]}> + <Text type="title-lg" style={[pal.text, s.bold]}> + <Trans>Discover new feeds</Trans> + </Text> - return ( - <View style={[pal.view, styles.container]}> - {isMobile && ( - <ViewHeader - title="Feeds" - canGoBack={false} - renderButton={renderHeaderBtn} - showBorder + {!isMobile && ( + <SearchInput + query={query} + onChangeQuery={onChangeQuery} + onPressCancelSearch={onPressCancelSearch} + onSubmitQuery={onSubmitQuery} + style={{flex: 1, maxWidth: 250}} + /> + )} + </View> + + {isMobile && ( + <View style={{paddingHorizontal: 8, paddingBottom: 10}}> + <SearchInput + query={query} + onChangeQuery={onChangeQuery} + onPressCancelSearch={onPressCancelSearch} + onSubmitQuery={onSubmitQuery} + /> + </View> + )} + </> + ) + } else if (item.type === 'popularFeedsLoading') { + return <FeedFeedLoadingPlaceholder /> + } else if (item.type === 'popularFeed') { + return ( + <FeedSourceCard + feedUri={item.feedUri} + showSaveBtn={hasSession} + showDescription + showLikes + pinOnSave /> - )} + ) + } else if (item.type === 'popularFeedsNoResults') { + return ( + <View + style={{ + paddingHorizontal: 16, + paddingTop: 10, + paddingBottom: '150%', + }}> + <Text type="lg" style={pal.textLight}> + <Trans>No results found for "{query}"</Trans> + </Text> + </View> + ) + } + return null + }, + [ + _, + hasSession, + isMobile, + pal, + query, + onChangeQuery, + onPressCancelSearch, + onSubmitQuery, + ], + ) - <FlatList - style={[!isTabletOrDesktop && s.flex1, styles.list]} - data={myFeeds.items} - keyExtractor={item => item._reactKey} - contentContainerStyle={styles.contentContainer} - refreshControl={ - <RefreshControl - refreshing={myFeeds.isRefreshing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } - renderItem={renderItem} - initialNumToRender={10} - onEndReached={() => myFeeds.loadMore()} - extraData={myFeeds.isLoading} - // @ts-ignore our .web version only -prf - desktopFixedHeight + return ( + <View style={[pal.view, styles.container]}> + {isMobile && ( + <ViewHeader + title={_(msg`Feeds`)} + canGoBack={false} + renderButton={renderHeaderBtn} + showBorder /> + )} + + {preferences ? <View /> : <ActivityIndicator />} + + <FlatList + style={[!isTabletOrDesktop && s.flex1, styles.list]} + data={items} + keyExtractor={item => item.key} + contentContainerStyle={styles.contentContainer} + renderItem={renderItem} + refreshControl={ + <RefreshControl + refreshing={isPTR} + onRefresh={isUserSearching ? undefined : onPullToRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + /> + } + initialNumToRender={10} + onEndReached={onEndReached} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> + + {hasSession && ( <FAB testID="composeFAB" onPress={onPressCompose} icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} accessibilityRole="button" - accessibilityLabel="New post" + accessibilityLabel={_(msg`New post`)} accessibilityHint="" /> - </View> - ) - }), -) + )} + </View> + ) +} -function SavedFeed({feed}: {feed: FeedSourceModel}) { +function SavedFeed({feedUri}: {feedUri: string}) { const pal = usePalette('default') const {isMobile} = useWebMediaQueries() + const {data: info, error} = useFeedSourceInfoQuery({uri: feedUri}) + + if (!info) + return ( + <SavedFeedLoadingPlaceholder + key={`savedFeedLoadingPlaceholder:${feedUri}`} + /> + ) + return ( <Link - testID={`saved-feed-${feed.displayName}`} - href={feed.href} + testID={`saved-feed-${info.displayName}`} + href={info.route.href} style={[pal.border, styles.savedFeed, isMobile && styles.savedFeedMobile]} hoverStyle={pal.viewLight} - accessibilityLabel={feed.displayName} + accessibilityLabel={info.displayName} accessibilityHint="" asAnchor anchorNoUnderline> - {feed.error ? ( + {error ? ( <View style={{width: 28, flexDirection: 'row', justifyContent: 'center'}}> <FontAwesomeIcon @@ -279,17 +546,17 @@ function SavedFeed({feed}: {feed: FeedSourceModel}) { /> </View> ) : ( - <UserAvatar type="algo" size={28} avatar={feed.avatar} /> + <UserAvatar type="algo" size={28} avatar={info.avatar} /> )} <View style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}> <Text type="lg-medium" style={pal.text} numberOfLines={1}> - {feed.displayName} + {info.displayName} </Text> - {feed.error ? ( + {error ? ( <View style={[styles.offlineSlug, pal.borderDark]}> <Text type="xs" style={pal.textLight}> - Feed offline + <Trans>Feed offline</Trans> </Text> </View> ) : null} diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index c58175327..e8001e973 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,154 +1,178 @@ import React from 'react' -import {useWindowDimensions} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' -import {observer} from 'mobx-react-lite' -import isEqual from 'lodash.isequal' +import {View, ActivityIndicator, StyleSheet} from 'react-native' +import {useFocusEffect, useIsFocused} from '@react-navigation/native' import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types' -import {PostsFeedModel} from 'state/models/feeds/posts' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed' import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState' import {FeedsTabBar} from '../com/pager/FeedsTabBar' -import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' -import {useStores} from 'state/index' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {Pager, RenderTabBarFnProps} from 'view/com/pager/Pager' import {FeedPage} from 'view/com/feeds/FeedPage' import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell' - -export const POLL_FREQ = 30e3 // 30sec +import {usePreferencesQuery} from '#/state/queries/preferences' +import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' +import {emitSoftReset} from '#/state/events' +import {useSession} from '#/state/session' type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> -export const HomeScreen = withAuthRequired( - observer(function HomeScreenImpl({}: Props) { - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() - const pagerRef = React.useRef<PagerRef>(null) - const [selectedPage, setSelectedPage] = React.useState(0) - const [customFeeds, setCustomFeeds] = React.useState<PostsFeedModel[]>([]) - const [requestedCustomFeeds, setRequestedCustomFeeds] = React.useState< - string[] - >([]) +export function HomeScreen(props: Props) { + const {data: preferences} = usePreferencesQuery() + + if (preferences) { + return <HomeScreenReady {...props} preferences={preferences} /> + } else { + return ( + <View style={styles.loading}> + <ActivityIndicator size="large" /> + </View> + ) + } +} + +function HomeScreenReady({ + preferences, +}: Props & { + preferences: UsePreferencesQueryResponse +}) { + const {hasSession} = useSession() + const setMinimalShellMode = useSetMinimalShellMode() + const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() + const [selectedPage, setSelectedPage] = React.useState(0) + const isPageFocused = useIsFocused() - React.useEffect(() => { - const pinned = store.preferences.pinnedFeeds + /** + * Used to ensure that we re-compute `customFeeds` AND force a re-render of + * the pager with the new order of feeds. + */ + const pinnedFeedOrderKey = JSON.stringify(preferences.feeds.pinned) - if (isEqual(pinned, requestedCustomFeeds)) { - // no changes - return + const customFeeds = React.useMemo(() => { + const pinned = preferences.feeds.pinned + const feeds: FeedDescriptor[] = [] + for (const uri of pinned) { + if (uri.includes('app.bsky.feed.generator')) { + feeds.push(`feedgen|${uri}`) + } else if (uri.includes('app.bsky.graph.list')) { + feeds.push(`list|${uri}`) } + } + return feeds + }, [preferences.feeds.pinned]) - const feeds = [] - for (const uri of pinned) { - if (uri.includes('app.bsky.feed.generator')) { - const model = new PostsFeedModel(store, 'custom', {feed: uri}) - feeds.push(model) - } else if (uri.includes('app.bsky.graph.list')) { - const model = new PostsFeedModel(store, 'list', {list: uri}) - feeds.push(model) - } + const homeFeedParams = React.useMemo<FeedParams>(() => { + return { + mergeFeedEnabled: Boolean(preferences.feedViewPrefs.lab_mergeFeedEnabled), + mergeFeedSources: preferences.feeds.saved, + } + }, [preferences]) + + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + setDrawerSwipeDisabled(selectedPage > 0) + return () => { + setDrawerSwipeDisabled(false) } - pagerRef.current?.setPage(0) - setCustomFeeds(feeds) - setRequestedCustomFeeds(pinned) - }, [ - store, - store.preferences.pinnedFeeds, - customFeeds, - setCustomFeeds, - pagerRef, - requestedCustomFeeds, - setRequestedCustomFeeds, - ]) + }, [setDrawerSwipeDisabled, selectedPage, setMinimalShellMode]), + ) - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - setDrawerSwipeDisabled(selectedPage > 0) - return () => { - setDrawerSwipeDisabled(false) - } - }, [setDrawerSwipeDisabled, selectedPage, setMinimalShellMode]), - ) + const onPageSelected = React.useCallback( + (index: number) => { + setMinimalShellMode(false) + setSelectedPage(index) + setDrawerSwipeDisabled(index > 0) + }, + [setDrawerSwipeDisabled, setSelectedPage, setMinimalShellMode], + ) + + const onPressSelected = React.useCallback(() => { + emitSoftReset() + }, []) - const onPageSelected = React.useCallback( - (index: number) => { + const onPageScrollStateChanged = React.useCallback( + (state: 'idle' | 'dragging' | 'settling') => { + if (state === 'dragging') { setMinimalShellMode(false) - setSelectedPage(index) - setDrawerSwipeDisabled(index > 0) - }, - [setDrawerSwipeDisabled, setSelectedPage, setMinimalShellMode], - ) + } + }, + [setMinimalShellMode], + ) - const onPressSelected = React.useCallback(() => { - store.emitScreenSoftReset() - }, [store]) + const renderTabBar = React.useCallback( + (props: RenderTabBarFnProps) => { + return ( + <FeedsTabBar + key="FEEDS_TAB_BAR" + selectedPage={props.selectedPage} + onSelect={props.onSelect} + testID="homeScreenFeedTabs" + onPressSelected={onPressSelected} + /> + ) + }, + [onPressSelected], + ) - const renderTabBar = React.useCallback( - (props: RenderTabBarFnProps) => { + const renderFollowingEmptyState = React.useCallback(() => { + return <FollowingEmptyState /> + }, []) + + const renderCustomFeedEmptyState = React.useCallback(() => { + return <CustomFeedEmptyState /> + }, []) + + return hasSession ? ( + <Pager + key={pinnedFeedOrderKey} + testID="homeScreen" + onPageSelected={onPageSelected} + onPageScrollStateChanged={onPageScrollStateChanged} + renderTabBar={renderTabBar} + tabBarPosition="top"> + <FeedPage + key="1" + testID="followingFeedPage" + isPageFocused={selectedPage === 0 && isPageFocused} + feed={homeFeedParams.mergeFeedEnabled ? 'home' : 'following'} + feedParams={homeFeedParams} + renderEmptyState={renderFollowingEmptyState} + renderEndOfFeed={FollowingEndOfFeed} + /> + {customFeeds.map((f, index) => { return ( - <FeedsTabBar - key="FEEDS_TAB_BAR" - selectedPage={props.selectedPage} - onSelect={props.onSelect} - testID="homeScreenFeedTabs" - onPressSelected={onPressSelected} + <FeedPage + key={f} + testID="customFeedPage" + isPageFocused={selectedPage === 1 + index && isPageFocused} + feed={f} + renderEmptyState={renderCustomFeedEmptyState} /> ) - }, - [onPressSelected], - ) - - const renderFollowingEmptyState = React.useCallback(() => { - return <FollowingEmptyState /> - }, []) - - const renderCustomFeedEmptyState = React.useCallback(() => { - return <CustomFeedEmptyState /> - }, []) - - return ( - <Pager - ref={pagerRef} - testID="homeScreen" - onPageSelected={onPageSelected} - renderTabBar={renderTabBar} - tabBarPosition="top"> - <FeedPage - key="1" - testID="followingFeedPage" - isPageFocused={selectedPage === 0} - feed={store.me.mainFeed} - renderEmptyState={renderFollowingEmptyState} - renderEndOfFeed={FollowingEndOfFeed} - /> - {customFeeds.map((f, index) => { - return ( - <FeedPage - key={f.reactKey} - testID="customFeedPage" - isPageFocused={selectedPage === 1 + index} - feed={f} - renderEmptyState={renderCustomFeedEmptyState} - /> - ) - })} - </Pager> - ) - }), -) - -export function useHeaderOffset() { - const {isDesktop, isTablet} = useWebMediaQueries() - const {fontScale} = useWindowDimensions() - if (isDesktop) { - return 0 - } - if (isTablet) { - return 50 - } - // default text takes 44px, plus 34px of pad - // scale the 44px by the font scale - return 34 + 44 * fontScale + })} + </Pager> + ) : ( + <Pager + testID="homeScreen" + onPageSelected={onPageSelected} + onPageScrollStateChanged={onPageScrollStateChanged} + renderTabBar={renderTabBar} + tabBarPosition="top"> + <FeedPage + testID="customFeedPage" + isPageFocused={isPageFocused} + feed={`feedgen|at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot`} + renderEmptyState={renderCustomFeedEmptyState} + /> + </Pager> + ) } + +const styles = StyleSheet.create({ + loading: { + height: '100%', + alignContent: 'center', + justifyContent: 'center', + paddingBottom: 100, + }, +}) diff --git a/src/view/screens/LanguageSettings.tsx b/src/view/screens/LanguageSettings.tsx index a68a3b5e3..7a2e54dc8 100644 --- a/src/view/screens/LanguageSettings.tsx +++ b/src/view/screens/LanguageSettings.tsx @@ -1,8 +1,6 @@ import React from 'react' import {StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {Text} from '../com/util/text/Text' -import {useStores} from 'state/index' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' @@ -16,20 +14,25 @@ import { } from '@fortawesome/react-native-fontawesome' import {useAnalytics} from 'lib/analytics/analytics' import {useFocusEffect} from '@react-navigation/native' -import {LANGUAGES} from 'lib/../locale/languages' +import {APP_LANGUAGES, LANGUAGES} from 'lib/../locale/languages' import RNPickerSelect, {PickerSelectProps} from 'react-native-picker-select' import {useSetMinimalShellMode} from '#/state/shell' +import {useModalControls} from '#/state/modals' +import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type Props = NativeStackScreenProps<CommonNavigatorParams, 'LanguageSettings'> -export const LanguageSettingsScreen = observer(function LanguageSettingsImpl( - _: Props, -) { +export function LanguageSettingsScreen(_props: Props) { const pal = usePalette('default') - const store = useStores() + const {_} = useLingui() + const langPrefs = useLanguagePrefs() + const setLangPrefs = useLanguagePrefsApi() const {isTabletOrDesktop} = useWebMediaQueries() const {screen, track} = useAnalytics() const setMinimalShellMode = useSetMinimalShellMode() + const {openModal} = useModalControls() useFocusEffect( React.useCallback(() => { @@ -40,26 +43,37 @@ export const LanguageSettingsScreen = observer(function LanguageSettingsImpl( const onPressContentLanguages = React.useCallback(() => { track('Settings:ContentlanguagesButtonClicked') - store.shell.openModal({name: 'content-languages-settings'}) - }, [track, store]) + openModal({name: 'content-languages-settings'}) + }, [track, openModal]) const onChangePrimaryLanguage = React.useCallback( (value: Parameters<PickerSelectProps['onValueChange']>[0]) => { - store.preferences.setPrimaryLanguage(value) + if (langPrefs.primaryLanguage !== value) { + setLangPrefs.setPrimaryLanguage(value) + } }, - [store.preferences], + [langPrefs, setLangPrefs], + ) + + const onChangeAppLanguage = React.useCallback( + (value: Parameters<PickerSelectProps['onValueChange']>[0]) => { + if (langPrefs.appLanguage !== value) { + setLangPrefs.setAppLanguage(value) + } + }, + [langPrefs, setLangPrefs], ) const myLanguages = React.useMemo(() => { return ( - store.preferences.contentLanguages + langPrefs.contentLanguages .map(lang => LANGUAGES.find(l => l.code2 === lang)) .filter(Boolean) // @ts-ignore .map(l => l.name) .join(', ') ) - }, [store.preferences.contentLanguages]) + }, [langPrefs.contentLanguages]) return ( <CenteredView @@ -69,20 +83,114 @@ export const LanguageSettingsScreen = observer(function LanguageSettingsImpl( styles.container, isTabletOrDesktop && styles.desktopContainer, ]}> - <ViewHeader title="Language Settings" showOnDesktop /> + <ViewHeader title={_(msg`Language Settings`)} showOnDesktop /> <View style={{paddingTop: 20, paddingHorizontal: 20}}> + {/* APP LANGUAGE */} + <View style={{paddingBottom: 20}}> + <Text type="title-sm" style={[pal.text, s.pb5]}> + <Trans>App Language</Trans> + </Text> + <Text style={[pal.text, s.pb10]}> + <Trans> + Select your app language for the default text to display in the + app + </Trans> + </Text> + + <View style={{position: 'relative'}}> + <RNPickerSelect + value={langPrefs.appLanguage} + onValueChange={onChangeAppLanguage} + items={APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({ + label: l.name, + value: l.code2, + key: l.code2, + }))} + style={{ + inputAndroid: { + backgroundColor: pal.viewLight.backgroundColor, + color: pal.text.color, + fontSize: 14, + letterSpacing: 0.5, + fontWeight: '500', + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 24, + }, + inputIOS: { + backgroundColor: pal.viewLight.backgroundColor, + color: pal.text.color, + fontSize: 14, + letterSpacing: 0.5, + fontWeight: '500', + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 24, + }, + inputWeb: { + // @ts-ignore web only + cursor: 'pointer', + '-moz-appearance': 'none', + '-webkit-appearance': 'none', + appearance: 'none', + outline: 0, + borderWidth: 0, + backgroundColor: pal.viewLight.backgroundColor, + color: pal.text.color, + fontSize: 14, + letterSpacing: 0.5, + fontWeight: '500', + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 24, + }, + }} + /> + + <View + style={{ + position: 'absolute', + top: 1, + right: 1, + bottom: 1, + width: 40, + backgroundColor: pal.viewLight.backgroundColor, + borderRadius: 24, + pointerEvents: 'none', + alignItems: 'center', + justifyContent: 'center', + }}> + <FontAwesomeIcon + icon="chevron-down" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + </View> + </View> + + <View + style={{ + height: 1, + backgroundColor: pal.border.borderColor, + marginBottom: 20, + }} + /> + + {/* PRIMARY LANGUAGE */} <View style={{paddingBottom: 20}}> <Text type="title-sm" style={[pal.text, s.pb5]}> - Primary Language + <Trans>Primary Language</Trans> </Text> <Text style={[pal.text, s.pb10]}> - Select your preferred language for translations in your feed. + <Trans> + Select your preferred language for translations in your feed. + </Trans> </Text> <View style={{position: 'relative'}}> <RNPickerSelect - value={store.preferences.primaryLanguage} + value={langPrefs.primaryLanguage} onValueChange={onChangePrimaryLanguage} items={LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({ label: l.name, @@ -159,13 +267,16 @@ export const LanguageSettingsScreen = observer(function LanguageSettingsImpl( }} /> + {/* CONTENT LANGUAGES */} <View style={{paddingBottom: 20}}> <Text type="title-sm" style={[pal.text, s.pb5]}> - Content Languages + <Trans>Content Languages</Trans> </Text> <Text style={[pal.text, s.pb10]}> - Select which languages you want your subscribed feeds to include. If - none are selected, all languages will be shown. + <Trans> + Select which languages you want your subscribed feeds to include. + If none are selected, all languages will be shown. + </Trans> </Text> <Button @@ -187,7 +298,7 @@ export const LanguageSettingsScreen = observer(function LanguageSettingsImpl( </View> </CenteredView> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/screens/Lists.tsx b/src/view/screens/Lists.tsx index a64b0ca3b..d28db7c6c 100644 --- a/src/view/screens/Lists.tsx +++ b/src/view/screens/Lists.tsx @@ -3,12 +3,8 @@ import {View} from 'react-native' import {useFocusEffect, useNavigation} from '@react-navigation/native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {AtUri} from '@atproto/api' -import {observer} from 'mobx-react-lite' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {useStores} from 'state/index' -import {ListsListModel} from 'state/models/lists/lists-list' -import {ListsList} from 'view/com/lists/ListsList' +import {MyLists} from '#/view/com/lists/MyLists' import {Text} from 'view/com/util/text/Text' import {Button} from 'view/com/util/forms/Button' import {NavigationProp} from 'lib/routes/types' @@ -17,78 +13,72 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' import {s} from 'lib/styles' import {useSetMinimalShellMode} from '#/state/shell' +import {useModalControls} from '#/state/modals' +import {Trans} from '@lingui/macro' type Props = NativeStackScreenProps<CommonNavigatorParams, 'Lists'> -export const ListsScreen = withAuthRequired( - observer(function ListsScreenImpl({}: Props) { - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {isMobile} = useWebMediaQueries() - const navigation = useNavigation<NavigationProp>() +export function ListsScreen({}: Props) { + const pal = usePalette('default') + const setMinimalShellMode = useSetMinimalShellMode() + const {isMobile} = useWebMediaQueries() + const navigation = useNavigation<NavigationProp>() + const {openModal} = useModalControls() - const listsLists: ListsListModel = React.useMemo( - () => new ListsListModel(store, 'my-curatelists'), - [store], - ) + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - listsLists.refresh() - }, [listsLists, setMinimalShellMode]), - ) + const onPressNewList = React.useCallback(() => { + openModal({ + name: 'create-or-edit-list', + purpose: 'app.bsky.graph.defs#curatelist', + onSave: (uri: string) => { + try { + const urip = new AtUri(uri) + navigation.navigate('ProfileList', { + name: urip.hostname, + rkey: urip.rkey, + }) + } catch {} + }, + }) + }, [openModal, navigation]) - const onPressNewList = React.useCallback(() => { - store.shell.openModal({ - name: 'create-or-edit-list', - purpose: 'app.bsky.graph.defs#curatelist', - onSave: (uri: string) => { - try { - const urip = new AtUri(uri) - navigation.navigate('ProfileList', { - name: urip.hostname, - rkey: urip.rkey, - }) - } catch {} - }, - }) - }, [store, navigation]) - - return ( - <View style={s.hContentRegion} testID="listsScreen"> - <SimpleViewHeader - showBackButton={isMobile} - style={ - !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}] - }> - <View style={{flex: 1}}> - <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}> - User Lists - </Text> - <Text style={pal.textLight}> - Public, shareable lists which can drive feeds. + return ( + <View style={s.hContentRegion} testID="listsScreen"> + <SimpleViewHeader + showBackButton={isMobile} + style={ + !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}] + }> + <View style={{flex: 1}}> + <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}> + <Trans>User Lists</Trans> + </Text> + <Text style={pal.textLight}> + <Trans>Public, shareable lists which can drive feeds.</Trans> + </Text> + </View> + <View> + <Button + testID="newUserListBtn" + type="default" + onPress={onPressNewList} + style={{ + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }}> + <FontAwesomeIcon icon="plus" color={pal.colors.text} /> + <Text type="button" style={pal.text}> + <Trans>New</Trans> </Text> - </View> - <View> - <Button - testID="newUserListBtn" - type="default" - onPress={onPressNewList} - style={{ - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }}> - <FontAwesomeIcon icon="plus" color={pal.colors.text} /> - <Text type="button" style={pal.text}> - New - </Text> - </Button> - </View> - </SimpleViewHeader> - <ListsList listsList={listsLists} style={s.flexGrow1} /> - </View> - ) - }), -) + </Button> + </View> + </SimpleViewHeader> + <MyLists filter="curate" style={s.flexGrow1} /> + </View> + ) +} diff --git a/src/view/screens/Log.tsx b/src/view/screens/Log.tsx index f524279a5..8680b851b 100644 --- a/src/view/screens/Log.tsx +++ b/src/view/screens/Log.tsx @@ -1,7 +1,6 @@ import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {ScrollView} from '../com/util/Views' @@ -11,13 +10,16 @@ import {Text} from '../com/util/text/Text' import {usePalette} from 'lib/hooks/usePalette' import {getEntries} from '#/logger/logDump' import {ago} from 'lib/strings/time' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' import {useSetMinimalShellMode} from '#/state/shell' -export const LogScreen = observer(function Log({}: NativeStackScreenProps< +export function LogScreen({}: NativeStackScreenProps< CommonNavigatorParams, 'Log' >) { const pal = usePalette('default') + const {_} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() const [expanded, setExpanded] = React.useState<string[]>([]) @@ -47,7 +49,7 @@ export const LogScreen = observer(function Log({}: NativeStackScreenProps< <TouchableOpacity style={[styles.entry, pal.border, pal.view]} onPress={toggler(entry.id)} - accessibilityLabel="View debug entry" + accessibilityLabel={_(msg`View debug entry`)} accessibilityHint="Opens additional details for a debug entry"> {entry.level === 'debug' ? ( <FontAwesomeIcon icon="info" /> @@ -85,7 +87,7 @@ export const LogScreen = observer(function Log({}: NativeStackScreenProps< </ScrollView> </View> ) -}) +} const styles = StyleSheet.create({ entry: { diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx index 142f3bce8..4d8d8cad7 100644 --- a/src/view/screens/Moderation.tsx +++ b/src/view/screens/Moderation.tsx @@ -5,10 +5,7 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -import {observer} from 'mobx-react-lite' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {useStores} from 'state/index' import {s} from 'lib/styles' import {CenteredView} from '../com/util/Views' import {ViewHeader} from '../com/util/ViewHeader' @@ -18,101 +15,103 @@ import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics/analytics' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useSetMinimalShellMode} from '#/state/shell' +import {useModalControls} from '#/state/modals' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'> -export const ModerationScreen = withAuthRequired( - observer(function Moderation({}: Props) { - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {screen, track} = useAnalytics() - const {isTabletOrDesktop} = useWebMediaQueries() +export function ModerationScreen({}: Props) { + const pal = usePalette('default') + const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() + const {screen, track} = useAnalytics() + const {isTabletOrDesktop} = useWebMediaQueries() + const {openModal} = useModalControls() - useFocusEffect( - React.useCallback(() => { - screen('Moderation') - setMinimalShellMode(false) - }, [screen, setMinimalShellMode]), - ) + useFocusEffect( + React.useCallback(() => { + screen('Moderation') + setMinimalShellMode(false) + }, [screen, setMinimalShellMode]), + ) - const onPressContentFiltering = React.useCallback(() => { - track('Moderation:ContentfilteringButtonClicked') - store.shell.openModal({name: 'content-filtering-settings'}) - }, [track, store]) + const onPressContentFiltering = React.useCallback(() => { + track('Moderation:ContentfilteringButtonClicked') + openModal({name: 'content-filtering-settings'}) + }, [track, openModal]) - return ( - <CenteredView - style={[ - s.hContentRegion, - pal.border, - isTabletOrDesktop ? styles.desktopContainer : pal.viewLight, - ]} - testID="moderationScreen"> - <ViewHeader title="Moderation" showOnDesktop /> - <View style={styles.spacer} /> - <TouchableOpacity - testID="contentFilteringBtn" - style={[styles.linkCard, pal.view]} - onPress={onPressContentFiltering} - accessibilityRole="tab" - accessibilityHint="Content filtering" - accessibilityLabel=""> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="eye" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - Content filtering - </Text> - </TouchableOpacity> - <Link - testID="moderationlistsBtn" - style={[styles.linkCard, pal.view]} - href="/moderation/modlists"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="users-slash" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - Moderation lists - </Text> - </Link> - <Link - testID="mutedAccountsBtn" - style={[styles.linkCard, pal.view]} - href="/moderation/muted-accounts"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="user-slash" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - Muted accounts - </Text> - </Link> - <Link - testID="blockedAccountsBtn" - style={[styles.linkCard, pal.view]} - href="/moderation/blocked-accounts"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="ban" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - Blocked accounts - </Text> - </Link> - </CenteredView> - ) - }), -) + return ( + <CenteredView + style={[ + s.hContentRegion, + pal.border, + isTabletOrDesktop ? styles.desktopContainer : pal.viewLight, + ]} + testID="moderationScreen"> + <ViewHeader title={_(msg`Moderation`)} showOnDesktop /> + <View style={styles.spacer} /> + <TouchableOpacity + testID="contentFilteringBtn" + style={[styles.linkCard, pal.view]} + onPress={onPressContentFiltering} + accessibilityRole="tab" + accessibilityHint="Content filtering" + accessibilityLabel=""> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="eye" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Content filtering</Trans> + </Text> + </TouchableOpacity> + <Link + testID="moderationlistsBtn" + style={[styles.linkCard, pal.view]} + href="/moderation/modlists"> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="users-slash" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Moderation lists</Trans> + </Text> + </Link> + <Link + testID="mutedAccountsBtn" + style={[styles.linkCard, pal.view]} + href="/moderation/muted-accounts"> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="user-slash" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Muted accounts</Trans> + </Text> + </Link> + <Link + testID="blockedAccountsBtn" + style={[styles.linkCard, pal.view]} + href="/moderation/blocked-accounts"> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="ban" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Blocked accounts</Trans> + </Text> + </Link> + </CenteredView> + ) +} const styles = StyleSheet.create({ desktopContainer: { diff --git a/src/view/screens/ModerationBlockedAccounts.tsx b/src/view/screens/ModerationBlockedAccounts.tsx index 0dc3b706b..8f6e2f729 100644 --- a/src/view/screens/ModerationBlockedAccounts.tsx +++ b/src/view/screens/ModerationBlockedAccounts.tsx @@ -1,4 +1,4 @@ -import React, {useMemo} from 'react' +import React from 'react' import { ActivityIndicator, FlatList, @@ -8,133 +8,165 @@ import { } from 'react-native' import {AppBskyActorDefs as ActorDefs} from '@atproto/api' import {Text} from '../com/util/text/Text' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {observer} from 'mobx-react-lite' import {NativeStackScreenProps} from '@react-navigation/native-stack' import {CommonNavigatorParams} from 'lib/routes/types' -import {BlockedAccountsModel} from 'state/models/lists/blocked-accounts' import {useAnalytics} from 'lib/analytics/analytics' import {useFocusEffect} from '@react-navigation/native' import {ViewHeader} from '../com/util/ViewHeader' import {CenteredView} from 'view/com/util/Views' +import {ErrorScreen} from '../com/util/error/ErrorScreen' import {ProfileCard} from 'view/com/profile/ProfileCard' import {logger} from '#/logger' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMyBlockedAccountsQuery} from '#/state/queries/my-blocked-accounts' +import {cleanError} from '#/lib/strings/errors' type Props = NativeStackScreenProps< CommonNavigatorParams, 'ModerationBlockedAccounts' > -export const ModerationBlockedAccounts = withAuthRequired( - observer(function ModerationBlockedAccountsImpl({}: Props) { - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {isTabletOrDesktop} = useWebMediaQueries() - const {screen} = useAnalytics() - const blockedAccounts = useMemo( - () => new BlockedAccountsModel(store), - [store], - ) +export function ModerationBlockedAccounts({}: Props) { + const pal = usePalette('default') + const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() + const {isTabletOrDesktop} = useWebMediaQueries() + const {screen} = useAnalytics() + const [isPTRing, setIsPTRing] = React.useState(false) + const { + data, + isFetching, + isError, + error, + refetch, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = useMyBlockedAccountsQuery() + const isEmpty = !isFetching && !data?.pages[0]?.blocks.length + const profiles = React.useMemo(() => { + if (data?.pages) { + return data.pages.flatMap(page => page.blocks) + } + return [] + }, [data]) - useFocusEffect( - React.useCallback(() => { - screen('BlockedAccounts') - setMinimalShellMode(false) - blockedAccounts.refresh() - }, [screen, setMinimalShellMode, blockedAccounts]), - ) + useFocusEffect( + React.useCallback(() => { + screen('BlockedAccounts') + setMinimalShellMode(false) + }, [screen, setMinimalShellMode]), + ) - const onRefresh = React.useCallback(() => { - blockedAccounts.refresh() - }, [blockedAccounts]) - const onEndReached = React.useCallback(() => { - blockedAccounts - .loadMore() - .catch(err => - logger.error('Failed to load more blocked accounts', {error: err}), - ) - }, [blockedAccounts]) + const onRefresh = React.useCallback(async () => { + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh my muted accounts', {error: err}) + } + setIsPTRing(false) + }, [refetch, setIsPTRing]) - const renderItem = ({ - item, - index, - }: { - item: ActorDefs.ProfileView - index: number - }) => ( - <ProfileCard - testID={`blockedAccount-${index}`} - key={item.did} - profile={item} - /> - ) - return ( - <CenteredView + const onEndReached = React.useCallback(async () => { + if (isFetching || !hasNextPage || isError) return + + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more of my muted accounts', {error: err}) + } + }, [isFetching, hasNextPage, isError, fetchNextPage]) + + const renderItem = ({ + item, + index, + }: { + item: ActorDefs.ProfileView + index: number + }) => ( + <ProfileCard + testID={`blockedAccount-${index}`} + key={item.did} + profile={item} + /> + ) + return ( + <CenteredView + style={[ + styles.container, + isTabletOrDesktop && styles.containerDesktop, + pal.view, + pal.border, + ]} + testID="blockedAccountsScreen"> + <ViewHeader title={_(msg`Blocked Accounts`)} showOnDesktop /> + <Text + type="sm" style={[ - styles.container, - isTabletOrDesktop && styles.containerDesktop, - pal.view, - pal.border, - ]} - testID="blockedAccountsScreen"> - <ViewHeader title="Blocked Accounts" showOnDesktop /> - <Text - type="sm" - style={[ - styles.description, - pal.text, - isTabletOrDesktop && styles.descriptionDesktop, - ]}> + styles.description, + pal.text, + isTabletOrDesktop && styles.descriptionDesktop, + ]}> + <Trans> Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you. You will not see their content and they will be prevented from seeing yours. - </Text> - {!blockedAccounts.hasContent ? ( - <View style={[pal.border, !isTabletOrDesktop && styles.flex1]}> + </Trans> + </Text> + {isEmpty ? ( + <View style={[pal.border, !isTabletOrDesktop && styles.flex1]}> + {isError ? ( + <ErrorScreen + title="Oops!" + message={cleanError(error)} + onPressTryAgain={refetch} + /> + ) : ( <View style={[styles.empty, pal.viewLight]}> <Text type="lg" style={[pal.text, styles.emptyText]}> - You have not blocked any accounts yet. To block an account, go - to their profile and selected "Block account" from the menu on - their account. + <Trans> + You have not blocked any accounts yet. To block an account, go + to their profile and selected "Block account" from the menu on + their account. + </Trans> </Text> </View> - </View> - ) : ( - <FlatList - style={[!isTabletOrDesktop && styles.flex1]} - data={blockedAccounts.blocks} - keyExtractor={(item: ActorDefs.ProfileView) => item.did} - refreshControl={ - <RefreshControl - refreshing={blockedAccounts.isRefreshing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } - onEndReached={onEndReached} - renderItem={renderItem} - initialNumToRender={15} - // FIXME(dan) - // eslint-disable-next-line react/no-unstable-nested-components - ListFooterComponent={() => ( - <View style={styles.footer}> - {blockedAccounts.isLoading && <ActivityIndicator />} - </View> - )} - extraData={blockedAccounts.isLoading} - // @ts-ignore our .web version only -prf - desktopFixedHeight - /> - )} - </CenteredView> - ) - }), -) + )} + </View> + ) : ( + <FlatList + style={[!isTabletOrDesktop && styles.flex1]} + data={profiles} + keyExtractor={(item: ActorDefs.ProfileView) => item.did} + refreshControl={ + <RefreshControl + refreshing={isPTRing} + onRefresh={onRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + /> + } + onEndReached={onEndReached} + renderItem={renderItem} + initialNumToRender={15} + // FIXME(dan) + + ListFooterComponent={() => ( + <View style={styles.footer}> + {(isFetching || isFetchingNextPage) && <ActivityIndicator />} + </View> + )} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> + )} + </CenteredView> + ) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/screens/ModerationModlists.tsx b/src/view/screens/ModerationModlists.tsx index 8794c6d17..145b35a42 100644 --- a/src/view/screens/ModerationModlists.tsx +++ b/src/view/screens/ModerationModlists.tsx @@ -3,12 +3,8 @@ import {View} from 'react-native' import {useFocusEffect, useNavigation} from '@react-navigation/native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {AtUri} from '@atproto/api' -import {observer} from 'mobx-react-lite' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {useStores} from 'state/index' -import {ListsListModel} from 'state/models/lists/lists-list' -import {ListsList} from 'view/com/lists/ListsList' +import {MyLists} from '#/view/com/lists/MyLists' import {Text} from 'view/com/util/text/Text' import {Button} from 'view/com/util/forms/Button' import {NavigationProp} from 'lib/routes/types' @@ -17,78 +13,71 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' import {s} from 'lib/styles' import {useSetMinimalShellMode} from '#/state/shell' +import {useModalControls} from '#/state/modals' type Props = NativeStackScreenProps<CommonNavigatorParams, 'ModerationModlists'> -export const ModerationModlistsScreen = withAuthRequired( - observer(function ModerationModlistsScreenImpl({}: Props) { - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {isMobile} = useWebMediaQueries() - const navigation = useNavigation<NavigationProp>() +export function ModerationModlistsScreen({}: Props) { + const pal = usePalette('default') + const setMinimalShellMode = useSetMinimalShellMode() + const {isMobile} = useWebMediaQueries() + const navigation = useNavigation<NavigationProp>() + const {openModal} = useModalControls() - const mutelists: ListsListModel = React.useMemo( - () => new ListsListModel(store, 'my-modlists'), - [store], - ) + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - mutelists.refresh() - }, [mutelists, setMinimalShellMode]), - ) + const onPressNewList = React.useCallback(() => { + openModal({ + name: 'create-or-edit-list', + purpose: 'app.bsky.graph.defs#modlist', + onSave: (uri: string) => { + try { + const urip = new AtUri(uri) + navigation.navigate('ProfileList', { + name: urip.hostname, + rkey: urip.rkey, + }) + } catch {} + }, + }) + }, [openModal, navigation]) - const onPressNewList = React.useCallback(() => { - store.shell.openModal({ - name: 'create-or-edit-list', - purpose: 'app.bsky.graph.defs#modlist', - onSave: (uri: string) => { - try { - const urip = new AtUri(uri) - navigation.navigate('ProfileList', { - name: urip.hostname, - rkey: urip.rkey, - }) - } catch {} - }, - }) - }, [store, navigation]) - - return ( - <View style={s.hContentRegion} testID="moderationModlistsScreen"> - <SimpleViewHeader - showBackButton={isMobile} - style={ - !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}] - }> - <View style={{flex: 1}}> - <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}> - Moderation Lists - </Text> - <Text style={pal.textLight}> - Public, shareable lists of users to mute or block in bulk. + return ( + <View style={s.hContentRegion} testID="moderationModlistsScreen"> + <SimpleViewHeader + showBackButton={isMobile} + style={ + !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}] + }> + <View style={{flex: 1}}> + <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}> + Moderation Lists + </Text> + <Text style={pal.textLight}> + Public, shareable lists of users to mute or block in bulk. + </Text> + </View> + <View> + <Button + testID="newModListBtn" + type="default" + onPress={onPressNewList} + style={{ + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }}> + <FontAwesomeIcon icon="plus" color={pal.colors.text} /> + <Text type="button" style={pal.text}> + New </Text> - </View> - <View> - <Button - testID="newModListBtn" - type="default" - onPress={onPressNewList} - style={{ - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }}> - <FontAwesomeIcon icon="plus" color={pal.colors.text} /> - <Text type="button" style={pal.text}> - New - </Text> - </Button> - </View> - </SimpleViewHeader> - <ListsList listsList={mutelists} style={s.flexGrow1} /> - </View> - ) - }), -) + </Button> + </View> + </SimpleViewHeader> + <MyLists filter="mod" style={s.flexGrow1} /> + </View> + ) +} diff --git a/src/view/screens/ModerationMutedAccounts.tsx b/src/view/screens/ModerationMutedAccounts.tsx index 2fa27ee54..41aee9f2f 100644 --- a/src/view/screens/ModerationMutedAccounts.tsx +++ b/src/view/screens/ModerationMutedAccounts.tsx @@ -1,4 +1,4 @@ -import React, {useMemo} from 'react' +import React from 'react' import { ActivityIndicator, FlatList, @@ -8,129 +8,164 @@ import { } from 'react-native' import {AppBskyActorDefs as ActorDefs} from '@atproto/api' import {Text} from '../com/util/text/Text' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {observer} from 'mobx-react-lite' import {NativeStackScreenProps} from '@react-navigation/native-stack' import {CommonNavigatorParams} from 'lib/routes/types' -import {MutedAccountsModel} from 'state/models/lists/muted-accounts' import {useAnalytics} from 'lib/analytics/analytics' import {useFocusEffect} from '@react-navigation/native' import {ViewHeader} from '../com/util/ViewHeader' import {CenteredView} from 'view/com/util/Views' +import {ErrorScreen} from '../com/util/error/ErrorScreen' import {ProfileCard} from 'view/com/profile/ProfileCard' import {logger} from '#/logger' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMyMutedAccountsQuery} from '#/state/queries/my-muted-accounts' +import {cleanError} from '#/lib/strings/errors' type Props = NativeStackScreenProps< CommonNavigatorParams, 'ModerationMutedAccounts' > -export const ModerationMutedAccounts = withAuthRequired( - observer(function ModerationMutedAccountsImpl({}: Props) { - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {isTabletOrDesktop} = useWebMediaQueries() - const {screen} = useAnalytics() - const mutedAccounts = useMemo(() => new MutedAccountsModel(store), [store]) +export function ModerationMutedAccounts({}: Props) { + const pal = usePalette('default') + const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() + const {isTabletOrDesktop} = useWebMediaQueries() + const {screen} = useAnalytics() + const [isPTRing, setIsPTRing] = React.useState(false) + const { + data, + isFetching, + isError, + error, + refetch, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = useMyMutedAccountsQuery() + const isEmpty = !isFetching && !data?.pages[0]?.mutes.length + const profiles = React.useMemo(() => { + if (data?.pages) { + return data.pages.flatMap(page => page.mutes) + } + return [] + }, [data]) - useFocusEffect( - React.useCallback(() => { - screen('MutedAccounts') - setMinimalShellMode(false) - mutedAccounts.refresh() - }, [screen, setMinimalShellMode, mutedAccounts]), - ) + useFocusEffect( + React.useCallback(() => { + screen('MutedAccounts') + setMinimalShellMode(false) + }, [screen, setMinimalShellMode]), + ) - const onRefresh = React.useCallback(() => { - mutedAccounts.refresh() - }, [mutedAccounts]) - const onEndReached = React.useCallback(() => { - mutedAccounts - .loadMore() - .catch(err => - logger.error('Failed to load more muted accounts', {error: err}), - ) - }, [mutedAccounts]) + const onRefresh = React.useCallback(async () => { + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh my muted accounts', {error: err}) + } + setIsPTRing(false) + }, [refetch, setIsPTRing]) - const renderItem = ({ - item, - index, - }: { - item: ActorDefs.ProfileView - index: number - }) => ( - <ProfileCard - testID={`mutedAccount-${index}`} - key={item.did} - profile={item} - /> - ) - return ( - <CenteredView + const onEndReached = React.useCallback(async () => { + if (isFetching || !hasNextPage || isError) return + + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more of my muted accounts', {error: err}) + } + }, [isFetching, hasNextPage, isError, fetchNextPage]) + + const renderItem = ({ + item, + index, + }: { + item: ActorDefs.ProfileView + index: number + }) => ( + <ProfileCard + testID={`mutedAccount-${index}`} + key={item.did} + profile={item} + /> + ) + return ( + <CenteredView + style={[ + styles.container, + isTabletOrDesktop && styles.containerDesktop, + pal.view, + pal.border, + ]} + testID="mutedAccountsScreen"> + <ViewHeader title={_(msg`Muted Accounts`)} showOnDesktop /> + <Text + type="sm" style={[ - styles.container, - isTabletOrDesktop && styles.containerDesktop, - pal.view, - pal.border, - ]} - testID="mutedAccountsScreen"> - <ViewHeader title="Muted Accounts" showOnDesktop /> - <Text - type="sm" - style={[ - styles.description, - pal.text, - isTabletOrDesktop && styles.descriptionDesktop, - ]}> + styles.description, + pal.text, + isTabletOrDesktop && styles.descriptionDesktop, + ]}> + <Trans> Muted accounts have their posts removed from your feed and from your notifications. Mutes are completely private. - </Text> - {!mutedAccounts.hasContent ? ( - <View style={[pal.border, !isTabletOrDesktop && styles.flex1]}> + </Trans> + </Text> + {isEmpty ? ( + <View style={[pal.border, !isTabletOrDesktop && styles.flex1]}> + {isError ? ( + <ErrorScreen + title="Oops!" + message={cleanError(error)} + onPressTryAgain={refetch} + /> + ) : ( <View style={[styles.empty, pal.viewLight]}> <Text type="lg" style={[pal.text, styles.emptyText]}> - You have not muted any accounts yet. To mute an account, go to - their profile and selected "Mute account" from the menu on their - account. + <Trans> + You have not muted any accounts yet. To mute an account, go to + their profile and selected "Mute account" from the menu on + their account. + </Trans> </Text> </View> - </View> - ) : ( - <FlatList - style={[!isTabletOrDesktop && styles.flex1]} - data={mutedAccounts.mutes} - keyExtractor={item => item.did} - refreshControl={ - <RefreshControl - refreshing={mutedAccounts.isRefreshing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } - onEndReached={onEndReached} - renderItem={renderItem} - initialNumToRender={15} - // FIXME(dan) - // eslint-disable-next-line react/no-unstable-nested-components - ListFooterComponent={() => ( - <View style={styles.footer}> - {mutedAccounts.isLoading && <ActivityIndicator />} - </View> - )} - extraData={mutedAccounts.isLoading} - // @ts-ignore our .web version only -prf - desktopFixedHeight - /> - )} - </CenteredView> - ) - }), -) + )} + </View> + ) : ( + <FlatList + style={[!isTabletOrDesktop && styles.flex1]} + data={profiles} + keyExtractor={item => item.did} + refreshControl={ + <RefreshControl + refreshing={isPTRing} + onRefresh={onRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + /> + } + onEndReached={onEndReached} + renderItem={renderItem} + initialNumToRender={15} + // FIXME(dan) + + ListFooterComponent={() => ( + <View style={styles.footer}> + {(isFetching || isFetchingNextPage) && <ActivityIndicator />} + </View> + )} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> + )} + </CenteredView> + ) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/screens/NotFound.tsx b/src/view/screens/NotFound.tsx index c2125756c..2508a9ed2 100644 --- a/src/view/screens/NotFound.tsx +++ b/src/view/screens/NotFound.tsx @@ -12,9 +12,12 @@ import {NavigationProp} from 'lib/routes/types' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' export const NotFoundScreen = () => { const pal = usePalette('default') + const {_} = useLingui() const navigation = useNavigation<NavigationProp>() const setMinimalShellMode = useSetMinimalShellMode() @@ -36,13 +39,15 @@ export const NotFoundScreen = () => { return ( <View testID="notFoundView" style={pal.view}> - <ViewHeader title="Page not found" /> + <ViewHeader title={_(msg`Page not found`)} /> <View style={styles.container}> <Text type="title-2xl" style={[pal.text, s.mb10]}> - Page not found + <Trans>Page not found</Trans> </Text> <Text type="md" style={[pal.text, s.mb10]}> - We're sorry! We can't find the page you were looking for. + <Trans> + We're sorry! We can't find the page you were looking for. + </Trans> </Text> <Button type="primary" diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index cd482bd1c..3ce1128a6 100644 --- a/src/view/screens/Notifications.tsx +++ b/src/view/screens/Notifications.tsx @@ -1,166 +1,135 @@ import React from 'react' import {FlatList, View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' -import {observer} from 'mobx-react-lite' +import {useQueryClient} from '@tanstack/react-query' import { NativeStackScreenProps, NotificationsTabNavigatorParams, } from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from '../com/util/ViewHeader' import {Feed} from '../com/notifications/Feed' import {TextLink} from 'view/com/util/Link' -import {InvitedUsers} from '../com/notifications/InvitedUsers' import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' -import {useStores} from 'state/index' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' -import {useTabFocusEffect} from 'lib/hooks/useTabFocusEffect' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {s, colors} from 'lib/styles' import {useAnalytics} from 'lib/analytics/analytics' -import {isWeb} from 'platform/detection' import {logger} from '#/logger' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import { + useUnreadNotifications, + useUnreadNotificationsApi, +} from '#/state/queries/notifications/unread' +import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed' +import {listenSoftReset, emitSoftReset} from '#/state/events' +import {truncateAndInvalidate} from '#/state/queries/util' type Props = NativeStackScreenProps< NotificationsTabNavigatorParams, 'Notifications' > -export const NotificationsScreen = withAuthRequired( - observer(function NotificationsScreenImpl({}: Props) { - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll() - const scrollElRef = React.useRef<FlatList>(null) - const {screen} = useAnalytics() - const pal = usePalette('default') - const {isDesktop} = useWebMediaQueries() - - const hasNew = - store.me.notifications.hasNewLatest && - !store.me.notifications.isRefreshing - - // event handlers - // = - const onPressTryAgain = React.useCallback(() => { - store.me.notifications.refresh() - }, [store]) - - const scrollToTop = React.useCallback(() => { - scrollElRef.current?.scrollToOffset({offset: 0}) - resetMainScroll() - }, [scrollElRef, resetMainScroll]) +export function NotificationsScreen({}: Props) { + const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() + const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll() + const scrollElRef = React.useRef<FlatList>(null) + const {screen} = useAnalytics() + const pal = usePalette('default') + const {isDesktop} = useWebMediaQueries() + const queryClient = useQueryClient() + const unreadNotifs = useUnreadNotifications() + const unreadApi = useUnreadNotificationsApi() + const hasNew = !!unreadNotifs - const onPressLoadLatest = React.useCallback(() => { - scrollToTop() - store.me.notifications.refresh() - }, [store, scrollToTop]) + // event handlers + // = + const scrollToTop = React.useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: 0}) + resetMainScroll() + }, [scrollElRef, resetMainScroll]) - // on-visible setup - // = - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - logger.debug('NotificationsScreen: Updating feed') - const softResetSub = store.onScreenSoftReset(onPressLoadLatest) - store.me.notifications.update() - screen('Notifications') + const onPressLoadLatest = React.useCallback(() => { + scrollToTop() + if (hasNew) { + // render what we have now + truncateAndInvalidate(queryClient, NOTIFS_RQKEY()) + } else { + // check with the server + unreadApi.checkUnread({invalidate: true}) + } + }, [scrollToTop, queryClient, unreadApi, hasNew]) - return () => { - softResetSub.remove() - store.me.notifications.markAllRead() - } - }, [store, screen, onPressLoadLatest, setMinimalShellMode]), - ) + // on-visible setup + // = + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + logger.debug('NotificationsScreen: Updating feed') + screen('Notifications') + return listenSoftReset(onPressLoadLatest) + }, [screen, onPressLoadLatest, setMinimalShellMode]), + ) - useTabFocusEffect( - 'Notifications', - React.useCallback( - isInside => { - // on mobile: - // fires with `isInside=true` when the user navigates to the root tab - // but not when the user goes back to the screen by pressing back - // on web: - // essentially equivalent to useFocusEffect because we dont used tabbed - // navigation - if (isInside) { - if (isWeb) { - store.me.notifications.syncQueue() - } else { - if (store.me.notifications.unreadCount > 0) { - store.me.notifications.refresh() - } else { - store.me.notifications.syncQueue() - } + const ListHeaderComponent = React.useCallback(() => { + if (isDesktop) { + return ( + <View + style={[ + pal.view, + { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 18, + paddingVertical: 12, + }, + ]}> + <TextLink + type="title-lg" + href="/notifications" + style={[pal.text, {fontWeight: 'bold'}]} + text={ + <> + <Trans>Notifications</Trans>{' '} + {hasNew && ( + <View + style={{ + top: -8, + backgroundColor: colors.blue3, + width: 8, + height: 8, + borderRadius: 4, + }} + /> + )} + </> } - } - }, - [store], - ), - ) - - const ListHeaderComponent = React.useCallback(() => { - if (isDesktop) { - return ( - <View - style={[ - pal.view, - { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 18, - paddingVertical: 12, - }, - ]}> - <TextLink - type="title-lg" - href="/notifications" - style={[pal.text, {fontWeight: 'bold'}]} - text={ - <> - Notifications{' '} - {hasNew && ( - <View - style={{ - top: -8, - backgroundColor: colors.blue3, - width: 8, - height: 8, - borderRadius: 4, - }} - /> - )} - </> - } - onPress={() => store.emitScreenSoftReset()} - /> - </View> - ) - } - return <></> - }, [isDesktop, pal, store, hasNew]) + onPress={emitSoftReset} + /> + </View> + ) + } + return <></> + }, [isDesktop, pal, hasNew]) - return ( - <View testID="notificationsScreen" style={s.hContentRegion}> - <ViewHeader title="Notifications" canGoBack={false} /> - <InvitedUsers /> - <Feed - view={store.me.notifications} - onPressTryAgain={onPressTryAgain} - onScroll={onMainScroll} - scrollElRef={scrollElRef} - ListHeaderComponent={ListHeaderComponent} + return ( + <View testID="notificationsScreen" style={s.hContentRegion}> + <ViewHeader title={_(msg`Notifications`)} canGoBack={false} /> + <Feed + onScroll={onMainScroll} + scrollElRef={scrollElRef} + ListHeaderComponent={ListHeaderComponent} + /> + {(isScrolledDown || hasNew) && ( + <LoadLatestBtn + onPress={onPressLoadLatest} + label={_(msg`Load new notifications`)} + showIndicator={hasNew} /> - {(isScrolledDown || hasNew) && ( - <LoadLatestBtn - onPress={onPressLoadLatest} - label="Load new notifications" - showIndicator={hasNew} - /> - )} - </View> - ) - }), -) + )} + </View> + ) +} diff --git a/src/view/screens/PostLikedBy.tsx b/src/view/screens/PostLikedBy.tsx index 2f45908b3..7cbb81102 100644 --- a/src/view/screens/PostLikedBy.tsx +++ b/src/view/screens/PostLikedBy.tsx @@ -2,17 +2,19 @@ import React from 'react' import {View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from '../com/util/ViewHeader' import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy' import {makeRecordUri} from 'lib/strings/url-helpers' import {useSetMinimalShellMode} from '#/state/shell' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostLikedBy'> -export const PostLikedByScreen = withAuthRequired(({route}: Props) => { +export const PostLikedByScreen = ({route}: Props) => { const setMinimalShellMode = useSetMinimalShellMode() const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) + const {_} = useLingui() useFocusEffect( React.useCallback(() => { @@ -22,8 +24,8 @@ export const PostLikedByScreen = withAuthRequired(({route}: Props) => { return ( <View> - <ViewHeader title="Liked by" /> + <ViewHeader title={_(msg`Liked by`)} /> <PostLikedByComponent uri={uri} /> </View> ) -}) +} diff --git a/src/view/screens/PostRepostedBy.tsx b/src/view/screens/PostRepostedBy.tsx index abe03467a..de95f33bf 100644 --- a/src/view/screens/PostRepostedBy.tsx +++ b/src/view/screens/PostRepostedBy.tsx @@ -1,18 +1,20 @@ import React from 'react' import {View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {ViewHeader} from '../com/util/ViewHeader' import {PostRepostedBy as PostRepostedByComponent} from '../com/post-thread/PostRepostedBy' import {makeRecordUri} from 'lib/strings/url-helpers' import {useSetMinimalShellMode} from '#/state/shell' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostRepostedBy'> -export const PostRepostedByScreen = withAuthRequired(({route}: Props) => { +export const PostRepostedByScreen = ({route}: Props) => { const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) const setMinimalShellMode = useSetMinimalShellMode() + const {_} = useLingui() useFocusEffect( React.useCallback(() => { @@ -22,8 +24,8 @@ export const PostRepostedByScreen = withAuthRequired(({route}: Props) => { return ( <View> - <ViewHeader title="Reposted by" /> + <ViewHeader title={_(msg`Reposted by`)} /> <PostRepostedByComponent uri={uri} /> </View> ) -}) +} diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index 0bdd06269..4b1f51748 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -1,104 +1,107 @@ -import React, {useMemo} from 'react' -import {InteractionManager, StyleSheet, View} from 'react-native' +import React from 'react' +import {StyleSheet, View} from 'react-native' +import Animated from 'react-native-reanimated' import {useFocusEffect} from '@react-navigation/native' -import {observer} from 'mobx-react-lite' +import {useQueryClient} from '@tanstack/react-query' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {makeRecordUri} from 'lib/strings/url-helpers' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from '../com/util/ViewHeader' import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread' import {ComposePrompt} from 'view/com/composer/Prompt' -import {PostThreadModel} from 'state/models/content/post-thread' -import {useStores} from 'state/index' import {s} from 'lib/styles' import {useSafeAreaInsets} from 'react-native-safe-area-context' +import { + RQKEY as POST_THREAD_RQKEY, + ThreadNode, +} from '#/state/queries/post-thread' import {clamp} from 'lodash' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {logger} from '#/logger' -import {useMinimalShellMode, useSetMinimalShellMode} from '#/state/shell' - -const SHELL_FOOTER_HEIGHT = 44 +import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' +import {useSetMinimalShellMode} from '#/state/shell' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import {ErrorMessage} from '../com/util/error/ErrorMessage' +import {CenteredView} from '../com/util/Views' +import {useComposerControls} from '#/state/shell/composer' type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'> -export const PostThreadScreen = withAuthRequired( - observer(function PostThreadScreenImpl({route}: Props) { - const store = useStores() - const minimalShellMode = useMinimalShellMode() - const setMinimalShellMode = useSetMinimalShellMode() - const safeAreaInsets = useSafeAreaInsets() - const {name, rkey} = route.params - const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) - const view = useMemo<PostThreadModel>( - () => new PostThreadModel(store, {uri}), - [store, uri], - ) - const {isMobile} = useWebMediaQueries() - - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - const threadCleanup = view.registerListeners() +export function PostThreadScreen({route}: Props) { + const queryClient = useQueryClient() + const {_} = useLingui() + const {fabMinimalShellTransform} = useMinimalShellMode() + const setMinimalShellMode = useSetMinimalShellMode() + const {openComposer} = useComposerControls() + const safeAreaInsets = useSafeAreaInsets() + const {name, rkey} = route.params + const {isMobile} = useWebMediaQueries() + const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) + const {data: resolvedUri, error: uriError} = useResolveUriQuery(uri) - InteractionManager.runAfterInteractions(() => { - if (!view.hasLoaded && !view.isLoading) { - view.setup().catch(err => { - logger.error('Failed to fetch thread', {error: err}) - }) - } - }) + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) - return () => { - threadCleanup() - } - }, [view, setMinimalShellMode]), + const onPressReply = React.useCallback(() => { + if (!resolvedUri) { + return + } + const thread = queryClient.getQueryData<ThreadNode>( + POST_THREAD_RQKEY(resolvedUri.uri), ) - - const onPressReply = React.useCallback(() => { - if (!view.thread) { - return - } - store.shell.openComposer({ - replyTo: { - uri: view.thread.post.uri, - cid: view.thread.post.cid, - text: view.thread.postRecord?.text as string, - author: { - handle: view.thread.post.author.handle, - displayName: view.thread.post.author.displayName, - avatar: view.thread.post.author.avatar, - }, + if (thread?.type !== 'post') { + return + } + openComposer({ + replyTo: { + uri: thread.post.uri, + cid: thread.post.cid, + text: thread.record.text, + author: { + handle: thread.post.author.handle, + displayName: thread.post.author.displayName, + avatar: thread.post.author.avatar, }, - onPost: () => view.refresh(), - }) - }, [view, store]) + }, + onPost: () => + queryClient.invalidateQueries({ + queryKey: POST_THREAD_RQKEY(resolvedUri.uri || ''), + }), + }) + }, [openComposer, queryClient, resolvedUri]) - return ( - <View style={s.hContentRegion}> - {isMobile && <ViewHeader title="Post" />} - <View style={s.flex1}> + return ( + <View style={s.hContentRegion}> + {isMobile && <ViewHeader title={_(msg`Post`)} />} + <View style={s.flex1}> + {uriError ? ( + <CenteredView> + <ErrorMessage message={String(uriError)} /> + </CenteredView> + ) : ( <PostThreadComponent - uri={uri} - view={view} + uri={resolvedUri?.uri} onPressReply={onPressReply} - treeView={!!store.preferences.thread.lab_treeViewEnabled} /> - </View> - {isMobile && !minimalShellMode && ( - <View - style={[ - styles.prompt, - { - bottom: - SHELL_FOOTER_HEIGHT + clamp(safeAreaInsets.bottom, 15, 30), - }, - ]}> - <ComposePrompt onPressCompose={onPressReply} /> - </View> )} </View> - ) - }), -) + {isMobile && ( + <Animated.View + style={[ + styles.prompt, + fabMinimalShellTransform, + { + bottom: clamp(safeAreaInsets.bottom, 15, 30), + }, + ]}> + <ComposePrompt onPressCompose={onPressReply} /> + </Animated.View> + )} + </View> + ) +} const styles = StyleSheet.create({ prompt: { diff --git a/src/view/screens/PreferencesHomeFeed.tsx b/src/view/screens/PreferencesHomeFeed.tsx index 21c15931f..fe17be5e8 100644 --- a/src/view/screens/PreferencesHomeFeed.tsx +++ b/src/view/screens/PreferencesHomeFeed.tsx @@ -1,10 +1,8 @@ import React, {useState} from 'react' import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Slider} from '@miblanchard/react-native-slider' import {Text} from '../com/util/text/Text' -import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' @@ -14,21 +12,33 @@ import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {ViewHeader} from 'view/com/util/ViewHeader' import {CenteredView} from 'view/com/util/Views' import debounce from 'lodash.debounce' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import { + usePreferencesQuery, + useSetFeedViewPreferencesMutation, +} from '#/state/queries/preferences' -function RepliesThresholdInput({enabled}: {enabled: boolean}) { - const store = useStores() +function RepliesThresholdInput({ + enabled, + initialValue, +}: { + enabled: boolean + initialValue: number +}) { const pal = usePalette('default') - const [value, setValue] = useState( - store.preferences.homeFeed.hideRepliesByLikeCount, - ) + const [value, setValue] = useState(initialValue) + const {mutate: setFeedViewPref} = useSetFeedViewPreferencesMutation() const save = React.useMemo( () => debounce( threshold => - store.preferences.setHomeFeedHideRepliesByLikeCount(threshold), + setFeedViewPref({ + hideRepliesByLikeCount: threshold, + }), 500, ), // debouce for 500ms - [store], + [setFeedViewPref], ) return ( @@ -61,12 +71,17 @@ type Props = NativeStackScreenProps< CommonNavigatorParams, 'PreferencesHomeFeed' > -export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ - navigation, -}: Props) { +export function PreferencesHomeFeed({navigation}: Props) { const pal = usePalette('default') - const store = useStores() + const {_} = useLingui() const {isTabletOrDesktop} = useWebMediaQueries() + const {data: preferences} = usePreferencesQuery() + const {mutate: setFeedViewPref, variables} = + useSetFeedViewPreferencesMutation() + + const showReplies = !( + variables?.hideReplies ?? preferences?.feedViewPrefs?.hideReplies + ) return ( <CenteredView @@ -77,14 +92,14 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ styles.container, isTabletOrDesktop && styles.desktopContainer, ]}> - <ViewHeader title="Home Feed Preferences" showOnDesktop /> + <ViewHeader title={_(msg`Home Feed Preferences`)} showOnDesktop /> <View style={[ styles.titleSection, isTabletOrDesktop && {paddingTop: 20, paddingBottom: 20}, ]}> <Text type="xl" style={[pal.textLight, styles.description]}> - Fine-tune the content you see on your home screen. + <Trans>Fine-tune the content you see on your home screen.</Trans> </Text> </View> @@ -92,98 +107,175 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ <View style={styles.cardsContainer}> <View style={[pal.viewLight, styles.card]}> <Text type="title-sm" style={[pal.text, s.pb5]}> - Show Replies + <Trans>Show Replies</Trans> </Text> <Text style={[pal.text, s.pb10]}> - Set this setting to "No" to hide all replies from your feed. + <Trans> + Set this setting to "No" to hide all replies from your feed. + </Trans> </Text> <ToggleButton testID="toggleRepliesBtn" type="default-light" - label={store.preferences.homeFeed.hideReplies ? 'No' : 'Yes'} - isSelected={!store.preferences.homeFeed.hideReplies} - onPress={store.preferences.toggleHomeFeedHideReplies} + label={showReplies ? 'Yes' : 'No'} + isSelected={showReplies} + onPress={() => + setFeedViewPref({ + hideReplies: !( + variables?.hideReplies ?? + preferences?.feedViewPrefs?.hideReplies + ), + }) + } /> </View> <View - style={[ - pal.viewLight, - styles.card, - store.preferences.homeFeed.hideReplies && styles.dimmed, - ]}> + style={[pal.viewLight, styles.card, !showReplies && styles.dimmed]}> <Text type="title-sm" style={[pal.text, s.pb5]}> - Reply Filters + <Trans>Reply Filters</Trans> </Text> <Text style={[pal.text, s.pb10]}> - Enable this setting to only see replies between people you follow. + <Trans> + Enable this setting to only see replies between people you + follow. + </Trans> </Text> <ToggleButton type="default-light" - label="Followed users only" - isSelected={store.preferences.homeFeed.hideRepliesByUnfollowed} + label={_(msg`Followed users only`)} + isSelected={Boolean( + variables?.hideRepliesByUnfollowed ?? + preferences?.feedViewPrefs?.hideRepliesByUnfollowed, + )} onPress={ - !store.preferences.homeFeed.hideReplies - ? store.preferences.toggleHomeFeedHideRepliesByUnfollowed + showReplies + ? () => + setFeedViewPref({ + hideRepliesByUnfollowed: !( + variables?.hideRepliesByUnfollowed ?? + preferences?.feedViewPrefs?.hideRepliesByUnfollowed + ), + }) : undefined } style={[s.mb10]} /> <Text style={[pal.text]}> - Adjust the number of likes a reply must have to be shown in your - feed. + <Trans> + Adjust the number of likes a reply must have to be shown in your + feed. + </Trans> </Text> - <RepliesThresholdInput - enabled={!store.preferences.homeFeed.hideReplies} - /> + {preferences && ( + <RepliesThresholdInput + enabled={showReplies} + initialValue={preferences.feedViewPrefs.hideRepliesByLikeCount} + /> + )} </View> <View style={[pal.viewLight, styles.card]}> <Text type="title-sm" style={[pal.text, s.pb5]}> - Show Reposts + <Trans>Show Reposts</Trans> </Text> <Text style={[pal.text, s.pb10]}> - Set this setting to "No" to hide all reposts from your feed. + <Trans> + Set this setting to "No" to hide all reposts from your feed. + </Trans> </Text> <ToggleButton type="default-light" - label={store.preferences.homeFeed.hideReposts ? 'No' : 'Yes'} - isSelected={!store.preferences.homeFeed.hideReposts} - onPress={store.preferences.toggleHomeFeedHideReposts} + label={ + variables?.hideReposts ?? + preferences?.feedViewPrefs?.hideReposts + ? _(msg`No`) + : _(msg`Yes`) + } + isSelected={ + !( + variables?.hideReposts ?? + preferences?.feedViewPrefs?.hideReposts + ) + } + onPress={() => + setFeedViewPref({ + hideReposts: !( + variables?.hideReposts ?? + preferences?.feedViewPrefs?.hideReposts + ), + }) + } /> </View> <View style={[pal.viewLight, styles.card]}> <Text type="title-sm" style={[pal.text, s.pb5]}> - Show Quote Posts + <Trans>Show Quote Posts</Trans> </Text> <Text style={[pal.text, s.pb10]}> - Set this setting to "No" to hide all quote posts from your feed. - Reposts will still be visible. + <Trans> + Set this setting to "No" to hide all quote posts from your feed. + Reposts will still be visible. + </Trans> </Text> <ToggleButton type="default-light" - label={store.preferences.homeFeed.hideQuotePosts ? 'No' : 'Yes'} - isSelected={!store.preferences.homeFeed.hideQuotePosts} - onPress={store.preferences.toggleHomeFeedHideQuotePosts} + label={ + variables?.hideQuotePosts ?? + preferences?.feedViewPrefs?.hideQuotePosts + ? _(msg`No`) + : _(msg`Yes`) + } + isSelected={ + !( + variables?.hideQuotePosts ?? + preferences?.feedViewPrefs?.hideQuotePosts + ) + } + onPress={() => + setFeedViewPref({ + hideQuotePosts: !( + variables?.hideQuotePosts ?? + preferences?.feedViewPrefs?.hideQuotePosts + ), + }) + } /> </View> <View style={[pal.viewLight, styles.card]}> <Text type="title-sm" style={[pal.text, s.pb5]}> - <FontAwesomeIcon icon="flask" color={pal.colors.text} /> Show - Posts from My Feeds + <FontAwesomeIcon icon="flask" color={pal.colors.text} /> + <Trans>Show Posts from My Feeds</Trans> </Text> <Text style={[pal.text, s.pb10]}> - Set this setting to "Yes" to show samples of your saved feeds in - your following feed. This is an experimental feature. + <Trans> + Set this setting to "Yes" to show samples of your saved feeds in + your following feed. This is an experimental feature. + </Trans> </Text> <ToggleButton type="default-light" label={ - store.preferences.homeFeed.lab_mergeFeedEnabled ? 'Yes' : 'No' + variables?.lab_mergeFeedEnabled ?? + preferences?.feedViewPrefs?.lab_mergeFeedEnabled + ? _(msg`Yes`) + : _(msg`No`) + } + isSelected={ + !!( + variables?.lab_mergeFeedEnabled ?? + preferences?.feedViewPrefs?.lab_mergeFeedEnabled + ) + } + onPress={() => + setFeedViewPref({ + lab_mergeFeedEnabled: !( + variables?.lab_mergeFeedEnabled ?? + preferences?.feedViewPrefs?.lab_mergeFeedEnabled + ), + }) } - isSelected={!!store.preferences.homeFeed.lab_mergeFeedEnabled} - onPress={store.preferences.toggleHomeFeedMergeFeedEnabled} /> </View> </View> @@ -204,14 +296,16 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ }} style={[styles.btn, isTabletOrDesktop && styles.btnDesktop]} accessibilityRole="button" - accessibilityLabel="Confirm" + accessibilityLabel={_(msg`Confirm`)} accessibilityHint=""> - <Text style={[s.white, s.bold, s.f18]}>Done</Text> + <Text style={[s.white, s.bold, s.f18]}> + <Trans>Done</Trans> + </Text> </TouchableOpacity> </View> </CenteredView> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/screens/PreferencesThreads.tsx b/src/view/screens/PreferencesThreads.tsx index af98a1833..73d941932 100644 --- a/src/view/screens/PreferencesThreads.tsx +++ b/src/view/screens/PreferencesThreads.tsx @@ -1,9 +1,13 @@ import React from 'react' -import {ScrollView, StyleSheet, TouchableOpacity, View} from 'react-native' -import {observer} from 'mobx-react-lite' +import { + ActivityIndicator, + ScrollView, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Text} from '../com/util/text/Text' -import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' @@ -12,14 +16,30 @@ import {RadioGroup} from 'view/com/util/forms/RadioGroup' import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {ViewHeader} from 'view/com/util/ViewHeader' import {CenteredView} from 'view/com/util/Views' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import { + usePreferencesQuery, + useSetThreadViewPreferencesMutation, +} from '#/state/queries/preferences' type Props = NativeStackScreenProps<CommonNavigatorParams, 'PreferencesThreads'> -export const PreferencesThreads = observer(function PreferencesThreadsImpl({ - navigation, -}: Props) { +export function PreferencesThreads({navigation}: Props) { const pal = usePalette('default') - const store = useStores() + const {_} = useLingui() const {isTabletOrDesktop} = useWebMediaQueries() + const {data: preferences} = usePreferencesQuery() + const {mutate: setThreadViewPrefs, variables} = + useSetThreadViewPreferencesMutation() + + const prioritizeFollowedUsers = Boolean( + variables?.prioritizeFollowedUsers ?? + preferences?.threadViewPrefs?.prioritizeFollowedUsers, + ) + const treeViewEnabled = Boolean( + variables?.lab_treeViewEnabled ?? + preferences?.threadViewPrefs?.lab_treeViewEnabled, + ) return ( <CenteredView @@ -30,78 +50,90 @@ export const PreferencesThreads = observer(function PreferencesThreadsImpl({ styles.container, isTabletOrDesktop && styles.desktopContainer, ]}> - <ViewHeader title="Thread Preferences" showOnDesktop /> + <ViewHeader title={_(msg`Thread Preferences`)} showOnDesktop /> <View style={[ styles.titleSection, isTabletOrDesktop && {paddingTop: 20, paddingBottom: 20}, ]}> <Text type="xl" style={[pal.textLight, styles.description]}> - Fine-tune the discussion threads. + <Trans>Fine-tune the discussion threads.</Trans> </Text> </View> - <ScrollView> - <View style={styles.cardsContainer}> - <View style={[pal.viewLight, styles.card]}> - <Text type="title-sm" style={[pal.text, s.pb5]}> - Sort Replies - </Text> - <Text style={[pal.text, s.pb10]}> - Sort replies to the same post by: - </Text> - <View style={[pal.view, {borderRadius: 8, paddingVertical: 6}]}> - <RadioGroup + {preferences ? ( + <ScrollView> + <View style={styles.cardsContainer}> + <View style={[pal.viewLight, styles.card]}> + <Text type="title-sm" style={[pal.text, s.pb5]}> + <Trans>Sort Replies</Trans> + </Text> + <Text style={[pal.text, s.pb10]}> + <Trans>Sort replies to the same post by:</Trans> + </Text> + <View style={[pal.view, {borderRadius: 8, paddingVertical: 6}]}> + <RadioGroup + type="default-light" + items={[ + {key: 'oldest', label: 'Oldest replies first'}, + {key: 'newest', label: 'Newest replies first'}, + {key: 'most-likes', label: 'Most-liked replies first'}, + {key: 'random', label: 'Random (aka "Poster\'s Roulette")'}, + ]} + onSelect={key => setThreadViewPrefs({sort: key})} + initialSelection={preferences?.threadViewPrefs?.sort} + /> + </View> + </View> + + <View style={[pal.viewLight, styles.card]}> + <Text type="title-sm" style={[pal.text, s.pb5]}> + <Trans>Prioritize Your Follows</Trans> + </Text> + <Text style={[pal.text, s.pb10]}> + <Trans> + Show replies by people you follow before all other replies. + </Trans> + </Text> + <ToggleButton type="default-light" - items={[ - {key: 'oldest', label: 'Oldest replies first'}, - {key: 'newest', label: 'Newest replies first'}, - {key: 'most-likes', label: 'Most-liked replies first'}, - {key: 'random', label: 'Random (aka "Poster\'s Roulette")'}, - ]} - onSelect={store.preferences.setThreadSort} - initialSelection={store.preferences.thread.sort} + label={prioritizeFollowedUsers ? 'Yes' : 'No'} + isSelected={prioritizeFollowedUsers} + onPress={() => + setThreadViewPrefs({ + prioritizeFollowedUsers: !prioritizeFollowedUsers, + }) + } /> </View> - </View> - <View style={[pal.viewLight, styles.card]}> - <Text type="title-sm" style={[pal.text, s.pb5]}> - Prioritize Your Follows - </Text> - <Text style={[pal.text, s.pb10]}> - Show replies by people you follow before all other replies. - </Text> - <ToggleButton - type="default-light" - label={ - store.preferences.thread.prioritizeFollowedUsers ? 'Yes' : 'No' - } - isSelected={store.preferences.thread.prioritizeFollowedUsers} - onPress={store.preferences.togglePrioritizedFollowedUsers} - /> - </View> - - <View style={[pal.viewLight, styles.card]}> - <Text type="title-sm" style={[pal.text, s.pb5]}> - <FontAwesomeIcon icon="flask" color={pal.colors.text} /> Threaded - Mode - </Text> - <Text style={[pal.text, s.pb10]}> - Set this setting to "Yes" to show replies in a threaded view. This - is an experimental feature. - </Text> - <ToggleButton - type="default-light" - label={ - store.preferences.thread.lab_treeViewEnabled ? 'Yes' : 'No' - } - isSelected={!!store.preferences.thread.lab_treeViewEnabled} - onPress={store.preferences.toggleThreadTreeViewEnabled} - /> + <View style={[pal.viewLight, styles.card]}> + <Text type="title-sm" style={[pal.text, s.pb5]}> + <FontAwesomeIcon icon="flask" color={pal.colors.text} />{' '} + <Trans>Threaded Mode</Trans> + </Text> + <Text style={[pal.text, s.pb10]}> + <Trans> + Set this setting to "Yes" to show replies in a threaded view. + This is an experimental feature. + </Trans> + </Text> + <ToggleButton + type="default-light" + label={treeViewEnabled ? 'Yes' : 'No'} + isSelected={treeViewEnabled} + onPress={() => + setThreadViewPrefs({ + lab_treeViewEnabled: !treeViewEnabled, + }) + } + /> + </View> </View> - </View> - </ScrollView> + </ScrollView> + ) : ( + <ActivityIndicator /> + )} <View style={[ @@ -118,14 +150,16 @@ export const PreferencesThreads = observer(function PreferencesThreadsImpl({ }} style={[styles.btn, isTabletOrDesktop && styles.btnDesktop]} accessibilityRole="button" - accessibilityLabel="Confirm" + accessibilityLabel={_(msg`Confirm`)} accessibilityHint=""> - <Text style={[s.white, s.bold, s.f18]}>Done</Text> + <Text style={[s.white, s.bold, s.f18]}> + <Trans>Done</Trans> + </Text> </TouchableOpacity> </View> </CenteredView> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/screens/PrivacyPolicy.tsx b/src/view/screens/PrivacyPolicy.tsx index f709c9fda..247afc316 100644 --- a/src/view/screens/PrivacyPolicy.tsx +++ b/src/view/screens/PrivacyPolicy.tsx @@ -9,10 +9,13 @@ import {ScrollView} from 'view/com/util/Views' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type Props = NativeStackScreenProps<CommonNavigatorParams, 'PrivacyPolicy'> export const PrivacyPolicyScreen = (_props: Props) => { const pal = usePalette('default') + const {_} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() useFocusEffect( @@ -23,16 +26,18 @@ export const PrivacyPolicyScreen = (_props: Props) => { return ( <View> - <ViewHeader title="Privacy Policy" /> + <ViewHeader title={_(msg`Privacy Policy`)} /> <ScrollView style={[s.hContentRegion, pal.view]}> <View style={[s.p20]}> <Text style={pal.text}> - The Privacy Policy has been moved to{' '} - <TextLink - style={pal.link} - href="https://blueskyweb.xyz/support/privacy-policy" - text="blueskyweb.xyz/support/privacy-policy" - /> + <Trans> + The Privacy Policy has been moved to{' '} + <TextLink + style={pal.link} + href="https://blueskyweb.xyz/support/privacy-policy" + text="blueskyweb.xyz/support/privacy-policy" + /> + </Trans> </Text> </View> <View style={s.footerSpacer} /> diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 9a25612ad..4af1b650e 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -1,317 +1,447 @@ -import React, {useEffect, useState} from 'react' -import {ActivityIndicator, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' +import React, {useMemo} from 'react' +import {StyleSheet, View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' +import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {ViewSelector, ViewSelectorHandle} from '../com/util/ViewSelector' -import {CenteredView} from '../com/util/Views' +import {CenteredView, FlatList} from '../com/util/Views' import {ScreenHider} from 'view/com/util/moderation/ScreenHider' -import {ProfileUiModel, Sections} from 'state/models/ui/profile' -import {useStores} from 'state/index' -import {PostsFeedSliceModel} from 'state/models/feeds/posts-slice' +import {Feed} from 'view/com/posts/Feed' +import {ProfileLists} from '../com/lists/ProfileLists' +import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens' import {ProfileHeader} from '../com/profile/ProfileHeader' -import {FeedSlice} from '../com/posts/FeedSlice' -import {ListCard} from 'view/com/lists/ListCard' -import { - PostFeedLoadingPlaceholder, - ProfileCardFeedLoadingPlaceholder, -} from '../com/util/LoadingPlaceholder' +import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {ErrorScreen} from '../com/util/error/ErrorScreen' -import {ErrorMessage} from '../com/util/error/ErrorMessage' import {EmptyState} from '../com/util/EmptyState' -import {Text} from '../com/util/text/Text' import {FAB} from '../com/util/fab/FAB' import {s, colors} from 'lib/styles' import {useAnalytics} from 'lib/analytics/analytics' import {ComposeIcon2} from 'lib/icons' -import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' -import {FeedSourceModel} from 'state/models/content/feed-source' import {useSetTitle} from 'lib/hooks/useSetTitle' import {combinedDisplayName} from 'lib/strings/display-names' -import {logger} from '#/logger' -import {useSetMinimalShellMode} from '#/state/shell' +import {OnScrollHandler} from '#/lib/hooks/useOnMainScroll' +import {FeedDescriptor} from '#/state/queries/post-feed' +import {useResolveDidQuery} from '#/state/queries/resolve-uri' +import {useProfileQuery} from '#/state/queries/profile' +import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useSession} from '#/state/session' +import {useModerationOpts} from '#/state/queries/preferences' +import {useProfileExtraInfoQuery} from '#/state/queries/profile-extra-info' +import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' +import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell' +import {cleanError} from '#/lib/strings/errors' +import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn' +import {useQueryClient} from '@tanstack/react-query' +import {useComposerControls} from '#/state/shell/composer' +import {listenSoftReset} from '#/state/events' +import {truncateAndInvalidate} from '#/state/queries/util' + +interface SectionRef { + scrollToTop: () => void +} type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'> -export const ProfileScreen = withAuthRequired( - observer(function ProfileScreenImpl({route}: Props) { - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {screen, track} = useAnalytics() - const viewSelectorRef = React.useRef<ViewSelectorHandle>(null) - const name = route.params.name === 'me' ? store.me.did : route.params.name +export function ProfileScreen({route}: Props) { + const {currentAccount} = useSession() + const name = + route.params.name === 'me' ? currentAccount?.did : route.params.name + const moderationOpts = useModerationOpts() + const { + data: resolvedDid, + error: resolveError, + refetch: refetchDid, + isInitialLoading: isInitialLoadingDid, + } = useResolveDidQuery(name) + const { + data: profile, + error: profileError, + refetch: refetchProfile, + isInitialLoading: isInitialLoadingProfile, + } = useProfileQuery({ + did: resolvedDid, + }) - useEffect(() => { - screen('Profile') - }, [screen]) + const onPressTryAgain = React.useCallback(() => { + if (resolveError) { + refetchDid() + } else { + refetchProfile() + } + }, [resolveError, refetchDid, refetchProfile]) - const [hasSetup, setHasSetup] = useState<boolean>(false) - const uiState = React.useMemo( - () => new ProfileUiModel(store, {user: name}), - [name, store], + if (isInitialLoadingDid || isInitialLoadingProfile || !moderationOpts) { + return ( + <CenteredView> + <ProfileHeader + profile={null} + moderation={null} + isProfilePreview={true} + /> + </CenteredView> ) - useSetTitle(combinedDisplayName(uiState.profile)) + } + if (resolveError || profileError) { + return ( + <CenteredView> + <ErrorScreen + testID="profileErrorScreen" + title="Oops!" + message={cleanError(resolveError || profileError)} + onPressTryAgain={onPressTryAgain} + /> + </CenteredView> + ) + } + if (profile && moderationOpts) { + return ( + <ProfileScreenLoaded + profile={profile} + moderationOpts={moderationOpts} + hideBackButton={!!route.params.hideBackButton} + /> + ) + } + // should never happen + return ( + <CenteredView> + <ErrorScreen + testID="profileErrorScreen" + title="Oops!" + message="Something went wrong and we're not sure what." + onPressTryAgain={onPressTryAgain} + /> + </CenteredView> + ) +} - const onSoftReset = React.useCallback(() => { - viewSelectorRef.current?.scrollToTop() - }, []) +function ProfileScreenLoaded({ + profile: profileUnshadowed, + moderationOpts, + hideBackButton, +}: { + profile: AppBskyActorDefs.ProfileViewDetailed + moderationOpts: ModerationOpts + hideBackButton: boolean +}) { + const profile = useProfileShadow(profileUnshadowed) + const {hasSession, currentAccount} = useSession() + const setMinimalShellMode = useSetMinimalShellMode() + const {openComposer} = useComposerControls() + const {screen, track} = useAnalytics() + const [currentPage, setCurrentPage] = React.useState(0) + const {_} = useLingui() + const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() + const extraInfoQuery = useProfileExtraInfoQuery(profile.did) + const postsSectionRef = React.useRef<SectionRef>(null) + const repliesSectionRef = React.useRef<SectionRef>(null) + const mediaSectionRef = React.useRef<SectionRef>(null) + const likesSectionRef = React.useRef<SectionRef>(null) + const feedsSectionRef = React.useRef<SectionRef>(null) + const listsSectionRef = React.useRef<SectionRef>(null) - useEffect(() => { - setHasSetup(false) - }, [name]) + useSetTitle(combinedDisplayName(profile)) - // We don't need this to be reactive, so we can just register the listeners once - useEffect(() => { - const listCleanup = uiState.lists.registerListeners() - return () => listCleanup() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + const moderation = useMemo( + () => moderateProfile(profile, moderationOpts), + [profile, moderationOpts], + ) - useFocusEffect( - React.useCallback(() => { - const softResetSub = store.onScreenSoftReset(onSoftReset) - let aborted = false - setMinimalShellMode(false) - const feedCleanup = uiState.feed.registerListeners() - if (!hasSetup) { - uiState.setup().then(() => { - if (aborted) { - return - } - setHasSetup(true) - }) - } - return () => { - aborted = true - feedCleanup() - softResetSub.remove() - } - }, [store, onSoftReset, uiState, hasSetup, setMinimalShellMode]), - ) + const isMe = profile.did === currentAccount?.did + const showRepliesTab = hasSession + const showLikesTab = isMe + const showFeedsTab = isMe || extraInfoQuery.data?.hasFeedgens + const showListsTab = hasSession && (isMe || extraInfoQuery.data?.hasLists) + const sectionTitles = useMemo<string[]>(() => { + return [ + 'Posts', + showRepliesTab ? 'Posts & Replies' : undefined, + 'Media', + showLikesTab ? 'Likes' : undefined, + showFeedsTab ? 'Feeds' : undefined, + showListsTab ? 'Lists' : undefined, + ].filter(Boolean) as string[] + }, [showRepliesTab, showLikesTab, showFeedsTab, showListsTab]) - // events - // = + let nextIndex = 0 + const postsIndex = nextIndex++ + let repliesIndex: number | null = null + if (showRepliesTab) { + repliesIndex = nextIndex++ + } + const mediaIndex = nextIndex++ + let likesIndex: number | null = null + if (showLikesTab) { + likesIndex = nextIndex++ + } + let feedsIndex: number | null = null + if (showFeedsTab) { + feedsIndex = nextIndex++ + } + let listsIndex: number | null = null + if (showListsTab) { + listsIndex = nextIndex++ + } - const onPressCompose = React.useCallback(() => { - track('ProfileScreen:PressCompose') - const mention = - uiState.profile.handle === store.me.handle || - uiState.profile.handle === 'handle.invalid' - ? undefined - : uiState.profile.handle - store.shell.openComposer({mention}) - }, [store, track, uiState]) - const onSelectView = React.useCallback( - (index: number) => { - uiState.setSelectedViewIndex(index) - }, - [uiState], - ) - const onRefresh = React.useCallback(() => { - uiState - .refresh() - .catch((err: any) => - logger.error('Failed to refresh user profile', {error: err}), - ) - }, [uiState]) - const onEndReached = React.useCallback(() => { - uiState.loadMore().catch((err: any) => - logger.error('Failed to load more entries in user profile', { - error: err, - }), - ) - }, [uiState]) - const onPressTryAgain = React.useCallback(() => { - uiState.setup() - }, [uiState]) + const scrollSectionToTop = React.useCallback( + (index: number) => { + if (index === postsIndex) { + postsSectionRef.current?.scrollToTop() + } else if (index === repliesIndex) { + repliesSectionRef.current?.scrollToTop() + } else if (index === mediaIndex) { + mediaSectionRef.current?.scrollToTop() + } else if (index === likesIndex) { + likesSectionRef.current?.scrollToTop() + } else if (index === feedsIndex) { + feedsSectionRef.current?.scrollToTop() + } else if (index === listsIndex) { + listsSectionRef.current?.scrollToTop() + } + }, + [postsIndex, repliesIndex, mediaIndex, likesIndex, feedsIndex, listsIndex], + ) - // rendering - // = + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + screen('Profile') + return listenSoftReset(() => { + scrollSectionToTop(currentPage) + }) + }, [setMinimalShellMode, screen, currentPage, scrollSectionToTop]), + ) - const renderHeader = React.useCallback(() => { - if (!uiState) { - return <View /> + useFocusEffect( + React.useCallback(() => { + setDrawerSwipeDisabled(currentPage > 0) + return () => { + setDrawerSwipeDisabled(false) } - return ( - <ProfileHeader - view={uiState.profile} - onRefreshAll={onRefresh} - hideBackButton={route.params.hideBackButton} - /> - ) - }, [uiState, onRefresh, route.params.hideBackButton]) + }, [setDrawerSwipeDisabled, currentPage]), + ) - const Footer = React.useMemo(() => { - return uiState.showLoadingMoreFooter ? LoadingMoreFooter : undefined - }, [uiState.showLoadingMoreFooter]) - const renderItem = React.useCallback( - (item: any) => { - // if section is lists - if (uiState.selectedView === Sections.Lists) { - if (item === ProfileUiModel.LOADING_ITEM) { - return <ProfileCardFeedLoadingPlaceholder /> - } else if (item._reactKey === '__error__') { - return ( - <View style={s.p5}> - <ErrorMessage - message={item.error} - onPressTryAgain={onPressTryAgain} - /> - </View> - ) - } else if (item === ProfileUiModel.EMPTY_ITEM) { - return ( - <EmptyState - testID="listsEmpty" - icon="list-ul" - message="No lists yet!" - style={styles.emptyState} + // events + // = + + const onPressCompose = React.useCallback(() => { + track('ProfileScreen:PressCompose') + const mention = + profile.handle === currentAccount?.handle || + profile.handle === 'handle.invalid' + ? undefined + : profile.handle + openComposer({mention}) + }, [openComposer, currentAccount, track, profile]) + + const onPageSelected = React.useCallback( + (i: number) => { + setCurrentPage(i) + }, + [setCurrentPage], + ) + + const onCurrentPageSelected = React.useCallback( + (index: number) => { + scrollSectionToTop(index) + }, + [scrollSectionToTop], + ) + + // rendering + // = + + const renderHeader = React.useCallback(() => { + return ( + <ProfileHeader + profile={profile} + moderation={moderation} + hideBackButton={hideBackButton} + /> + ) + }, [profile, moderation, hideBackButton]) + + return ( + <ScreenHider + testID="profileView" + style={styles.container} + screenDescription="profile" + moderation={moderation.account}> + <PagerWithHeader + testID="profilePager" + isHeaderReady={true} + items={sectionTitles} + onPageSelected={onPageSelected} + onCurrentPageSelected={onCurrentPageSelected} + renderHeader={renderHeader}> + {({onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}) => ( + <FeedSection + ref={postsSectionRef} + feed={`author|${profile.did}|posts_no_replies`} + onScroll={onScroll} + headerHeight={headerHeight} + isFocused={isFocused} + isScrolledDown={isScrolledDown} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + /> + )} + {showRepliesTab + ? ({ + onScroll, + headerHeight, + isFocused, + isScrolledDown, + scrollElRef, + }) => ( + <FeedSection + ref={repliesSectionRef} + feed={`author|${profile.did}|posts_with_replies`} + onScroll={onScroll} + headerHeight={headerHeight} + isFocused={isFocused} + isScrolledDown={isScrolledDown} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } /> ) - } else { - return <ListCard testID={`list-${item.name}`} list={item} /> - } - // if section is custom algorithms - } else if (uiState.selectedView === Sections.CustomAlgorithms) { - if (item === ProfileUiModel.LOADING_ITEM) { - return <ProfileCardFeedLoadingPlaceholder /> - } else if (item._reactKey === '__error__') { - return ( - <View style={s.p5}> - <ErrorMessage - message={item.error} - onPressTryAgain={onPressTryAgain} - /> - </View> - ) - } else if (item === ProfileUiModel.EMPTY_ITEM) { - return ( - <EmptyState - testID="customAlgorithmsEmpty" - icon="list-ul" - message="No custom algorithms yet!" - style={styles.emptyState} + : null} + {({onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}) => ( + <FeedSection + ref={mediaSectionRef} + feed={`author|${profile.did}|posts_with_media`} + onScroll={onScroll} + headerHeight={headerHeight} + isFocused={isFocused} + isScrolledDown={isScrolledDown} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + /> + )} + {showLikesTab + ? ({ + onScroll, + headerHeight, + isFocused, + isScrolledDown, + scrollElRef, + }) => ( + <FeedSection + ref={likesSectionRef} + feed={`likes|${profile.did}`} + onScroll={onScroll} + headerHeight={headerHeight} + isFocused={isFocused} + isScrolledDown={isScrolledDown} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } /> ) - } else if (item instanceof FeedSourceModel) { - return ( - <FeedSourceCard - item={item} - showSaveBtn - showLikes - showDescription + : null} + {showFeedsTab + ? ({onScroll, headerHeight, isFocused, scrollElRef}) => ( + <ProfileFeedgens + ref={feedsSectionRef} + did={profile.did} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + onScroll={onScroll} + scrollEventThrottle={1} + headerOffset={headerHeight} + enabled={isFocused} /> ) - } - // if section is posts or posts & replies - } else { - if (item === ProfileUiModel.END_ITEM) { - return <Text style={styles.endItem}>- end of feed -</Text> - } else if (item === ProfileUiModel.LOADING_ITEM) { - return <PostFeedLoadingPlaceholder /> - } else if (item._reactKey === '__error__') { - if (uiState.feed.isBlocking) { - return ( - <EmptyState - icon="ban" - message="Posts hidden" - style={styles.emptyState} - /> - ) - } - if (uiState.feed.isBlockedBy) { - return ( - <EmptyState - icon="ban" - message="Posts hidden" - style={styles.emptyState} - /> - ) - } - return ( - <View style={s.p5}> - <ErrorMessage - message={item.error} - onPressTryAgain={onPressTryAgain} - /> - </View> - ) - } else if (item === ProfileUiModel.EMPTY_ITEM) { - return ( - <EmptyState - icon={['far', 'message']} - message="No posts yet!" - style={styles.emptyState} + : null} + {showListsTab + ? ({onScroll, headerHeight, isFocused, scrollElRef}) => ( + <ProfileLists + ref={listsSectionRef} + did={profile.did} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + onScroll={onScroll} + scrollEventThrottle={1} + headerOffset={headerHeight} + enabled={isFocused} /> ) - } else if (item instanceof PostsFeedSliceModel) { - return ( - <FeedSlice slice={item} ignoreFilterFor={uiState.profile.did} /> - ) - } - } - return <View /> - }, - [ - onPressTryAgain, - uiState.selectedView, - uiState.profile.did, - uiState.feed.isBlocking, - uiState.feed.isBlockedBy, - ], - ) - - return ( - <ScreenHider - testID="profileView" - style={styles.container} - screenDescription="profile" - moderation={uiState.profile.moderation.account}> - {uiState.profile.hasError ? ( - <ErrorScreen - testID="profileErrorScreen" - title="Failed to load profile" - message={uiState.profile.error} - onPressTryAgain={onPressTryAgain} - /> - ) : uiState.profile.hasLoaded ? ( - <ViewSelector - ref={viewSelectorRef} - swipeEnabled={false} - sections={uiState.selectorItems} - items={uiState.uiItems} - renderHeader={renderHeader} - renderItem={renderItem} - ListFooterComponent={Footer} - refreshing={uiState.isRefreshing || false} - onSelectView={onSelectView} - onRefresh={onRefresh} - onEndReached={onEndReached} - /> - ) : ( - <CenteredView>{renderHeader()}</CenteredView> - )} + : null} + </PagerWithHeader> + {hasSession && ( <FAB testID="composeFAB" onPress={onPressCompose} icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} accessibilityRole="button" - accessibilityLabel="New post" + accessibilityLabel={_(msg`New post`)} accessibilityHint="" /> - </ScreenHider> - ) - }), -) - -function LoadingMoreFooter() { - return ( - <View style={styles.loadingMoreFooter}> - <ActivityIndicator /> - </View> + )} + </ScreenHider> ) } +interface FeedSectionProps { + feed: FeedDescriptor + onScroll: OnScrollHandler + headerHeight: number + isFocused: boolean + isScrolledDown: boolean + scrollElRef: React.MutableRefObject<FlatList<any> | null> +} +const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( + function FeedSectionImpl( + {feed, onScroll, headerHeight, isFocused, isScrolledDown, scrollElRef}, + ref, + ) { + const queryClient = useQueryClient() + const [hasNew, setHasNew] = React.useState(false) + + const onScrollToTop = React.useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: -headerHeight}) + truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) + setHasNew(false) + }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) + React.useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + const renderPostsEmpty = React.useCallback(() => { + return <EmptyState icon="feed" message="This feed is empty!" /> + }, []) + + return ( + <View> + <Feed + testID="postsFeed" + enabled={isFocused} + feed={feed} + pollInterval={30e3} + scrollElRef={scrollElRef} + onHasNew={setHasNew} + onScroll={onScroll} + scrollEventThrottle={1} + renderEmptyState={renderPostsEmpty} + headerOffset={headerHeight} + /> + {(isScrolledDown || hasNew) && ( + <LoadLatestBtn + onPress={onScrollToTop} + label="Load new posts" + showIndicator={hasNew} + /> + )} + </View> + ) + }, +) + const styles = StyleSheet.create({ container: { flexDirection: 'column', diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx index a4d146d66..3a0bdcc0f 100644 --- a/src/view/screens/ProfileFeed.tsx +++ b/src/view/screens/ProfileFeed.tsx @@ -1,25 +1,21 @@ import React, {useMemo, useCallback} from 'react' import { - FlatList, - NativeScrollEvent, + Dimensions, StyleSheet, View, ActivityIndicator, + FlatList, } from 'react-native' import {NativeStackScreenProps} from '@react-navigation/native-stack' import {useNavigation} from '@react-navigation/native' -import {useAnimatedScrollHandler} from 'react-native-reanimated' +import {useQueryClient} from '@tanstack/react-query' import {usePalette} from 'lib/hooks/usePalette' import {HeartIcon, HeartIconSolid} from 'lib/icons' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {CommonNavigatorParams} from 'lib/routes/types' import {makeRecordUri} from 'lib/strings/url-helpers' import {colors, s} from 'lib/styles' -import {observer} from 'mobx-react-lite' -import {useStores} from 'state/index' -import {FeedSourceModel} from 'state/models/content/feed-source' -import {PostsFeedModel} from 'state/models/feeds/posts' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {FeedDescriptor} from '#/state/queries/post-feed' import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader' import {Feed} from 'view/com/posts/Feed' @@ -32,13 +28,13 @@ import {FAB} from 'view/com/util/fab/FAB' import {EmptyState} from 'view/com/util/EmptyState' import * as Toast from 'view/com/util/Toast' import {useSetTitle} from 'lib/hooks/useSetTitle' -import {useCustomFeed} from 'lib/hooks/useCustomFeed' +import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' +import {OnScrollHandler} from 'lib/hooks/useOnMainScroll' import {shareUrl} from 'lib/sharing' import {toShareUrl} from 'lib/strings/url-helpers' import {Haptics} from 'lib/haptics' import {useAnalytics} from 'lib/analytics/analytics' import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown' -import {resolveName} from 'lib/api' import {makeCustomFeedLink} from 'lib/routes/links' import {pluralize} from 'lib/strings/helpers' import {CenteredView, ScrollView} from 'view/com/util/Views' @@ -47,6 +43,28 @@ import {sanitizeHandle} from 'lib/strings/handles' import {makeProfileLink} from 'lib/routes/links' import {ComposeIcon2} from 'lib/icons' import {logger} from '#/logger' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useModalControls} from '#/state/modals' +import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' +import { + useFeedSourceInfoQuery, + FeedSourceFeedInfo, + useIsFeedPublicQuery, +} from '#/state/queries/feed' +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import { + UsePreferencesQueryResponse, + usePreferencesQuery, + useSaveFeedMutation, + useRemoveFeedMutation, + usePinFeedMutation, + useUnpinFeedMutation, +} from '#/state/queries/preferences' +import {useSession} from '#/state/session' +import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like' +import {useComposerControls} from '#/state/shell/composer' +import {truncateAndInvalidate} from '#/state/queries/util' const SECTION_TITLES = ['Posts', 'About'] @@ -55,315 +73,372 @@ interface SectionRef { } type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeed'> -export const ProfileFeedScreen = withAuthRequired( - observer(function ProfileFeedScreenImpl(props: Props) { - const pal = usePalette('default') - const store = useStores() - const navigation = useNavigation<NavigationProp>() +export function ProfileFeedScreen(props: Props) { + const {rkey, name: handleOrDid} = props.route.params - const {name: handleOrDid} = props.route.params + const pal = usePalette('default') + const {_} = useLingui() + const navigation = useNavigation<NavigationProp>() - const [feedOwnerDid, setFeedOwnerDid] = React.useState<string | undefined>() - const [error, setError] = React.useState<string | undefined>() + const uri = useMemo( + () => makeRecordUri(handleOrDid, 'app.bsky.feed.generator', rkey), + [rkey, handleOrDid], + ) + const {error, data: resolvedUri} = useResolveUriQuery(uri) - const onPressBack = React.useCallback(() => { - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Home') - } - }, [navigation]) - - React.useEffect(() => { - /* - * We must resolve the DID of the feed owner before we can fetch the feed. - */ - async function fetchDid() { - try { - const did = await resolveName(store, handleOrDid) - setFeedOwnerDid(did) - } catch (e) { - setError( - `We're sorry, but we were unable to resolve this feed. If this persists, please contact the feed creator, @${handleOrDid}.`, - ) - } - } + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + + if (error) { + return ( + <CenteredView> + <View style={[pal.view, pal.border, styles.notFoundContainer]}> + <Text type="title-lg" style={[pal.text, s.mb10]}> + <Trans>Could not load feed</Trans> + </Text> + <Text type="md" style={[pal.text, s.mb20]}> + {error.toString()} + </Text> - fetchDid() - }, [store, handleOrDid, setFeedOwnerDid]) - - if (error) { - return ( - <CenteredView> - <View style={[pal.view, pal.border, styles.notFoundContainer]}> - <Text type="title-lg" style={[pal.text, s.mb10]}> - Could not load feed - </Text> - <Text type="md" style={[pal.text, s.mb20]}> - {error} - </Text> - - <View style={{flexDirection: 'row'}}> - <Button - type="default" - accessibilityLabel="Go Back" - accessibilityHint="Return to previous page" - onPress={onPressBack} - style={{flexShrink: 1}}> - <Text type="button" style={pal.text}> - Go Back - </Text> - </Button> - </View> + <View style={{flexDirection: 'row'}}> + <Button + type="default" + accessibilityLabel={_(msg`Go Back`)} + accessibilityHint="Return to previous page" + onPress={onPressBack} + style={{flexShrink: 1}}> + <Text type="button" style={pal.text}> + <Trans>Go Back</Trans> + </Text> + </Button> </View> - </CenteredView> - ) - } + </View> + </CenteredView> + ) + } - return feedOwnerDid ? ( - <ProfileFeedScreenInner {...props} feedOwnerDid={feedOwnerDid} /> - ) : ( + return resolvedUri ? ( + <ProfileFeedScreenIntermediate feedUri={resolvedUri.uri} /> + ) : ( + <CenteredView> + <View style={s.p20}> + <ActivityIndicator size="large" /> + </View> + </CenteredView> + ) +} + +function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) { + const {data: preferences} = usePreferencesQuery() + const {data: info} = useFeedSourceInfoQuery({uri: feedUri}) + const {isLoading: isPublicStatusLoading, data: isPublic} = + useIsFeedPublicQuery({uri: feedUri}) + + if (!preferences || !info || isPublicStatusLoading) { + return ( <CenteredView> <View style={s.p20}> <ActivityIndicator size="large" /> </View> </CenteredView> ) - }), -) + } -export const ProfileFeedScreenInner = observer( - function ProfileFeedScreenInnerImpl({ - route, - feedOwnerDid, - }: Props & {feedOwnerDid: string}) { - const pal = usePalette('default') - const store = useStores() - const {track} = useAnalytics() - const feedSectionRef = React.useRef<SectionRef>(null) - const {rkey, name: handleOrDid} = route.params - const uri = useMemo( - () => makeRecordUri(feedOwnerDid, 'app.bsky.feed.generator', rkey), - [rkey, feedOwnerDid], - ) - const feedInfo = useCustomFeed(uri) - const feed: PostsFeedModel = useMemo(() => { - const model = new PostsFeedModel(store, 'custom', { - feed: uri, - }) - model.setup() - return model - }, [store, uri]) - const isPinned = store.preferences.isPinnedFeed(uri) - useSetTitle(feedInfo?.displayName) - - // events - // = - - const onToggleSaved = React.useCallback(async () => { - try { - Haptics.default() - if (feedInfo?.isSaved) { - await feedInfo?.unsave() - } else { - await feedInfo?.save() - } - } catch (err) { - Toast.show( - 'There was an an issue updating your feeds, please check your internet connection and try again.', - ) - logger.error('Failed up update feeds', {error: err}) - } - }, [feedInfo]) + return ( + <ProfileFeedScreenInner + preferences={preferences} + feedInfo={info as FeedSourceFeedInfo} + isPublic={Boolean(isPublic)} + /> + ) +} - const onToggleLiked = React.useCallback(async () => { +export function ProfileFeedScreenInner({ + preferences, + feedInfo, + isPublic, +}: { + preferences: UsePreferencesQueryResponse + feedInfo: FeedSourceFeedInfo + isPublic: boolean +}) { + const {_} = useLingui() + const pal = usePalette('default') + const {hasSession, currentAccount} = useSession() + const {openModal} = useModalControls() + const {openComposer} = useComposerControls() + const {track} = useAnalytics() + const feedSectionRef = React.useRef<SectionRef>(null) + + const { + mutateAsync: saveFeed, + variables: savedFeed, + reset: resetSaveFeed, + isPending: isSavePending, + } = useSaveFeedMutation() + const { + mutateAsync: removeFeed, + variables: removedFeed, + reset: resetRemoveFeed, + isPending: isRemovePending, + } = useRemoveFeedMutation() + const { + mutateAsync: pinFeed, + variables: pinnedFeed, + reset: resetPinFeed, + isPending: isPinPending, + } = usePinFeedMutation() + const { + mutateAsync: unpinFeed, + variables: unpinnedFeed, + reset: resetUnpinFeed, + isPending: isUnpinPending, + } = useUnpinFeedMutation() + + const isSaved = + !removedFeed && + (!!savedFeed || preferences.feeds.saved.includes(feedInfo.uri)) + const isPinned = + !unpinnedFeed && + (!!pinnedFeed || preferences.feeds.pinned.includes(feedInfo.uri)) + + useSetTitle(feedInfo?.displayName) + + const onToggleSaved = React.useCallback(async () => { + try { Haptics.default() - try { - if (feedInfo?.isLiked) { - await feedInfo?.unlike() - } else { - await feedInfo?.like() - } - } catch (err) { - Toast.show( - 'There was an an issue contacting the server, please check your internet connection and try again.', - ) - logger.error('Failed up toggle like', {error: err}) + + if (isSaved) { + await removeFeed({uri: feedInfo.uri}) + resetRemoveFeed() + } else { + await saveFeed({uri: feedInfo.uri}) + resetSaveFeed() } - }, [feedInfo]) + } catch (err) { + Toast.show( + 'There was an an issue updating your feeds, please check your internet connection and try again.', + ) + logger.error('Failed up update feeds', {error: err}) + } + }, [feedInfo, isSaved, saveFeed, removeFeed, resetSaveFeed, resetRemoveFeed]) - const onTogglePinned = React.useCallback(async () => { + const onTogglePinned = React.useCallback(async () => { + try { Haptics.default() - if (feedInfo) { - feedInfo.togglePin().catch(e => { - Toast.show('There was an issue contacting the server') - logger.error('Failed to toggle pinned feed', {error: e}) - }) - } - }, [feedInfo]) - - const onPressShare = React.useCallback(() => { - const url = toShareUrl(`/profile/${handleOrDid}/feed/${rkey}`) - shareUrl(url) - track('CustomFeed:Share') - }, [handleOrDid, rkey, track]) - - const onPressReport = React.useCallback(() => { - if (!feedInfo) return - store.shell.openModal({ - name: 'report', - uri: feedInfo.uri, - cid: feedInfo.cid, - }) - }, [store, feedInfo]) - - const onCurrentPageSelected = React.useCallback( - (index: number) => { - if (index === 0) { - feedSectionRef.current?.scrollToTop() - } - }, - [feedSectionRef], - ) - // render - // = + if (isPinned) { + await unpinFeed({uri: feedInfo.uri}) + resetUnpinFeed() + } else { + await pinFeed({uri: feedInfo.uri}) + resetPinFeed() + } + } catch (e) { + Toast.show('There was an issue contacting the server') + logger.error('Failed to toggle pinned feed', {error: e}) + } + }, [isPinned, feedInfo, pinFeed, unpinFeed, resetPinFeed, resetUnpinFeed]) + + const onPressShare = React.useCallback(() => { + const url = toShareUrl(feedInfo.route.href) + shareUrl(url) + track('CustomFeed:Share') + }, [feedInfo, track]) + + const onPressReport = React.useCallback(() => { + if (!feedInfo) return + openModal({ + name: 'report', + uri: feedInfo.uri, + cid: feedInfo.cid, + }) + }, [openModal, feedInfo]) + + const onCurrentPageSelected = React.useCallback( + (index: number) => { + if (index === 0) { + feedSectionRef.current?.scrollToTop() + } + }, + [feedSectionRef], + ) - const dropdownItems: DropdownItem[] = React.useMemo(() => { - return [ - { - testID: 'feedHeaderDropdownToggleSavedBtn', - label: feedInfo?.isSaved ? 'Remove from my feeds' : 'Add to my feeds', - onPress: onToggleSaved, - icon: feedInfo?.isSaved - ? { - ios: { - name: 'trash', - }, - android: 'ic_delete', - web: ['far', 'trash-can'], - } - : { - ios: { - name: 'plus', - }, - android: '', - web: 'plus', + // render + // = + + const dropdownItems: DropdownItem[] = React.useMemo(() => { + return [ + hasSession && { + testID: 'feedHeaderDropdownToggleSavedBtn', + label: isSaved ? _(msg`Remove from my feeds`) : _(msg`Add to my feeds`), + onPress: isSavePending || isRemovePending ? undefined : onToggleSaved, + icon: isSaved + ? { + ios: { + name: 'trash', }, - }, - { - testID: 'feedHeaderDropdownReportBtn', - label: 'Report feed', - onPress: onPressReport, - icon: { - ios: { - name: 'exclamationmark.triangle', + android: 'ic_delete', + web: ['far', 'trash-can'], + } + : { + ios: { + name: 'plus', + }, + android: '', + web: 'plus', }, - android: 'ic_menu_report_image', - web: 'circle-exclamation', + }, + hasSession && { + testID: 'feedHeaderDropdownReportBtn', + label: _(msg`Report feed`), + onPress: onPressReport, + icon: { + ios: { + name: 'exclamationmark.triangle', }, + android: 'ic_menu_report_image', + web: 'circle-exclamation', }, - { - testID: 'feedHeaderDropdownShareBtn', - label: 'Share link', - onPress: onPressShare, - icon: { - ios: { - name: 'square.and.arrow.up', - }, - android: 'ic_menu_share', - web: 'share', + }, + { + testID: 'feedHeaderDropdownShareBtn', + label: _(msg`Share feed`), + onPress: onPressShare, + icon: { + ios: { + name: 'square.and.arrow.up', }, + android: 'ic_menu_share', + web: 'share', }, - ] as DropdownItem[] - }, [feedInfo, onToggleSaved, onPressReport, onPressShare]) - - const renderHeader = useCallback(() => { - return ( - <ProfileSubpageHeader - isLoading={!feedInfo?.hasLoaded} - href={makeCustomFeedLink(feedOwnerDid, rkey)} - title={feedInfo?.displayName} - avatar={feedInfo?.avatar} - isOwner={feedInfo?.isOwner} - creator={ - feedInfo - ? {did: feedInfo.creatorDid, handle: feedInfo.creatorHandle} - : undefined - } - avatarType="algo"> - {feedInfo && ( - <> - <Button - type="default" - label={feedInfo?.isSaved ? 'Unsave' : 'Save'} - onPress={onToggleSaved} - style={styles.btn} - /> - <Button - type={isPinned ? 'default' : 'inverted'} - label={isPinned ? 'Unpin' : 'Pin to home'} - onPress={onTogglePinned} - style={styles.btn} - /> - </> - )} - <NativeDropdown - testID="headerDropdownBtn" - items={dropdownItems} - accessibilityLabel="More options" - accessibilityHint=""> - <View style={[pal.viewLight, styles.btn]}> - <FontAwesomeIcon - icon="ellipsis" - size={20} - color={pal.colors.text} - /> - </View> - </NativeDropdown> - </ProfileSubpageHeader> - ) - }, [ - pal, - feedOwnerDid, - rkey, - feedInfo, - isPinned, - onTogglePinned, - onToggleSaved, - dropdownItems, - ]) - + }, + ].filter(Boolean) as DropdownItem[] + }, [ + hasSession, + onToggleSaved, + onPressReport, + onPressShare, + isSaved, + isSavePending, + isRemovePending, + _, + ]) + + const renderHeader = useCallback(() => { return ( - <View style={s.hContentRegion}> - <PagerWithHeader - items={SECTION_TITLES} - isHeaderReady={feedInfo?.hasLoaded ?? false} - renderHeader={renderHeader} - onCurrentPageSelected={onCurrentPageSelected}> - {({onScroll, headerHeight, isScrolledDown}) => ( + <ProfileSubpageHeader + isLoading={false} + href={feedInfo.route.href} + title={feedInfo?.displayName} + avatar={feedInfo?.avatar} + isOwner={feedInfo.creatorDid === currentAccount?.did} + creator={ + feedInfo + ? {did: feedInfo.creatorDid, handle: feedInfo.creatorHandle} + : undefined + } + avatarType="algo"> + {feedInfo && hasSession && ( + <> + <Button + disabled={isSavePending || isRemovePending} + type="default" + label={isSaved ? 'Unsave' : 'Save'} + onPress={onToggleSaved} + style={styles.btn} + /> + <Button + testID={isPinned ? 'unpinBtn' : 'pinBtn'} + disabled={isPinPending || isUnpinPending} + type={isPinned ? 'default' : 'inverted'} + label={isPinned ? 'Unpin' : 'Pin to home'} + onPress={onTogglePinned} + style={styles.btn} + /> + </> + )} + <NativeDropdown + testID="headerDropdownBtn" + items={dropdownItems} + accessibilityLabel={_(msg`More options`)} + accessibilityHint=""> + <View style={[pal.viewLight, styles.btn]}> + <FontAwesomeIcon + icon="ellipsis" + size={20} + color={pal.colors.text} + /> + </View> + </NativeDropdown> + </ProfileSubpageHeader> + ) + }, [ + _, + hasSession, + pal, + feedInfo, + isPinned, + onTogglePinned, + onToggleSaved, + dropdownItems, + currentAccount?.did, + isPinPending, + isRemovePending, + isSavePending, + isSaved, + isUnpinPending, + ]) + + return ( + <View style={s.hContentRegion}> + <PagerWithHeader + items={SECTION_TITLES} + isHeaderReady={true} + renderHeader={renderHeader} + onCurrentPageSelected={onCurrentPageSelected}> + {({onScroll, headerHeight, isScrolledDown, scrollElRef, isFocused}) => + isPublic ? ( <FeedSection ref={feedSectionRef} - feed={feed} + feed={`feedgen|${feedInfo.uri}`} onScroll={onScroll} headerHeight={headerHeight} isScrolledDown={isScrolledDown} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + isFocused={isFocused} /> - )} - {({onScroll, headerHeight}) => ( - <AboutSection - feedOwnerDid={feedOwnerDid} - feedRkey={rkey} - feedInfo={feedInfo} - headerHeight={headerHeight} - onToggleLiked={onToggleLiked} - onScroll={onScroll} - /> - )} - </PagerWithHeader> + ) : ( + <CenteredView sideBorders style={[{paddingTop: headerHeight}]}> + <NonPublicFeedMessage /> + </CenteredView> + ) + } + {({onScroll, headerHeight, scrollElRef}) => ( + <AboutSection + feedOwnerDid={feedInfo.creatorDid} + feedRkey={feedInfo.route.params.rkey} + feedInfo={feedInfo} + headerHeight={headerHeight} + onScroll={onScroll} + scrollElRef={ + scrollElRef as React.MutableRefObject<ScrollView | null> + } + isOwner={feedInfo.creatorDid === currentAccount?.did} + /> + )} + </PagerWithHeader> + {hasSession && ( <FAB testID="composeFAB" - onPress={() => store.shell.openComposer({})} + onPress={() => openComposer({})} icon={ <ComposeIcon2 strokeWidth={1.5} @@ -372,32 +447,67 @@ export const ProfileFeedScreenInner = observer( /> } accessibilityRole="button" - accessibilityLabel="New post" + accessibilityLabel={_(msg`New post`)} accessibilityHint="" /> + )} + </View> + ) +} + +function NonPublicFeedMessage() { + const pal = usePalette('default') + + return ( + <View + style={[ + pal.border, + { + padding: 18, + borderTopWidth: 1, + minHeight: Dimensions.get('window').height * 1.5, + }, + ]}> + <View + style={[ + pal.viewLight, + { + padding: 12, + borderRadius: 8, + }, + ]}> + <Text style={[pal.text]}> + <Trans> + Looks like this feed is only available to users with a Bluesky + account. Please sign up or sign in to view this feed! + </Trans> + </Text> </View> - ) - }, -) + </View> + ) +} interface FeedSectionProps { - feed: PostsFeedModel - onScroll: (e: NativeScrollEvent) => void + feed: FeedDescriptor + onScroll: OnScrollHandler headerHeight: number isScrolledDown: boolean + scrollElRef: React.MutableRefObject<FlatList<any> | null> + isFocused: boolean } const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( function FeedSectionImpl( - {feed, onScroll, headerHeight, isScrolledDown}, + {feed, onScroll, headerHeight, isScrolledDown, scrollElRef, isFocused}, ref, ) { - const hasNew = feed.hasNewLatest && !feed.isRefreshing - const scrollElRef = React.useRef<FlatList>(null) + const [hasNew, setHasNew] = React.useState(false) + const queryClient = useQueryClient() const onScrollToTop = useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerHeight}) - feed.refresh() - }, [feed, scrollElRef, headerHeight]) + truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) + setHasNew(false) + }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) React.useImperativeHandle(ref, () => ({ scrollToTop: onScrollToTop, @@ -407,13 +517,15 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( return <EmptyState icon="feed" message="This feed is empty!" /> }, []) - const scrollHandler = useAnimatedScrollHandler({onScroll}) return ( <View> <Feed + enabled={isFocused} feed={feed} + pollInterval={30e3} scrollElRef={scrollElRef} - onScroll={scrollHandler} + onHasNew={setHasNew} + onScroll={onScroll} scrollEventThrottle={5} renderEmptyState={renderPostsEmpty} headerOffset={headerHeight} @@ -430,32 +542,64 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( }, ) -const AboutSection = observer(function AboutPageImpl({ +function AboutSection({ feedOwnerDid, feedRkey, feedInfo, headerHeight, - onToggleLiked, onScroll, + scrollElRef, + isOwner, }: { feedOwnerDid: string feedRkey: string - feedInfo: FeedSourceModel | undefined + feedInfo: FeedSourceFeedInfo headerHeight: number - onToggleLiked: () => void - onScroll: (e: NativeScrollEvent) => void + onScroll: OnScrollHandler + scrollElRef: React.MutableRefObject<ScrollView | null> + isOwner: boolean }) { const pal = usePalette('default') - const scrollHandler = useAnimatedScrollHandler({onScroll}) + const {_} = useLingui() + const scrollHandler = useAnimatedScrollHandler(onScroll) + const [likeUri, setLikeUri] = React.useState(feedInfo.likeUri) + const {hasSession} = useSession() - if (!feedInfo) { - return <View /> - } + const {mutateAsync: likeFeed, isPending: isLikePending} = useLikeMutation() + const {mutateAsync: unlikeFeed, isPending: isUnlikePending} = + useUnlikeMutation() + + const isLiked = !!likeUri + const likeCount = + isLiked && likeUri ? (feedInfo.likeCount || 0) + 1 : feedInfo.likeCount + + const onToggleLiked = React.useCallback(async () => { + try { + Haptics.default() + + if (isLiked && likeUri) { + await unlikeFeed({uri: likeUri}) + setLikeUri('') + } else { + const res = await likeFeed({uri: feedInfo.uri, cid: feedInfo.cid}) + setLikeUri(res.uri) + } + } catch (err) { + Toast.show( + 'There was an an issue contacting the server, please check your internet connection and try again.', + ) + logger.error('Failed up toggle like', {error: err}) + } + }, [likeUri, isLiked, feedInfo, likeFeed, unlikeFeed]) return ( <ScrollView + ref={scrollElRef} scrollEventThrottle={1} - contentContainerStyle={{paddingTop: headerHeight}} + contentContainerStyle={{ + paddingTop: headerHeight, + minHeight: Dimensions.get('window').height * 1.5, + }} onScroll={scrollHandler}> <View style={[ @@ -467,46 +611,44 @@ const AboutSection = observer(function AboutPageImpl({ }, pal.border, ]}> - {feedInfo.descriptionRT ? ( + {feedInfo.description ? ( <RichText testID="listDescription" type="lg" style={pal.text} - richText={feedInfo.descriptionRT} + richText={feedInfo.description} /> ) : ( <Text type="lg" style={[{fontStyle: 'italic'}, pal.textLight]}> - No description + <Trans>No description</Trans> </Text> )} <View style={{flexDirection: 'row', alignItems: 'center', gap: 10}}> <Button type="default" testID="toggleLikeBtn" - accessibilityLabel="Like this feed" + accessibilityLabel={_(msg`Like this feed`)} accessibilityHint="" + disabled={!hasSession || isLikePending || isUnlikePending} onPress={onToggleLiked} style={{paddingHorizontal: 10}}> - {feedInfo?.isLiked ? ( + {isLiked ? ( <HeartIconSolid size={19} style={styles.liked} /> ) : ( <HeartIcon strokeWidth={3} size={19} style={pal.textLight} /> )} </Button> - {typeof feedInfo.likeCount === 'number' && ( + {typeof likeCount === 'number' && ( <TextLink href={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')} - text={`Liked by ${feedInfo.likeCount} ${pluralize( - feedInfo.likeCount, - 'user', - )}`} + text={`Liked by ${likeCount} ${pluralize(likeCount, 'user')}`} style={[pal.textLight, s.semiBold]} /> )} </View> <Text type="md" style={[pal.textLight]} numberOfLines={1}> Created by{' '} - {feedInfo.isOwner ? ( + {isOwner ? ( 'you' ) : ( <TextLink @@ -522,7 +664,7 @@ const AboutSection = observer(function AboutPageImpl({ </View> </ScrollView> ) -}) +} const styles = StyleSheet.create({ btn: { diff --git a/src/view/screens/ProfileFeedLikedBy.tsx b/src/view/screens/ProfileFeedLikedBy.tsx index 4972116f3..0460670e1 100644 --- a/src/view/screens/ProfileFeedLikedBy.tsx +++ b/src/view/screens/ProfileFeedLikedBy.tsx @@ -2,17 +2,19 @@ import React from 'react' import {View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from '../com/util/ViewHeader' import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy' import {makeRecordUri} from 'lib/strings/url-helpers' import {useSetMinimalShellMode} from '#/state/shell' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeedLikedBy'> -export const ProfileFeedLikedByScreen = withAuthRequired(({route}: Props) => { +export const ProfileFeedLikedByScreen = ({route}: Props) => { const setMinimalShellMode = useSetMinimalShellMode() const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.generator', rkey) + const {_} = useLingui() useFocusEffect( React.useCallback(() => { @@ -22,8 +24,8 @@ export const ProfileFeedLikedByScreen = withAuthRequired(({route}: Props) => { return ( <View> - <ViewHeader title="Liked by" /> + <ViewHeader title={_(msg`Liked by`)} /> <PostLikedByComponent uri={uri} /> </View> ) -}) +} diff --git a/src/view/screens/ProfileFollowers.tsx b/src/view/screens/ProfileFollowers.tsx index 49f55bf46..2cad08cb5 100644 --- a/src/view/screens/ProfileFollowers.tsx +++ b/src/view/screens/ProfileFollowers.tsx @@ -2,15 +2,17 @@ import React from 'react' import {View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from '../com/util/ViewHeader' import {ProfileFollowers as ProfileFollowersComponent} from '../com/profile/ProfileFollowers' import {useSetMinimalShellMode} from '#/state/shell' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFollowers'> -export const ProfileFollowersScreen = withAuthRequired(({route}: Props) => { +export const ProfileFollowersScreen = ({route}: Props) => { const {name} = route.params const setMinimalShellMode = useSetMinimalShellMode() + const {_} = useLingui() useFocusEffect( React.useCallback(() => { @@ -20,8 +22,8 @@ export const ProfileFollowersScreen = withAuthRequired(({route}: Props) => { return ( <View> - <ViewHeader title="Followers" /> + <ViewHeader title={_(msg`Followers`)} /> <ProfileFollowersComponent name={name} /> </View> ) -}) +} diff --git a/src/view/screens/ProfileFollows.tsx b/src/view/screens/ProfileFollows.tsx index 4f0ff7d67..80502b98b 100644 --- a/src/view/screens/ProfileFollows.tsx +++ b/src/view/screens/ProfileFollows.tsx @@ -2,15 +2,17 @@ import React from 'react' import {View} from 'react-native' import {useFocusEffect} from '@react-navigation/native' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from '../com/util/ViewHeader' import {ProfileFollows as ProfileFollowsComponent} from '../com/profile/ProfileFollows' import {useSetMinimalShellMode} from '#/state/shell' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFollows'> -export const ProfileFollowsScreen = withAuthRequired(({route}: Props) => { +export const ProfileFollowsScreen = ({route}: Props) => { const {name} = route.params const setMinimalShellMode = useSetMinimalShellMode() + const {_} = useLingui() useFocusEffect( React.useCallback(() => { @@ -20,8 +22,8 @@ export const ProfileFollowsScreen = withAuthRequired(({route}: Props) => { return ( <View> - <ViewHeader title="Following" /> + <ViewHeader title={_(msg`Following`)} /> <ProfileFollowsComponent name={name} /> </View> ) -}) +} diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index b84732d53..421611764 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -2,7 +2,6 @@ import React, {useCallback, useMemo} from 'react' import { ActivityIndicator, FlatList, - NativeScrollEvent, Pressable, StyleSheet, View, @@ -11,10 +10,8 @@ import {useFocusEffect} from '@react-navigation/native' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {useNavigation} from '@react-navigation/native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {useAnimatedScrollHandler} from 'react-native-reanimated' -import {observer} from 'mobx-react-lite' -import {RichText as RichTextAPI} from '@atproto/api' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {AppBskyGraphDefs, AtUri, RichText as RichTextAPI} from '@atproto/api' +import {useQueryClient} from '@tanstack/react-query' import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader' import {Feed} from 'view/com/posts/Feed' @@ -29,23 +26,36 @@ import * as Toast from 'view/com/util/Toast' import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' import {FAB} from 'view/com/util/fab/FAB' import {Haptics} from 'lib/haptics' -import {ListModel} from 'state/models/content/list' -import {PostsFeedModel} from 'state/models/feeds/posts' -import {useStores} from 'state/index' +import {FeedDescriptor} from '#/state/queries/post-feed' import {usePalette} from 'lib/hooks/usePalette' import {useSetTitle} from 'lib/hooks/useSetTitle' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' +import {OnScrollHandler} from 'lib/hooks/useOnMainScroll' import {NavigationProp} from 'lib/routes/types' import {toShareUrl} from 'lib/strings/url-helpers' import {shareUrl} from 'lib/sharing' -import {resolveName} from 'lib/api' import {s} from 'lib/styles' import {sanitizeHandle} from 'lib/strings/handles' import {makeProfileLink, makeListLink} from 'lib/routes/links' import {ComposeIcon2} from 'lib/icons' -import {ListItems} from 'view/com/lists/ListItems' -import {logger} from '#/logger' +import {ListMembers} from '#/view/com/lists/ListMembers' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {useSetMinimalShellMode} from '#/state/shell' +import {useModalControls} from '#/state/modals' +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import { + useListQuery, + useListMuteMutation, + useListBlockMutation, + useListDeleteMutation, +} from '#/state/queries/list' +import {cleanError} from '#/lib/strings/errors' +import {useSession} from '#/state/session' +import {useComposerControls} from '#/state/shell/composer' +import {isWeb} from '#/platform/detection' +import {truncateAndInvalidate} from '#/state/queries/util' const SECTION_TITLES_CURATE = ['Posts', 'About'] const SECTION_TITLES_MOD = ['About'] @@ -55,240 +65,220 @@ interface SectionRef { } type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'> -export const ProfileListScreen = withAuthRequired( - observer(function ProfileListScreenImpl(props: Props) { - const store = useStores() - const {name: handleOrDid} = props.route.params - const [listOwnerDid, setListOwnerDid] = React.useState<string | undefined>() - const [error, setError] = React.useState<string | undefined>() - - React.useEffect(() => { - /* - * We must resolve the DID of the list owner before we can fetch the list. - */ - async function fetchDid() { - try { - const did = await resolveName(store, handleOrDid) - setListOwnerDid(did) - } catch (e) { - setError( - `We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @${handleOrDid}.`, - ) - } - } - - fetchDid() - }, [store, handleOrDid, setListOwnerDid]) - - if (error) { - return ( - <CenteredView> - <ErrorScreen error={error} /> - </CenteredView> - ) - } +export function ProfileListScreen(props: Props) { + const {name: handleOrDid, rkey} = props.route.params + const {data: resolvedUri, error: resolveError} = useResolveUriQuery( + AtUri.make(handleOrDid, 'app.bsky.graph.list', rkey).toString(), + ) + const {data: list, error: listError} = useListQuery(resolvedUri?.uri) - return listOwnerDid ? ( - <ProfileListScreenInner {...props} listOwnerDid={listOwnerDid} /> - ) : ( + if (resolveError) { + return ( <CenteredView> - <View style={s.p20}> - <ActivityIndicator size="large" /> - </View> + <ErrorScreen + error={`We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @${handleOrDid}.`} + /> </CenteredView> ) - }), -) - -export const ProfileListScreenInner = observer( - function ProfileListScreenInnerImpl({ - route, - listOwnerDid, - }: Props & {listOwnerDid: string}) { - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const {rkey} = route.params - const feedSectionRef = React.useRef<SectionRef>(null) - const aboutSectionRef = React.useRef<SectionRef>(null) - - const list: ListModel = useMemo(() => { - const model = new ListModel( - store, - `at://${listOwnerDid}/app.bsky.graph.list/${rkey}`, - ) - return model - }, [store, listOwnerDid, rkey]) - const feed = useMemo( - () => new PostsFeedModel(store, 'list', {list: list.uri}), - [store, list], - ) - useSetTitle(list.data?.name) - - useFocusEffect( - useCallback(() => { - setMinimalShellMode(false) - list.loadMore(true).then(() => { - if (list.isCuratelist) { - feed.setup() - } - }) - }, [setMinimalShellMode, list, feed]), + } + if (listError) { + return ( + <CenteredView> + <ErrorScreen error={cleanError(listError)} /> + </CenteredView> ) + } + + return resolvedUri && list ? ( + <ProfileListScreenLoaded {...props} uri={resolvedUri.uri} list={list} /> + ) : ( + <CenteredView> + <View style={s.p20}> + <ActivityIndicator size="large" /> + </View> + </CenteredView> + ) +} - const onPressAddUser = useCallback(() => { - store.shell.openModal({ - name: 'list-add-user', - list, - onAdd() { - if (list.isCuratelist) { - feed.refresh() - } - }, - }) - }, [store, list, feed]) +function ProfileListScreenLoaded({ + route, + uri, + list, +}: Props & {uri: string; list: AppBskyGraphDefs.ListView}) { + const {_} = useLingui() + const queryClient = useQueryClient() + const {openComposer} = useComposerControls() + const setMinimalShellMode = useSetMinimalShellMode() + const {rkey} = route.params + const feedSectionRef = React.useRef<SectionRef>(null) + const aboutSectionRef = React.useRef<SectionRef>(null) + const {openModal} = useModalControls() + const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist' + + useSetTitle(list.name) + + useFocusEffect( + useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) - const onCurrentPageSelected = React.useCallback( - (index: number) => { - if (index === 0) { - feedSectionRef.current?.scrollToTop() - } - if (index === 1) { - aboutSectionRef.current?.scrollToTop() + const onPressAddUser = useCallback(() => { + openModal({ + name: 'list-add-remove-users', + list, + onChange() { + if (isCurateList) { + // TODO(eric) should construct these strings with a fn too + truncateAndInvalidate(queryClient, FEED_RQKEY(`list|${list.uri}`)) } }, - [feedSectionRef], - ) + }) + }, [openModal, list, isCurateList, queryClient]) + + const onCurrentPageSelected = React.useCallback( + (index: number) => { + if (index === 0) { + feedSectionRef.current?.scrollToTop() + } else if (index === 1) { + aboutSectionRef.current?.scrollToTop() + } + }, + [feedSectionRef], + ) - const renderHeader = useCallback(() => { - return <Header rkey={rkey} list={list} /> - }, [rkey, list]) + const renderHeader = useCallback(() => { + return <Header rkey={rkey} list={list} /> + }, [rkey, list]) - if (list.isCuratelist) { - return ( - <View style={s.hContentRegion}> - <PagerWithHeader - items={SECTION_TITLES_CURATE} - isHeaderReady={list.hasLoaded} - renderHeader={renderHeader} - onCurrentPageSelected={onCurrentPageSelected}> - {({onScroll, headerHeight, isScrolledDown}) => ( - <FeedSection - ref={feedSectionRef} - feed={feed} - onScroll={onScroll} - headerHeight={headerHeight} - isScrolledDown={isScrolledDown} - /> - )} - {({onScroll, headerHeight, isScrolledDown}) => ( - <AboutSection - ref={aboutSectionRef} - list={list} - descriptionRT={list.descriptionRT} - creator={list.data ? list.data.creator : undefined} - isCurateList={list.isCuratelist} - isOwner={list.isOwner} - onPressAddUser={onPressAddUser} - onScroll={onScroll} - headerHeight={headerHeight} - isScrolledDown={isScrolledDown} - /> - )} - </PagerWithHeader> - <FAB - testID="composeFAB" - onPress={() => store.shell.openComposer({})} - icon={ - <ComposeIcon2 - strokeWidth={1.5} - size={29} - style={{color: 'white'}} - /> - } - accessibilityRole="button" - accessibilityLabel="New post" - accessibilityHint="" - /> - </View> - ) - } - if (list.isModlist) { - return ( - <View style={s.hContentRegion}> - <PagerWithHeader - items={SECTION_TITLES_MOD} - isHeaderReady={list.hasLoaded} - renderHeader={renderHeader}> - {({onScroll, headerHeight, isScrolledDown}) => ( - <AboutSection - list={list} - descriptionRT={list.descriptionRT} - creator={list.data ? list.data.creator : undefined} - isCurateList={list.isCuratelist} - isOwner={list.isOwner} - onPressAddUser={onPressAddUser} - onScroll={onScroll} - headerHeight={headerHeight} - isScrolledDown={isScrolledDown} - /> - )} - </PagerWithHeader> - <FAB - testID="composeFAB" - onPress={() => store.shell.openComposer({})} - icon={ - <ComposeIcon2 - strokeWidth={1.5} - size={29} - style={{color: 'white'}} - /> - } - accessibilityRole="button" - accessibilityLabel="New post" - accessibilityHint="" - /> - </View> - ) - } + if (isCurateList) { return ( - <CenteredView sideBorders style={s.hContentRegion}> - <Header rkey={rkey} list={list} /> - {list.error ? <ErrorScreen error={list.error} /> : null} - </CenteredView> + <View style={s.hContentRegion}> + <PagerWithHeader + items={SECTION_TITLES_CURATE} + isHeaderReady={true} + renderHeader={renderHeader} + onCurrentPageSelected={onCurrentPageSelected}> + {({ + onScroll, + headerHeight, + isScrolledDown, + scrollElRef, + isFocused, + }) => ( + <FeedSection + ref={feedSectionRef} + feed={`list|${uri}`} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + onScroll={onScroll} + headerHeight={headerHeight} + isScrolledDown={isScrolledDown} + isFocused={isFocused} + /> + )} + {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( + <AboutSection + ref={aboutSectionRef} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + list={list} + onPressAddUser={onPressAddUser} + onScroll={onScroll} + headerHeight={headerHeight} + isScrolledDown={isScrolledDown} + /> + )} + </PagerWithHeader> + <FAB + testID="composeFAB" + onPress={() => openComposer({})} + icon={ + <ComposeIcon2 + strokeWidth={1.5} + size={29} + style={{color: 'white'}} + /> + } + accessibilityRole="button" + accessibilityLabel={_(msg`New post`)} + accessibilityHint="" + /> + </View> ) - }, -) + } + return ( + <View style={s.hContentRegion}> + <PagerWithHeader + items={SECTION_TITLES_MOD} + isHeaderReady={true} + renderHeader={renderHeader}> + {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( + <AboutSection + list={list} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + onPressAddUser={onPressAddUser} + onScroll={onScroll} + headerHeight={headerHeight} + isScrolledDown={isScrolledDown} + /> + )} + </PagerWithHeader> + <FAB + testID="composeFAB" + onPress={() => openComposer({})} + icon={ + <ComposeIcon2 strokeWidth={1.5} size={29} style={{color: 'white'}} /> + } + accessibilityRole="button" + accessibilityLabel={_(msg`New post`)} + accessibilityHint="" + /> + </View> + ) +} -const Header = observer(function HeaderImpl({ - rkey, - list, -}: { - rkey: string - list: ListModel -}) { +function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { const pal = usePalette('default') const palInverted = usePalette('inverted') - const store = useStores() + const {_} = useLingui() const navigation = useNavigation<NavigationProp>() + const {currentAccount} = useSession() + const {openModal, closeModal} = useModalControls() + const listMuteMutation = useListMuteMutation() + const listBlockMutation = useListBlockMutation() + const listDeleteMutation = useListDeleteMutation() + const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist' + const isModList = list.purpose === 'app.bsky.graph.defs#modlist' + const isPinned = false // TODO + const isBlocking = !!list.viewer?.blocked + const isMuting = !!list.viewer?.muted + const isOwner = list.creator.did === currentAccount?.did const onTogglePinned = useCallback(async () => { Haptics.default() - list.togglePin().catch(e => { - Toast.show('There was an issue contacting the server') - logger.error('Failed to toggle pinned list', {error: e}) - }) - }, [list]) + // TODO + // list.togglePin().catch(e => { + // Toast.show('There was an issue contacting the server') + // logger.error('Failed to toggle pinned list', {error: e}) + // }) + }, []) const onSubscribeMute = useCallback(() => { - store.shell.openModal({ + openModal({ name: 'confirm', - title: 'Mute these accounts?', - message: - 'Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.', + title: _(msg`Mute these accounts?`), + message: _( + msg`Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.`, + ), confirmBtnText: 'Mute this List', async onPressConfirm() { try { - await list.mute() + await listMuteMutation.mutateAsync({uri: list.uri, mute: true}) Toast.show('List muted') } catch { Toast.show( @@ -297,32 +287,33 @@ const Header = observer(function HeaderImpl({ } }, onPressCancel() { - store.shell.closeModal() + closeModal() }, }) - }, [store, list]) + }, [openModal, closeModal, list, listMuteMutation, _]) const onUnsubscribeMute = useCallback(async () => { try { - await list.unmute() + await listMuteMutation.mutateAsync({uri: list.uri, mute: false}) Toast.show('List unmuted') } catch { Toast.show( 'There was an issue. Please check your internet connection and try again.', ) } - }, [list]) + }, [list, listMuteMutation]) const onSubscribeBlock = useCallback(() => { - store.shell.openModal({ + openModal({ name: 'confirm', - title: 'Block these accounts?', - message: - 'Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.', + title: _(msg`Block these accounts?`), + message: _( + msg`Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, + ), confirmBtnText: 'Block this List', async onPressConfirm() { try { - await list.block() + await listBlockMutation.mutateAsync({uri: list.uri, block: true}) Toast.show('List blocked') } catch { Toast.show( @@ -331,39 +322,36 @@ const Header = observer(function HeaderImpl({ } }, onPressCancel() { - store.shell.closeModal() + closeModal() }, }) - }, [store, list]) + }, [openModal, closeModal, list, listBlockMutation, _]) const onUnsubscribeBlock = useCallback(async () => { try { - await list.unblock() + await listBlockMutation.mutateAsync({uri: list.uri, block: false}) Toast.show('List unblocked') } catch { Toast.show( 'There was an issue. Please check your internet connection and try again.', ) } - }, [list]) + }, [list, listBlockMutation]) const onPressEdit = useCallback(() => { - store.shell.openModal({ + openModal({ name: 'create-or-edit-list', list, - onSave() { - list.refresh() - }, }) - }, [store, list]) + }, [openModal, list]) const onPressDelete = useCallback(() => { - store.shell.openModal({ + openModal({ name: 'confirm', - title: 'Delete List', - message: 'Are you sure?', + title: _(msg`Delete List`), + message: _(msg`Are you sure?`), async onPressConfirm() { - await list.delete() + await listDeleteMutation.mutateAsync({uri: list.uri}) Toast.show('List deleted') if (navigation.canGoBack()) { navigation.goBack() @@ -372,30 +360,26 @@ const Header = observer(function HeaderImpl({ } }, }) - }, [store, list, navigation]) + }, [openModal, list, listDeleteMutation, navigation, _]) const onPressReport = useCallback(() => { - if (!list.data) return - store.shell.openModal({ + openModal({ name: 'report', uri: list.uri, - cid: list.data.cid, + cid: list.cid, }) - }, [store, list]) + }, [openModal, list]) const onPressShare = useCallback(() => { - const url = toShareUrl(`/profile/${list.creatorDid}/lists/${rkey}`) + const url = toShareUrl(`/profile/${list.creator.did}/lists/${rkey}`) shareUrl(url) - }, [list.creatorDid, rkey]) + }, [list, rkey]) const dropdownItems: DropdownItem[] = useMemo(() => { - if (!list.hasLoaded) { - return [] - } let items: DropdownItem[] = [ { testID: 'listHeaderDropdownShareBtn', - label: 'Share', + label: isWeb ? _(msg`Copy link to list`) : _(msg`Share`), onPress: onPressShare, icon: { ios: { @@ -406,11 +390,11 @@ const Header = observer(function HeaderImpl({ }, }, ] - if (list.isOwner) { + if (isOwner) { items.push({label: 'separator'}) items.push({ testID: 'listHeaderDropdownEditBtn', - label: 'Edit List Details', + label: _(msg`Edit list details`), onPress: onPressEdit, icon: { ios: { @@ -422,7 +406,7 @@ const Header = observer(function HeaderImpl({ }) items.push({ testID: 'listHeaderDropdownDeleteBtn', - label: 'Delete List', + label: _(msg`Delete List`), onPress: onPressDelete, icon: { ios: { @@ -436,7 +420,7 @@ const Header = observer(function HeaderImpl({ items.push({label: 'separator'}) items.push({ testID: 'listHeaderDropdownReportBtn', - label: 'Report List', + label: _(msg`Report List`), onPress: onPressReport, icon: { ios: { @@ -448,20 +432,13 @@ const Header = observer(function HeaderImpl({ }) } return items - }, [ - list.hasLoaded, - list.isOwner, - onPressShare, - onPressEdit, - onPressDelete, - onPressReport, - ]) + }, [isOwner, onPressShare, onPressEdit, onPressDelete, onPressReport, _]) const subscribeDropdownItems: DropdownItem[] = useMemo(() => { return [ { testID: 'subscribeDropdownMuteBtn', - label: 'Mute accounts', + label: _(msg`Mute accounts`), onPress: onSubscribeMute, icon: { ios: { @@ -473,7 +450,7 @@ const Header = observer(function HeaderImpl({ }, { testID: 'subscribeDropdownBlockBtn', - label: 'Block accounts', + label: _(msg`Block accounts`), onPress: onSubscribeBlock, icon: { ios: { @@ -484,36 +461,32 @@ const Header = observer(function HeaderImpl({ }, }, ] - }, [onSubscribeMute, onSubscribeBlock]) + }, [onSubscribeMute, onSubscribeBlock, _]) return ( <ProfileSubpageHeader - isLoading={!list.hasLoaded} - href={makeListLink( - list.data?.creator.handle || list.data?.creator.did || '', - rkey, - )} - title={list.data?.name || 'User list'} - avatar={list.data?.avatar} - isOwner={list.isOwner} - creator={list.data?.creator} + href={makeListLink(list.creator.handle || list.creator.did || '', rkey)} + title={list.name} + avatar={list.avatar} + isOwner={list.creator.did === currentAccount?.did} + creator={list.creator} avatarType="list"> - {list.isCuratelist || list.isPinned ? ( + {isCurateList || isPinned ? ( <Button testID={list.isPinned ? 'unpinBtn' : 'pinBtn'} type={list.isPinned ? 'default' : 'inverted'} label={list.isPinned ? 'Unpin' : 'Pin to home'} onPress={onTogglePinned} /> - ) : list.isModlist ? ( - list.isBlocking ? ( + ) : isModList ? ( + isBlocking ? ( <Button testID="unblockBtn" type="default" label="Unblock" onPress={onUnsubscribeBlock} /> - ) : list.isMuting ? ( + ) : isMuting ? ( <Button testID="unmuteBtn" type="default" @@ -524,10 +497,12 @@ const Header = observer(function HeaderImpl({ <NativeDropdown testID="subscribeBtn" items={subscribeDropdownItems} - accessibilityLabel="Subscribe to this list" + accessibilityLabel={_(msg`Subscribe to this list`)} accessibilityHint=""> <View style={[palInverted.view, styles.btn]}> - <Text style={palInverted.text}>Subscribe</Text> + <Text style={palInverted.text}> + <Trans>Subscribe</Trans> + </Text> </View> </NativeDropdown> ) @@ -535,7 +510,7 @@ const Header = observer(function HeaderImpl({ <NativeDropdown testID="headerDropdownBtn" items={dropdownItems} - accessibilityLabel="More options" + accessibilityLabel={_(msg`More options`)} accessibilityHint=""> <View style={[pal.viewLight, styles.btn]}> <FontAwesomeIcon icon="ellipsis" size={20} color={pal.colors.text} /> @@ -543,26 +518,29 @@ const Header = observer(function HeaderImpl({ </NativeDropdown> </ProfileSubpageHeader> ) -}) +} interface FeedSectionProps { - feed: PostsFeedModel - onScroll: (e: NativeScrollEvent) => void + feed: FeedDescriptor + onScroll: OnScrollHandler headerHeight: number isScrolledDown: boolean + scrollElRef: React.MutableRefObject<FlatList<any> | null> + isFocused: boolean } const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( function FeedSectionImpl( - {feed, onScroll, headerHeight, isScrolledDown}, + {feed, scrollElRef, onScroll, headerHeight, isScrolledDown, isFocused}, ref, ) { - const hasNew = feed.hasNewLatest && !feed.isRefreshing - const scrollElRef = React.useRef<FlatList>(null) + const queryClient = useQueryClient() + const [hasNew, setHasNew] = React.useState(false) const onScrollToTop = useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerHeight}) - feed.refresh() - }, [feed, scrollElRef, headerHeight]) + queryClient.resetQueries({queryKey: FEED_RQKEY(feed)}) + setHasNew(false) + }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) React.useImperativeHandle(ref, () => ({ scrollToTop: onScrollToTop, })) @@ -571,14 +549,16 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( return <EmptyState icon="feed" message="This feed is empty!" /> }, []) - const scrollHandler = useAnimatedScrollHandler({onScroll}) return ( <View> <Feed testID="listFeed" + enabled={isFocused} feed={feed} + pollInterval={30e3} scrollElRef={scrollElRef} - onScroll={scrollHandler} + onHasNew={setHasNew} + onScroll={onScroll} scrollEventThrottle={1} renderEmptyState={renderPostsEmpty} headerOffset={headerHeight} @@ -596,34 +576,35 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( ) interface AboutSectionProps { - list: ListModel - descriptionRT: RichTextAPI | null - creator: {did: string; handle: string} | undefined - isCurateList: boolean | undefined - isOwner: boolean | undefined + list: AppBskyGraphDefs.ListView onPressAddUser: () => void - onScroll: (e: NativeScrollEvent) => void + onScroll: OnScrollHandler headerHeight: number isScrolledDown: boolean + scrollElRef: React.MutableRefObject<FlatList<any> | null> } const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( function AboutSectionImpl( - { - list, - descriptionRT, - creator, - isCurateList, - isOwner, - onPressAddUser, - onScroll, - headerHeight, - isScrolledDown, - }, + {list, onPressAddUser, onScroll, headerHeight, isScrolledDown, scrollElRef}, ref, ) { const pal = usePalette('default') + const {_} = useLingui() const {isMobile} = useWebMediaQueries() - const scrollElRef = React.useRef<FlatList>(null) + const {currentAccount} = useSession() + const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist' + const isOwner = list.creator.did === currentAccount?.did + + const descriptionRT = useMemo( + () => + list.description + ? new RichTextAPI({ + text: list.description, + facets: list.descriptionFacets, + }) + : undefined, + [list], + ) const onScrollToTop = useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerHeight}) @@ -634,9 +615,6 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( })) const renderHeader = React.useCallback(() => { - if (!list.data) { - return <View /> - } return ( <View> <View @@ -660,7 +638,7 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( testID="listDescriptionEmpty" type="lg" style={[{fontStyle: 'italic'}, pal.textLight]}> - No description + <Trans>No description</Trans> </Text> )} <Text type="md" style={[pal.textLight]} numberOfLines={1}> @@ -669,8 +647,8 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( 'you' ) : ( <TextLink - text={sanitizeHandle(creator?.handle || '', '@')} - href={creator ? makeProfileLink(creator) : ''} + text={sanitizeHandle(list.creator.handle || '', '@')} + href={makeProfileLink(list.creator)} style={pal.textLight} /> )} @@ -686,12 +664,14 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( paddingBottom: isMobile ? 14 : 18, }, ]}> - <Text type="lg-bold">Users</Text> + <Text type="lg-bold"> + <Trans>Users</Trans> + </Text> {isOwner && ( <Pressable testID="addUserBtn" accessibilityRole="button" - accessibilityLabel="Add a user to this list" + accessibilityLabel={_(msg`Add a user to this list`)} accessibilityHint="" onPress={onPressAddUser} style={{flexDirection: 'row', alignItems: 'center', gap: 6}}> @@ -700,7 +680,9 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( color={pal.colors.link} size={16} /> - <Text style={pal.link}>Add</Text> + <Text style={pal.link}> + <Trans>Add</Trans> + </Text> </Pressable> )} </View> @@ -708,13 +690,13 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( ) }, [ pal, - list.data, + list, isMobile, descriptionRT, - creator, isCurateList, isOwner, onPressAddUser, + _, ]) const renderEmptyState = useCallback(() => { @@ -727,17 +709,16 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( ) }, []) - const scrollHandler = useAnimatedScrollHandler({onScroll}) return ( <View> - <ListItems + <ListMembers testID="listItems" + list={list.uri} scrollElRef={scrollElRef} renderHeader={renderHeader} renderEmptyState={renderEmptyState} - list={list} headerOffset={headerHeight} - onScroll={scrollHandler} + onScroll={onScroll} scrollEventThrottle={1} /> {isScrolledDown && ( @@ -755,6 +736,7 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( function ErrorScreen({error}: {error: string}) { const pal = usePalette('default') const navigation = useNavigation<NavigationProp>() + const {_} = useLingui() const onPressBack = useCallback(() => { if (navigation.canGoBack()) { navigation.goBack() @@ -776,7 +758,7 @@ function ErrorScreen({error}: {error: string}) { }, ]}> <Text type="title-lg" style={[pal.text, s.mb10]}> - Could not load list + <Trans>Could not load list</Trans> </Text> <Text type="md" style={[pal.text, s.mb20]}> {error} @@ -785,12 +767,12 @@ function ErrorScreen({error}: {error: string}) { <View style={{flexDirection: 'row'}}> <Button type="default" - accessibilityLabel="Go Back" + accessibilityLabel={_(msg`Go Back`)} accessibilityHint="Return to previous page" onPress={onPressBack} style={{flexShrink: 1}}> <Text type="button" style={pal.text}> - Go Back + <Trans>Go Back</Trans> </Text> </Button> </View> diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx index 487f56643..858a58a3c 100644 --- a/src/view/screens/SavedFeeds.tsx +++ b/src/view/screens/SavedFeeds.tsx @@ -1,33 +1,32 @@ -import React, {useCallback, useMemo} from 'react' -import { - StyleSheet, - View, - ActivityIndicator, - Pressable, - TouchableOpacity, -} from 'react-native' +import React from 'react' +import {StyleSheet, View, ActivityIndicator, Pressable} from 'react-native' import {useFocusEffect} from '@react-navigation/native' import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {track} from '#/lib/analytics/analytics' import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' import {CommonNavigatorParams} from 'lib/routes/types' -import {observer} from 'mobx-react-lite' -import {useStores} from 'state/index' -import {SavedFeedsModel} from 'state/models/ui/saved-feeds' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from 'view/com/util/ViewHeader' import {ScrollView, CenteredView} from 'view/com/util/Views' import {Text} from 'view/com/util/text/Text' import {s, colors} from 'lib/styles' import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' -import {FeedSourceModel} from 'state/models/content/feed-source' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import * as Toast from 'view/com/util/Toast' import {Haptics} from 'lib/haptics' import {TextLink} from 'view/com/util/Link' import {logger} from '#/logger' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import { + usePreferencesQuery, + usePinFeedMutation, + useUnpinFeedMutation, + useSetSaveFeedsMutation, +} from '#/state/queries/preferences' +import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' const HITSLOP_TOP = { top: 20, @@ -43,99 +42,118 @@ const HITSLOP_BOTTOM = { } type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'> -export const SavedFeeds = withAuthRequired( - observer(function SavedFeedsImpl({}: Props) { - const pal = usePalette('default') - const store = useStores() - const {isMobile, isTabletOrDesktop} = useWebMediaQueries() - const {screen} = useAnalytics() - const setMinimalShellMode = useSetMinimalShellMode() +export function SavedFeeds({}: Props) { + const pal = usePalette('default') + const {_} = useLingui() + const {isMobile, isTabletOrDesktop} = useWebMediaQueries() + const {screen} = useAnalytics() + const setMinimalShellMode = useSetMinimalShellMode() + const {data: preferences} = usePreferencesQuery() + const { + mutateAsync: setSavedFeeds, + variables: optimisticSavedFeedsResponse, + reset: resetSaveFeedsMutationState, + error: setSavedFeedsError, + } = useSetSaveFeedsMutation() + + /* + * Use optimistic data if exists and no error, otherwise fallback to remote + * data + */ + const currentFeeds = + optimisticSavedFeedsResponse && !setSavedFeedsError + ? optimisticSavedFeedsResponse + : preferences?.feeds || {saved: [], pinned: []} + const unpinned = currentFeeds.saved.filter(f => { + return !currentFeeds.pinned?.includes(f) + }) - const savedFeeds = useMemo(() => { - const model = new SavedFeedsModel(store) - model.refresh() - return model - }, [store]) - useFocusEffect( - useCallback(() => { - screen('SavedFeeds') - setMinimalShellMode(false) - savedFeeds.refresh() - }, [screen, setMinimalShellMode, savedFeeds]), - ) + useFocusEffect( + React.useCallback(() => { + screen('SavedFeeds') + setMinimalShellMode(false) + }, [screen, setMinimalShellMode]), + ) - return ( - <CenteredView - style={[ - s.hContentRegion, - pal.border, - isTabletOrDesktop && styles.desktopContainer, - ]}> - <ViewHeader title="Edit My Feeds" showOnDesktop showBorder /> - <ScrollView style={s.flex1}> - <View style={[pal.text, pal.border, styles.title]}> - <Text type="title" style={pal.text}> - Pinned Feeds - </Text> - </View> - {savedFeeds.hasLoaded ? ( - !savedFeeds.pinned.length ? ( - <View - style={[ - pal.border, - isMobile && s.flex1, - pal.viewLight, - styles.empty, - ]}> - <Text type="lg" style={[pal.text]}> - You don't have any pinned feeds. - </Text> - </View> - ) : ( - savedFeeds.pinned.map(feed => ( - <ListItem - key={feed._reactKey} - savedFeeds={savedFeeds} - item={feed} - /> - )) - ) + return ( + <CenteredView + style={[ + s.hContentRegion, + pal.border, + isTabletOrDesktop && styles.desktopContainer, + ]}> + <ViewHeader title={_(msg`Edit My Feeds`)} showOnDesktop showBorder /> + <ScrollView style={s.flex1}> + <View style={[pal.text, pal.border, styles.title]}> + <Text type="title" style={pal.text}> + <Trans>Pinned Feeds</Trans> + </Text> + </View> + {preferences?.feeds ? ( + !currentFeeds.pinned.length ? ( + <View + style={[ + pal.border, + isMobile && s.flex1, + pal.viewLight, + styles.empty, + ]}> + <Text type="lg" style={[pal.text]}> + <Trans>You don't have any pinned feeds.</Trans> + </Text> + </View> ) : ( - <ActivityIndicator style={{marginTop: 20}} /> - )} - <View style={[pal.text, pal.border, styles.title]}> - <Text type="title" style={pal.text}> - Saved Feeds - </Text> - </View> - {savedFeeds.hasLoaded ? ( - !savedFeeds.unpinned.length ? ( - <View - style={[ - pal.border, - isMobile && s.flex1, - pal.viewLight, - styles.empty, - ]}> - <Text type="lg" style={[pal.text]}> - You don't have any saved feeds. - </Text> - </View> - ) : ( - savedFeeds.unpinned.map(feed => ( - <ListItem - key={feed._reactKey} - savedFeeds={savedFeeds} - item={feed} - /> - )) - ) + currentFeeds.pinned.map(uri => ( + <ListItem + key={uri} + feedUri={uri} + isPinned + setSavedFeeds={setSavedFeeds} + resetSaveFeedsMutationState={resetSaveFeedsMutationState} + currentFeeds={currentFeeds} + /> + )) + ) + ) : ( + <ActivityIndicator style={{marginTop: 20}} /> + )} + <View style={[pal.text, pal.border, styles.title]}> + <Text type="title" style={pal.text}> + <Trans>Saved Feeds</Trans> + </Text> + </View> + {preferences?.feeds ? ( + !unpinned.length ? ( + <View + style={[ + pal.border, + isMobile && s.flex1, + pal.viewLight, + styles.empty, + ]}> + <Text type="lg" style={[pal.text]}> + <Trans>You don't have any saved feeds.</Trans> + </Text> + </View> ) : ( - <ActivityIndicator style={{marginTop: 20}} /> - )} + unpinned.map(uri => ( + <ListItem + key={uri} + feedUri={uri} + isPinned={false} + setSavedFeeds={setSavedFeeds} + resetSaveFeedsMutationState={resetSaveFeedsMutationState} + currentFeeds={currentFeeds} + /> + )) + ) + ) : ( + <ActivityIndicator style={{marginTop: 20}} /> + )} - <View style={styles.footerText}> - <Text type="sm" style={pal.textLight}> + <View style={styles.footerText}> + <Text type="sm" style={pal.textLight}> + <Trans> Feeds are custom algorithms that users build with a little coding expertise.{' '} <TextLink @@ -145,48 +163,95 @@ export const SavedFeeds = withAuthRequired( text="See this guide" />{' '} for more information. - </Text> - </View> - <View style={{height: 100}} /> - </ScrollView> - </CenteredView> - ) - }), -) + </Trans> + </Text> + </View> + <View style={{height: 100}} /> + </ScrollView> + </CenteredView> + ) +} -const ListItem = observer(function ListItemImpl({ - savedFeeds, - item, +function ListItem({ + feedUri, + isPinned, + currentFeeds, + setSavedFeeds, + resetSaveFeedsMutationState, }: { - savedFeeds: SavedFeedsModel - item: FeedSourceModel + feedUri: string // uri + isPinned: boolean + currentFeeds: {saved: string[]; pinned: string[]} + setSavedFeeds: ReturnType<typeof useSetSaveFeedsMutation>['mutateAsync'] + resetSaveFeedsMutationState: ReturnType< + typeof useSetSaveFeedsMutation + >['reset'] }) { const pal = usePalette('default') - const isPinned = item.isPinned + const {isPending: isPinPending, mutateAsync: pinFeed} = usePinFeedMutation() + const {isPending: isUnpinPending, mutateAsync: unpinFeed} = + useUnpinFeedMutation() + const isPending = isPinPending || isUnpinPending - const onTogglePinned = useCallback(() => { + const onTogglePinned = React.useCallback(async () => { Haptics.default() - item.togglePin().catch(e => { + + try { + resetSaveFeedsMutationState() + + if (isPinned) { + await unpinFeed({uri: feedUri}) + } else { + await pinFeed({uri: feedUri}) + } + } catch (e) { Toast.show('There was an issue contacting the server') logger.error('Failed to toggle pinned feed', {error: e}) - }) - }, [item]) - const onPressUp = useCallback( - () => - savedFeeds.movePinnedFeed(item, 'up').catch(e => { - Toast.show('There was an issue contacting the server') - logger.error('Failed to set pinned feed order', {error: e}) - }), - [savedFeeds, item], - ) - const onPressDown = useCallback( - () => - savedFeeds.movePinnedFeed(item, 'down').catch(e => { - Toast.show('There was an issue contacting the server') - logger.error('Failed to set pinned feed order', {error: e}) - }), - [savedFeeds, item], - ) + } + }, [feedUri, isPinned, pinFeed, unpinFeed, resetSaveFeedsMutationState]) + + const onPressUp = React.useCallback(async () => { + if (!isPinned) return + + // create new array, do not mutate + const pinned = [...currentFeeds.pinned] + const index = pinned.indexOf(feedUri) + + if (index === -1 || index === 0) return + ;[pinned[index], pinned[index - 1]] = [pinned[index - 1], pinned[index]] + + try { + await setSavedFeeds({saved: currentFeeds.saved, pinned}) + track('CustomFeed:Reorder', { + uri: feedUri, + index: pinned.indexOf(feedUri), + }) + } catch (e) { + Toast.show('There was an issue contacting the server') + logger.error('Failed to set pinned feed order', {error: e}) + } + }, [feedUri, isPinned, setSavedFeeds, currentFeeds]) + + const onPressDown = React.useCallback(async () => { + if (!isPinned) return + + const pinned = [...currentFeeds.pinned] + const index = pinned.indexOf(feedUri) + + if (index === -1 || index >= pinned.length - 1) return + ;[pinned[index], pinned[index + 1]] = [pinned[index + 1], pinned[index]] + + try { + await setSavedFeeds({saved: currentFeeds.saved, pinned}) + track('CustomFeed:Reorder', { + uri: feedUri, + index: pinned.indexOf(feedUri), + }) + } catch (e) { + Toast.show('There was an issue contacting the server') + logger.error('Failed to set pinned feed order', {error: e}) + } + }, [feedUri, isPinned, setSavedFeeds, currentFeeds]) return ( <Pressable @@ -194,43 +259,62 @@ const ListItem = observer(function ListItemImpl({ style={[styles.itemContainer, pal.border]}> {isPinned ? ( <View style={styles.webArrowButtonsContainer}> - <TouchableOpacity + <Pressable + disabled={isPending} accessibilityRole="button" onPress={onPressUp} - hitSlop={HITSLOP_TOP}> + hitSlop={HITSLOP_TOP} + style={state => ({ + opacity: state.hovered || state.focused || isPending ? 0.5 : 1, + })}> <FontAwesomeIcon icon="arrow-up" size={12} style={[pal.text, styles.webArrowUpButton]} /> - </TouchableOpacity> - <TouchableOpacity + </Pressable> + <Pressable + disabled={isPending} accessibilityRole="button" onPress={onPressDown} - hitSlop={HITSLOP_BOTTOM}> + hitSlop={HITSLOP_BOTTOM} + style={state => ({ + opacity: state.hovered || state.focused || isPending ? 0.5 : 1, + })}> <FontAwesomeIcon icon="arrow-down" size={12} style={[pal.text]} /> - </TouchableOpacity> + </Pressable> </View> ) : null} <FeedSourceCard - key={item.uri} - item={item} - showSaveBtn + key={feedUri} + feedUri={feedUri} style={styles.noBorder} + showSaveBtn + LoadingComponent={ + <FeedLoadingPlaceholder + style={{flex: 1}} + showLowerPlaceholder={false} + showTopBorder={false} + /> + } /> - <TouchableOpacity + <Pressable + disabled={isPending} accessibilityRole="button" hitSlop={10} - onPress={onTogglePinned}> + onPress={onTogglePinned} + style={state => ({ + opacity: state.hovered || state.focused || isPending ? 0.5 : 1, + })}> <FontAwesomeIcon icon="thumb-tack" size={20} color={isPinned ? colors.blue3 : pal.colors.icon} /> - </TouchableOpacity> + </Pressable> </Pressable> ) -}) +} const styles = StyleSheet.create({ desktopContainer: { diff --git a/src/view/screens/Search.tsx b/src/view/screens/Search.tsx deleted file mode 100644 index bf9857df4..000000000 --- a/src/view/screens/Search.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from './SearchMobile' diff --git a/src/view/screens/Search.web.tsx b/src/view/screens/Search.web.tsx deleted file mode 100644 index 2d0c0288a..000000000 --- a/src/view/screens/Search.web.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React from 'react' -import {View, StyleSheet} from 'react-native' -import {SearchUIModel} from 'state/models/ui/search' -import {FoafsModel} from 'state/models/discovery/foafs' -import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {Suggestions} from 'view/com/search/Suggestions' -import {SearchResults} from 'view/com/search/SearchResults' -import {observer} from 'mobx-react-lite' -import { - NativeStackScreenProps, - SearchTabNavigatorParams, -} from 'lib/routes/types' -import {useStores} from 'state/index' -import {CenteredView} from 'view/com/util/Views' -import * as Mobile from './SearchMobile' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' - -type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'> -export const SearchScreen = withAuthRequired( - observer(function SearchScreenImpl({navigation, route}: Props) { - const store = useStores() - const params = route.params || {} - const foafs = React.useMemo<FoafsModel>( - () => new FoafsModel(store), - [store], - ) - const suggestedActors = React.useMemo<SuggestedActorsModel>( - () => new SuggestedActorsModel(store), - [store], - ) - const searchUIModel = React.useMemo<SearchUIModel | undefined>( - () => (params.q ? new SearchUIModel(store) : undefined), - [params.q, store], - ) - - React.useEffect(() => { - if (params.q && searchUIModel) { - searchUIModel.fetch(params.q) - } - if (!foafs.hasData) { - foafs.fetch() - } - if (!suggestedActors.hasLoaded) { - suggestedActors.loadMore(true) - } - }, [foafs, suggestedActors, searchUIModel, params.q]) - - const {isDesktop} = useWebMediaQueries() - - if (searchUIModel) { - return ( - <View style={styles.scrollContainer}> - <SearchResults model={searchUIModel} /> - </View> - ) - } - - if (!isDesktop) { - return ( - <CenteredView style={styles.scrollContainer}> - <Mobile.SearchScreen navigation={navigation} route={route} /> - </CenteredView> - ) - } - - return <Suggestions foafs={foafs} suggestedActors={suggestedActors} /> - }), -) - -const styles = StyleSheet.create({ - scrollContainer: { - height: '100%', - overflowY: 'auto', - }, -}) diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx new file mode 100644 index 000000000..f031abcc2 --- /dev/null +++ b/src/view/screens/Search/Search.tsx @@ -0,0 +1,658 @@ +import React from 'react' +import { + View, + StyleSheet, + ActivityIndicator, + RefreshControl, + TextInput, + Pressable, + Platform, +} from 'react-native' +import {FlatList, ScrollView, CenteredView} from '#/view/com/util/Views' +import {AppBskyActorDefs, AppBskyFeedDefs, moderateProfile} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {useFocusEffect} from '@react-navigation/native' + +import {logger} from '#/logger' +import { + NativeStackScreenProps, + SearchTabNavigatorParams, +} from 'lib/routes/types' +import {Text} from '#/view/com/util/text/Text' +import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' +import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' +import {Post} from '#/view/com/post/Post' +import {Pager} from '#/view/com/pager/Pager' +import {TabBar} from '#/view/com/pager/TabBar' +import {HITSLOP_10} from '#/lib/constants' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {usePalette} from '#/lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' +import {useSession} from '#/state/session' +import {useGetSuggestedFollowersByActor} from '#/state/queries/suggested-follows' +import {useSearchPostsQuery} from '#/state/queries/search-posts' +import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' +import {useSetDrawerOpen} from '#/state/shell' +import {useAnalytics} from '#/lib/analytics/analytics' +import {MagnifyingGlassIcon} from '#/lib/icons' +import {useModerationOpts} from '#/state/queries/preferences' +import {SearchResultCard} from '#/view/shell/desktop/Search' +import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell' +import {isWeb} from '#/platform/detection' +import {listenSoftReset} from '#/state/events' +import {s} from '#/lib/styles' + +function Loader() { + const pal = usePalette('default') + const {isMobile} = useWebMediaQueries() + return ( + <CenteredView + style={[ + // @ts-ignore web only -prf + { + padding: 18, + height: isWeb ? '100vh' : undefined, + }, + pal.border, + ]} + sideBorders={!isMobile}> + <ActivityIndicator /> + </CenteredView> + ) +} + +function EmptyState({message, error}: {message: string; error?: string}) { + const pal = usePalette('default') + const {isMobile} = useWebMediaQueries() + + return ( + <CenteredView + sideBorders={!isMobile} + style={[ + pal.border, + // @ts-ignore web only -prf + { + padding: 18, + height: isWeb ? '100vh' : undefined, + }, + ]}> + <View style={[pal.viewLight, {padding: 18, borderRadius: 8}]}> + <Text style={[pal.text]}> + <Trans>{message}</Trans> + </Text> + + {error && ( + <> + <View + style={[ + { + marginVertical: 12, + height: 1, + width: '100%', + backgroundColor: pal.text.color, + opacity: 0.2, + }, + ]} + /> + + <Text style={[pal.textLight]}> + <Trans>Error:</Trans> {error} + </Text> + </> + )} + </View> + </CenteredView> + ) +} + +function SearchScreenSuggestedFollows() { + const pal = usePalette('default') + const {currentAccount} = useSession() + const [suggestions, setSuggestions] = React.useState< + AppBskyActorDefs.ProfileViewBasic[] + >([]) + const getSuggestedFollowsByActor = useGetSuggestedFollowersByActor() + + React.useEffect(() => { + async function getSuggestions() { + const friends = await getSuggestedFollowsByActor( + currentAccount!.did, + ).then(friendsRes => friendsRes.suggestions) + + if (!friends) return // :( + + const friendsOfFriends = new Map< + string, + AppBskyActorDefs.ProfileViewBasic + >() + + await Promise.all( + friends.slice(0, 4).map(friend => + getSuggestedFollowsByActor(friend.did).then(foafsRes => { + for (const user of foafsRes.suggestions) { + friendsOfFriends.set(user.did, user) + } + }), + ), + ) + + setSuggestions(Array.from(friendsOfFriends.values())) + } + + try { + getSuggestions() + } catch (e) { + logger.error(`SearchScreenSuggestedFollows: failed to get suggestions`, { + error: e, + }) + } + }, [currentAccount, setSuggestions, getSuggestedFollowsByActor]) + + return suggestions.length ? ( + <FlatList + data={suggestions} + renderItem={({item}) => <ProfileCardWithFollowBtn profile={item} noBg />} + keyExtractor={item => item.did} + // @ts-ignore web only -prf + desktopFixedHeight + contentContainerStyle={{paddingBottom: 1200}} + /> + ) : ( + <CenteredView sideBorders style={[pal.border, s.hContentRegion]}> + <ProfileCardFeedLoadingPlaceholder /> + <ProfileCardFeedLoadingPlaceholder /> + </CenteredView> + ) +} + +type SearchResultSlice = + | { + type: 'post' + key: string + post: AppBskyFeedDefs.PostView + } + | { + type: 'loadingMore' + key: string + } + +function SearchScreenPostResults({query}: {query: string}) { + const {_} = useLingui() + const pal = usePalette('default') + const [isPTR, setIsPTR] = React.useState(false) + const { + isFetched, + data: results, + isFetching, + error, + refetch, + fetchNextPage, + isFetchingNextPage, + hasNextPage, + } = useSearchPostsQuery({query}) + + const onPullToRefresh = React.useCallback(async () => { + setIsPTR(true) + await refetch() + setIsPTR(false) + }, [setIsPTR, refetch]) + const onEndReached = React.useCallback(() => { + if (isFetching || !hasNextPage || error) return + fetchNextPage() + }, [isFetching, error, hasNextPage, fetchNextPage]) + + const posts = React.useMemo(() => { + return results?.pages.flatMap(page => page.posts) || [] + }, [results]) + const items = React.useMemo(() => { + let temp: SearchResultSlice[] = [] + + for (const post of posts) { + temp.push({ + type: 'post', + key: post.uri, + post, + }) + } + + if (isFetchingNextPage) { + temp.push({ + type: 'loadingMore', + key: 'loadingMore', + }) + } + + return temp + }, [posts, isFetchingNextPage]) + + return error ? ( + <EmptyState + message={_( + msg`We're sorry, but your search could not be completed. Please try again in a few minutes.`, + )} + error={error.toString()} + /> + ) : ( + <> + {isFetched ? ( + <> + {posts.length ? ( + <FlatList + data={items} + renderItem={({item}) => { + if (item.type === 'post') { + return <Post post={item.post} /> + } else { + return <Loader /> + } + }} + keyExtractor={item => item.key} + refreshControl={ + <RefreshControl + refreshing={isPTR} + onRefresh={onPullToRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + /> + } + onEndReached={onEndReached} + // @ts-ignore web only -prf + desktopFixedHeight + contentContainerStyle={{paddingBottom: 100}} + /> + ) : ( + <EmptyState message={_(msg`No results found for ${query}`)} /> + )} + </> + ) : ( + <Loader /> + )} + </> + ) +} + +function SearchScreenUserResults({query}: {query: string}) { + const {_} = useLingui() + const [isFetched, setIsFetched] = React.useState(false) + const [results, setResults] = React.useState< + AppBskyActorDefs.ProfileViewBasic[] + >([]) + const search = useActorAutocompleteFn() + + React.useEffect(() => { + async function getResults() { + try { + const searchResults = await search({query, limit: 30}) + + if (searchResults) { + setResults(searchResults) + } + } catch (e: any) { + logger.error(`SearchScreenUserResults: failed to get results`, { + error: e.toString(), + }) + } finally { + setIsFetched(true) + } + } + + if (query) { + getResults() + } else { + setResults([]) + setIsFetched(false) + } + }, [query, search, setResults]) + + return isFetched ? ( + <> + {results.length ? ( + <FlatList + data={results} + renderItem={({item}) => ( + <ProfileCardWithFollowBtn profile={item} noBg /> + )} + keyExtractor={item => item.did} + // @ts-ignore web only -prf + desktopFixedHeight + contentContainerStyle={{paddingBottom: 100}} + /> + ) : ( + <EmptyState message={_(msg`No results found for ${query}`)} /> + )} + </> + ) : ( + <Loader /> + ) +} + +const SECTIONS = ['Posts', 'Users'] +export function SearchScreenInner({query}: {query?: string}) { + const pal = usePalette('default') + const setMinimalShellMode = useSetMinimalShellMode() + const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() + const {hasSession} = useSession() + const {isDesktop} = useWebMediaQueries() + + const onPageSelected = React.useCallback( + (index: number) => { + setMinimalShellMode(false) + setDrawerSwipeDisabled(index > 0) + }, + [setDrawerSwipeDisabled, setMinimalShellMode], + ) + + return query ? ( + <Pager + tabBarPosition="top" + onPageSelected={onPageSelected} + renderTabBar={props => ( + <CenteredView sideBorders style={pal.border}> + <TabBar items={SECTIONS} {...props} /> + </CenteredView> + )} + initialPage={0}> + <View> + <SearchScreenPostResults query={query} /> + </View> + <View> + <SearchScreenUserResults query={query} /> + </View> + </Pager> + ) : hasSession ? ( + <View> + <CenteredView sideBorders style={pal.border}> + <Text + type="title" + style={[ + pal.text, + pal.border, + { + display: 'flex', + paddingVertical: 12, + paddingHorizontal: 18, + fontWeight: 'bold', + }, + ]}> + <Trans>Suggested Follows</Trans> + </Text> + </CenteredView> + + <SearchScreenSuggestedFollows /> + </View> + ) : ( + <CenteredView sideBorders style={pal.border}> + <View + // @ts-ignore web only -esb + style={{ + height: Platform.select({web: '100vh'}), + }}> + {isDesktop && ( + <Text + type="title" + style={[ + pal.text, + pal.border, + { + display: 'flex', + paddingVertical: 12, + paddingHorizontal: 18, + fontWeight: 'bold', + borderBottomWidth: 1, + }, + ]}> + <Trans>Search</Trans> + </Text> + )} + + <Text + style={[ + pal.textLight, + {textAlign: 'center', paddingVertical: 12, paddingHorizontal: 18}, + ]}> + <Trans>Search for posts and users.</Trans> + </Text> + </View> + </CenteredView> + ) +} + +export function SearchScreenDesktop( + props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, +) { + const {isDesktop} = useWebMediaQueries() + + return isDesktop ? ( + <SearchScreenInner query={props.route.params?.q} /> + ) : ( + <SearchScreenMobile {...props} /> + ) +} + +export function SearchScreenMobile( + props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, +) { + const theme = useTheme() + const textInput = React.useRef<TextInput>(null) + const {_} = useLingui() + const pal = usePalette('default') + const {track} = useAnalytics() + const setDrawerOpen = useSetDrawerOpen() + const moderationOpts = useModerationOpts() + const search = useActorAutocompleteFn() + const setMinimalShellMode = useSetMinimalShellMode() + const {isTablet} = useWebMediaQueries() + + const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>( + undefined, + ) + const [isFetching, setIsFetching] = React.useState<boolean>(false) + const [query, setQuery] = React.useState<string>(props.route?.params?.q || '') + const [searchResults, setSearchResults] = React.useState< + AppBskyActorDefs.ProfileViewBasic[] + >([]) + const [inputIsFocused, setInputIsFocused] = React.useState(false) + const [showAutocompleteResults, setShowAutocompleteResults] = + React.useState(false) + + const onPressMenu = React.useCallback(() => { + track('ViewHeader:MenuButtonClicked') + setDrawerOpen(true) + }, [track, setDrawerOpen]) + const onPressCancelSearch = React.useCallback(() => { + textInput.current?.blur() + setQuery('') + setShowAutocompleteResults(false) + if (searchDebounceTimeout.current) + clearTimeout(searchDebounceTimeout.current) + }, [textInput]) + const onPressClearQuery = React.useCallback(() => { + setQuery('') + setShowAutocompleteResults(false) + }, [setQuery]) + const onChangeText = React.useCallback( + async (text: string) => { + setQuery(text) + + if (text.length > 0) { + setIsFetching(true) + setShowAutocompleteResults(true) + + if (searchDebounceTimeout.current) + clearTimeout(searchDebounceTimeout.current) + + searchDebounceTimeout.current = setTimeout(async () => { + const results = await search({query: text, limit: 30}) + + if (results) { + setSearchResults(results) + setIsFetching(false) + } + }, 300) + } else { + if (searchDebounceTimeout.current) + clearTimeout(searchDebounceTimeout.current) + setSearchResults([]) + setIsFetching(false) + setShowAutocompleteResults(false) + } + }, + [setQuery, search, setSearchResults], + ) + const onSubmit = React.useCallback(() => { + setShowAutocompleteResults(false) + }, [setShowAutocompleteResults]) + + const onSoftReset = React.useCallback(() => { + onPressCancelSearch() + }, [onPressCancelSearch]) + + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + return listenSoftReset(onSoftReset) + }, [onSoftReset, setMinimalShellMode]), + ) + + return ( + <View style={{flex: 1}}> + <CenteredView style={[styles.header, pal.border]} sideBorders={isTablet}> + <Pressable + testID="viewHeaderBackOrMenuBtn" + onPress={onPressMenu} + hitSlop={HITSLOP_10} + style={styles.headerMenuBtn} + accessibilityRole="button" + accessibilityLabel={_(msg`Menu`)} + accessibilityHint="Access navigation links and settings"> + <FontAwesomeIcon icon="bars" size={18} color={pal.colors.textLight} /> + </Pressable> + + <View + style={[ + {backgroundColor: pal.colors.backgroundLight}, + styles.headerSearchContainer, + ]}> + <MagnifyingGlassIcon + style={[pal.icon, styles.headerSearchIcon]} + size={21} + /> + <TextInput + testID="searchTextInput" + ref={textInput} + placeholder="Search" + placeholderTextColor={pal.colors.textLight} + selectTextOnFocus + returnKeyType="search" + value={query} + style={[pal.text, styles.headerSearchInput]} + keyboardAppearance={theme.colorScheme} + onFocus={() => setInputIsFocused(true)} + onBlur={() => setInputIsFocused(false)} + onChangeText={onChangeText} + onSubmitEditing={onSubmit} + autoFocus={false} + accessibilityRole="search" + accessibilityLabel={_(msg`Search`)} + accessibilityHint="" + autoCorrect={false} + autoCapitalize="none" + /> + {query ? ( + <Pressable + testID="searchTextInputClearBtn" + onPress={onPressClearQuery} + accessibilityRole="button" + accessibilityLabel={_(msg`Clear search query`)} + accessibilityHint=""> + <FontAwesomeIcon + icon="xmark" + size={16} + style={pal.textLight as FontAwesomeIconStyle} + /> + </Pressable> + ) : undefined} + </View> + + {query || inputIsFocused ? ( + <View style={styles.headerCancelBtn}> + <Pressable onPress={onPressCancelSearch} accessibilityRole="button"> + <Text style={[pal.text]}> + <Trans>Cancel</Trans> + </Text> + </Pressable> + </View> + ) : undefined} + </CenteredView> + + {showAutocompleteResults && moderationOpts ? ( + <> + {isFetching ? ( + <Loader /> + ) : ( + <ScrollView style={{height: '100%'}}> + {searchResults.length ? ( + searchResults.map((item, i) => ( + <SearchResultCard + key={item.did} + profile={item} + moderation={moderateProfile(item, moderationOpts)} + style={i === 0 ? {borderTopWidth: 0} : {}} + /> + )) + ) : ( + <EmptyState message={_(msg`No results found for ${query}`)} /> + )} + + <View style={{height: 200}} /> + </ScrollView> + )} + </> + ) : ( + <SearchScreenInner query={query} /> + )} + </View> + ) +} + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 4, + }, + headerMenuBtn: { + width: 30, + height: 30, + borderRadius: 30, + marginRight: 6, + paddingBottom: 2, + alignItems: 'center', + justifyContent: 'center', + }, + headerSearchContainer: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + borderRadius: 30, + paddingHorizontal: 12, + paddingVertical: 8, + }, + headerSearchIcon: { + marginRight: 6, + alignSelf: 'center', + }, + headerSearchInput: { + flex: 1, + fontSize: 17, + }, + headerCancelBtn: { + paddingLeft: 10, + }, +}) diff --git a/src/view/screens/Search/index.tsx b/src/view/screens/Search/index.tsx new file mode 100644 index 000000000..a65149bf7 --- /dev/null +++ b/src/view/screens/Search/index.tsx @@ -0,0 +1,3 @@ +import {SearchScreenMobile} from '#/view/screens/Search/Search' + +export const SearchScreen = SearchScreenMobile diff --git a/src/view/screens/Search/index.web.tsx b/src/view/screens/Search/index.web.tsx new file mode 100644 index 000000000..8e039e3cd --- /dev/null +++ b/src/view/screens/Search/index.web.tsx @@ -0,0 +1,3 @@ +import {SearchScreenDesktop} from '#/view/screens/Search/Search' + +export const SearchScreen = SearchScreenDesktop diff --git a/src/view/screens/SearchMobile.tsx b/src/view/screens/SearchMobile.tsx deleted file mode 100644 index c1df58ffd..000000000 --- a/src/view/screens/SearchMobile.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import React, {useCallback} from 'react' -import { - StyleSheet, - TouchableWithoutFeedback, - Keyboard, - View, -} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {FlatList, ScrollView} from 'view/com/util/Views' -import { - NativeStackScreenProps, - SearchTabNavigatorParams, -} from 'lib/routes/types' -import {observer} from 'mobx-react-lite' -import {Text} from 'view/com/util/text/Text' -import {useStores} from 'state/index' -import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' -import {SearchUIModel} from 'state/models/ui/search' -import {FoafsModel} from 'state/models/discovery/foafs' -import {SuggestedActorsModel} from 'state/models/discovery/suggested-actors' -import {HeaderWithInput} from 'view/com/search/HeaderWithInput' -import {Suggestions} from 'view/com/search/Suggestions' -import {SearchResults} from 'view/com/search/SearchResults' -import {s} from 'lib/styles' -import {ProfileCard} from 'view/com/profile/ProfileCard' -import {usePalette} from 'lib/hooks/usePalette' -import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' -import {isAndroid, isIOS} from 'platform/detection' -import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell' - -type Props = NativeStackScreenProps<SearchTabNavigatorParams, 'Search'> -export const SearchScreen = withAuthRequired( - observer<Props>(function SearchScreenImpl({}: Props) { - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const setIsDrawerSwipeDisabled = useSetDrawerSwipeDisabled() - const scrollViewRef = React.useRef<ScrollView>(null) - const flatListRef = React.useRef<FlatList>(null) - const [onMainScroll] = useOnMainScroll() - const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false) - const [query, setQuery] = React.useState<string>('') - const autocompleteView = React.useMemo<UserAutocompleteModel>( - () => new UserAutocompleteModel(store), - [store], - ) - const foafs = React.useMemo<FoafsModel>( - () => new FoafsModel(store), - [store], - ) - const suggestedActors = React.useMemo<SuggestedActorsModel>( - () => new SuggestedActorsModel(store), - [store], - ) - const [searchUIModel, setSearchUIModel] = React.useState< - SearchUIModel | undefined - >() - - const onChangeQuery = React.useCallback( - (text: string) => { - setQuery(text) - if (text.length > 0) { - autocompleteView.setActive(true) - autocompleteView.setPrefix(text) - } else { - autocompleteView.setActive(false) - } - }, - [setQuery, autocompleteView], - ) - - const onPressClearQuery = React.useCallback(() => { - setQuery('') - }, [setQuery]) - - const onPressCancelSearch = React.useCallback(() => { - setQuery('') - autocompleteView.setActive(false) - setSearchUIModel(undefined) - setIsDrawerSwipeDisabled(false) - }, [setQuery, autocompleteView, setIsDrawerSwipeDisabled]) - - const onSubmitQuery = React.useCallback(() => { - if (query.length === 0) { - return - } - - const model = new SearchUIModel(store) - model.fetch(query) - setSearchUIModel(model) - setIsDrawerSwipeDisabled(true) - }, [query, setSearchUIModel, store, setIsDrawerSwipeDisabled]) - - const onSoftReset = React.useCallback(() => { - scrollViewRef.current?.scrollTo({x: 0, y: 0}) - flatListRef.current?.scrollToOffset({offset: 0}) - onPressCancelSearch() - }, [scrollViewRef, flatListRef, onPressCancelSearch]) - - useFocusEffect( - React.useCallback(() => { - const softResetSub = store.onScreenSoftReset(onSoftReset) - const cleanup = () => { - softResetSub.remove() - } - - setMinimalShellMode(false) - autocompleteView.setup() - if (!foafs.hasData) { - foafs.fetch() - } - if (!suggestedActors.hasLoaded) { - suggestedActors.loadMore(true) - } - - return cleanup - }, [ - store, - autocompleteView, - foafs, - suggestedActors, - onSoftReset, - setMinimalShellMode, - ]), - ) - - const onPress = useCallback(() => { - if (isIOS || isAndroid) { - Keyboard.dismiss() - } - }, []) - - return ( - <TouchableWithoutFeedback onPress={onPress} accessible={false}> - <View style={[pal.view, styles.container]}> - <HeaderWithInput - isInputFocused={isInputFocused} - query={query} - setIsInputFocused={setIsInputFocused} - onChangeQuery={onChangeQuery} - onPressClearQuery={onPressClearQuery} - onPressCancelSearch={onPressCancelSearch} - onSubmitQuery={onSubmitQuery} - /> - {searchUIModel ? ( - <SearchResults model={searchUIModel} /> - ) : !isInputFocused && !query ? ( - <Suggestions - ref={flatListRef} - foafs={foafs} - suggestedActors={suggestedActors} - /> - ) : ( - <ScrollView - ref={scrollViewRef} - testID="searchScrollView" - style={pal.view} - onScroll={onMainScroll} - scrollEventThrottle={100}> - {query && autocompleteView.suggestions.length ? ( - <> - {autocompleteView.suggestions.map((suggestion, index) => ( - <ProfileCard - key={suggestion.did} - testID={`searchAutoCompleteResult-${suggestion.handle}`} - profile={suggestion} - noBorder={index === 0} - /> - ))} - </> - ) : query && !autocompleteView.suggestions.length ? ( - <View> - <Text style={[pal.textLight, styles.searchPrompt]}> - No results found for {autocompleteView.prefix} - </Text> - </View> - ) : isInputFocused ? ( - <View> - <Text style={[pal.textLight, styles.searchPrompt]}> - Search for users and posts on the network - </Text> - </View> - ) : null} - <View style={s.footerSpacer} /> - </ScrollView> - )} - </View> - </TouchableWithoutFeedback> - ) - }), -) - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - - searchPrompt: { - textAlign: 'center', - paddingTop: 10, - }, -}) diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index ca4ef2a40..388a5d954 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -10,20 +10,13 @@ import { View, ViewStyle, } from 'react-native' -import { - useFocusEffect, - useNavigation, - StackActions, -} from '@react-navigation/native' +import {useFocusEffect, useNavigation} from '@react-navigation/native' import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -import {observer} from 'mobx-react-lite' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' import * as AppInfo from 'lib/app-info' -import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {ScrollView} from '../com/util/Views' import {ViewHeader} from '../com/util/ViewHeader' @@ -39,662 +32,766 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher' import {useAnalytics} from 'lib/analytics/analytics' import {NavigationProp} from 'lib/routes/types' -import {pluralize} from 'lib/strings/helpers' import {HandIcon, HashtagIcon} from 'lib/icons' -import {formatCount} from 'view/com/util/numeric/format' import Clipboard from '@react-native-clipboard/clipboard' import {makeProfileLink} from 'lib/routes/links' import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn' -import {logger} from '#/logger' +import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' +import {useModalControls} from '#/state/modals' import { useSetMinimalShellMode, useColorMode, useSetColorMode, + useOnboardingDispatch, } from '#/state/shell' +import { + useRequireAltTextEnabled, + useSetRequireAltTextEnabled, +} from '#/state/preferences' +import { + useSession, + useSessionApi, + SessionAccount, + getAgent, +} from '#/state/session' +import {useProfileQuery} from '#/state/queries/profile' +import {useClearPreferencesMutation} from '#/state/queries/preferences' +import {useInviteCodesQuery} from '#/state/queries/invites' +import {clear as clearStorage} from '#/state/persisted/store' +import {clearLegacyStorage} from '#/state/persisted/legacy' // TEMPORARY (APP-700) // remove after backend testing finishes // -prf import {useDebugHeaderSetting} from 'lib/api/debug-appview-proxy-header' import {STATUS_PAGE_URL} from 'lib/constants' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useQueryClient} from '@tanstack/react-query' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' +import {useCloseAllActiveElements} from '#/state/util' + +function SettingsAccountCard({account}: {account: SessionAccount}) { + const pal = usePalette('default') + const {isSwitchingAccounts, currentAccount} = useSession() + const {logout} = useSessionApi() + const {data: profile} = useProfileQuery({did: account.did}) + const isCurrentAccount = account.did === currentAccount?.did + const {onPressSwitchAccount} = useAccountSwitcher() + + const contents = ( + <View style={[pal.view, styles.linkCard]}> + <View style={styles.avi}> + <UserAvatar size={40} avatar={profile?.avatar} /> + </View> + <View style={[s.flex1]}> + <Text type="md-bold" style={pal.text}> + {profile?.displayName || account.handle} + </Text> + <Text type="sm" style={pal.textLight}> + {account.handle} + </Text> + </View> -type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'> -export const SettingsScreen = withAuthRequired( - observer(function Settings({}: Props) { - const colorMode = useColorMode() - const setColorMode = useSetColorMode() - const pal = usePalette('default') - const store = useStores() - const setMinimalShellMode = useSetMinimalShellMode() - const navigation = useNavigation<NavigationProp>() - const {isMobile} = useWebMediaQueries() - const {screen, track} = useAnalytics() - const [isSwitching, setIsSwitching, onPressSwitchAccount] = - useAccountSwitcher() - const [debugHeaderEnabled, toggleDebugHeader] = useDebugHeaderSetting( - store.agent, - ) + {isCurrentAccount ? ( + <TouchableOpacity + testID="signOutBtn" + onPress={logout} + accessibilityRole="button" + accessibilityLabel="Sign out" + accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`}> + <Text type="lg" style={pal.link}> + Sign out + </Text> + </TouchableOpacity> + ) : ( + <AccountDropdownBtn account={account} /> + )} + </View> + ) + + return isCurrentAccount ? ( + <Link + href={makeProfileLink({ + did: currentAccount?.did, + handle: currentAccount?.handle, + })} + title="Your profile" + noFeedback> + {contents} + </Link> + ) : ( + <TouchableOpacity + testID={`switchToAccountBtn-${account.handle}`} + key={account.did} + onPress={ + isSwitchingAccounts ? undefined : () => onPressSwitchAccount(account) + } + accessibilityRole="button" + accessibilityLabel={`Switch to ${account.handle}`} + accessibilityHint="Switches the account you are logged in to"> + {contents} + </TouchableOpacity> + ) +} - const primaryBg = useCustomPalette<ViewStyle>({ - light: {backgroundColor: colors.blue0}, - dark: {backgroundColor: colors.blue6}, - }) - const primaryText = useCustomPalette<TextStyle>({ - light: {color: colors.blue3}, - dark: {color: colors.blue2}, +type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'> +export function SettingsScreen({}: Props) { + const queryClient = useQueryClient() + const colorMode = useColorMode() + const setColorMode = useSetColorMode() + const pal = usePalette('default') + const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() + const requireAltTextEnabled = useRequireAltTextEnabled() + const setRequireAltTextEnabled = useSetRequireAltTextEnabled() + const onboardingDispatch = useOnboardingDispatch() + const navigation = useNavigation<NavigationProp>() + const {isMobile} = useWebMediaQueries() + const {screen, track} = useAnalytics() + const {openModal} = useModalControls() + const {isSwitchingAccounts, accounts, currentAccount} = useSession() + const [debugHeaderEnabled, toggleDebugHeader] = useDebugHeaderSetting( + getAgent(), + ) + const {mutate: clearPreferences} = useClearPreferencesMutation() + const {data: invites} = useInviteCodesQuery() + const invitesAvailable = invites?.available?.length ?? 0 + const {setShowLoggedOut} = useLoggedOutViewControls() + const closeAllActiveElements = useCloseAllActiveElements() + + const primaryBg = useCustomPalette<ViewStyle>({ + light: {backgroundColor: colors.blue0}, + dark: {backgroundColor: colors.blue6}, + }) + const primaryText = useCustomPalette<TextStyle>({ + light: {color: colors.blue3}, + dark: {color: colors.blue2}, + }) + + const dangerBg = useCustomPalette<ViewStyle>({ + light: {backgroundColor: colors.red1}, + dark: {backgroundColor: colors.red7}, + }) + const dangerText = useCustomPalette<TextStyle>({ + light: {color: colors.red4}, + dark: {color: colors.red2}, + }) + + useFocusEffect( + React.useCallback(() => { + screen('Settings') + setMinimalShellMode(false) + }, [screen, setMinimalShellMode]), + ) + + const onPressAddAccount = React.useCallback(() => { + track('Settings:AddAccountButtonClicked') + setShowLoggedOut(true) + closeAllActiveElements() + }, [track, setShowLoggedOut, closeAllActiveElements]) + + const onPressChangeHandle = React.useCallback(() => { + track('Settings:ChangeHandleButtonClicked') + openModal({ + name: 'change-handle', + onChanged() { + if (currentAccount) { + // refresh my profile + queryClient.invalidateQueries({ + queryKey: RQKEY_PROFILE(currentAccount.did), + }) + } + }, }) + }, [track, queryClient, openModal, currentAccount]) - const dangerBg = useCustomPalette<ViewStyle>({ - light: {backgroundColor: colors.red1}, - dark: {backgroundColor: colors.red7}, - }) - const dangerText = useCustomPalette<TextStyle>({ - light: {color: colors.red4}, - dark: {color: colors.red2}, - }) + const onPressInviteCodes = React.useCallback(() => { + track('Settings:InvitecodesButtonClicked') + openModal({name: 'invite-codes'}) + }, [track, openModal]) - useFocusEffect( - React.useCallback(() => { - screen('Settings') - setMinimalShellMode(false) - }, [screen, setMinimalShellMode]), - ) + const onPressLanguageSettings = React.useCallback(() => { + navigation.navigate('LanguageSettings') + }, [navigation]) + + const onPressDeleteAccount = React.useCallback(() => { + openModal({name: 'delete-account'}) + }, [openModal]) - const onPressAddAccount = React.useCallback(() => { - track('Settings:AddAccountButtonClicked') - navigation.navigate('HomeTab') - navigation.dispatch(StackActions.popToTop()) - store.session.clear() - }, [track, navigation, store]) - - const onPressChangeHandle = React.useCallback(() => { - track('Settings:ChangeHandleButtonClicked') - store.shell.openModal({ - name: 'change-handle', - onChanged() { - setIsSwitching(true) - store.session.reloadFromServer().then( - () => { - setIsSwitching(false) - Toast.show('Your handle has been updated') - }, - err => { - logger.error('Failed to reload from server after handle update', { - error: err, - }) - setIsSwitching(false) - }, - ) - }, - }) - }, [track, store, setIsSwitching]) - - const onPressInviteCodes = React.useCallback(() => { - track('Settings:InvitecodesButtonClicked') - store.shell.openModal({name: 'invite-codes'}) - }, [track, store]) - - const onPressLanguageSettings = React.useCallback(() => { - navigation.navigate('LanguageSettings') - }, [navigation]) - - const onPressSignout = React.useCallback(() => { - track('Settings:SignOutButtonClicked') - store.session.logout() - }, [track, store]) - - const onPressDeleteAccount = React.useCallback(() => { - store.shell.openModal({name: 'delete-account'}) - }, [store]) - - const onPressResetPreferences = React.useCallback(async () => { - await store.preferences.reset() - Toast.show('Preferences reset') - }, [store]) - - const onPressResetOnboarding = React.useCallback(async () => { - store.onboarding.reset() - Toast.show('Onboarding reset') - }, [store]) - - const onPressBuildInfo = React.useCallback(() => { - Clipboard.setString( - `Build version: ${AppInfo.appVersion}; Platform: ${Platform.OS}`, - ) - Toast.show('Copied build version to clipboard') - }, []) - - const openHomeFeedPreferences = React.useCallback(() => { - navigation.navigate('PreferencesHomeFeed') - }, [navigation]) - - const openThreadsPreferences = React.useCallback(() => { - navigation.navigate('PreferencesThreads') - }, [navigation]) - - const onPressAppPasswords = React.useCallback(() => { - navigation.navigate('AppPasswords') - }, [navigation]) - - const onPressSystemLog = React.useCallback(() => { - navigation.navigate('Log') - }, [navigation]) - - const onPressStorybook = React.useCallback(() => { - navigation.navigate('Debug') - }, [navigation]) - - const onPressSavedFeeds = React.useCallback(() => { - navigation.navigate('SavedFeeds') - }, [navigation]) - - const onPressStatusPage = React.useCallback(() => { - Linking.openURL(STATUS_PAGE_URL) - }, []) - - return ( - <View style={[s.hContentRegion]} testID="settingsScreen"> - <ViewHeader title="Settings" /> - <ScrollView - style={[s.hContentRegion]} - contentContainerStyle={isMobile && pal.viewLight} - scrollIndicatorInsets={{right: 1}}> - <View style={styles.spacer20} /> - {store.session.currentSession !== undefined ? ( - <> - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Account + const onPressResetPreferences = React.useCallback(async () => { + clearPreferences() + }, [clearPreferences]) + + const onPressResetOnboarding = React.useCallback(async () => { + onboardingDispatch({type: 'start'}) + Toast.show('Onboarding reset') + }, [onboardingDispatch]) + + const onPressBuildInfo = React.useCallback(() => { + Clipboard.setString( + `Build version: ${AppInfo.appVersion}; Platform: ${Platform.OS}`, + ) + Toast.show('Copied build version to clipboard') + }, []) + + const openHomeFeedPreferences = React.useCallback(() => { + navigation.navigate('PreferencesHomeFeed') + }, [navigation]) + + const openThreadsPreferences = React.useCallback(() => { + navigation.navigate('PreferencesThreads') + }, [navigation]) + + const onPressAppPasswords = React.useCallback(() => { + navigation.navigate('AppPasswords') + }, [navigation]) + + const onPressSystemLog = React.useCallback(() => { + navigation.navigate('Log') + }, [navigation]) + + const onPressStorybook = React.useCallback(() => { + navigation.navigate('Debug') + }, [navigation]) + + const onPressSavedFeeds = React.useCallback(() => { + navigation.navigate('SavedFeeds') + }, [navigation]) + + const onPressStatusPage = React.useCallback(() => { + Linking.openURL(STATUS_PAGE_URL) + }, []) + + const clearAllStorage = React.useCallback(async () => { + await clearStorage() + Toast.show(`Storage cleared, you need to restart the app now.`) + }, []) + const clearAllLegacyStorage = React.useCallback(async () => { + await clearLegacyStorage() + Toast.show(`Legacy storage cleared, you need to restart the app now.`) + }, []) + + return ( + <View style={[s.hContentRegion]} testID="settingsScreen"> + <ViewHeader title={_(msg`Settings`)} /> + <ScrollView + style={[s.hContentRegion]} + contentContainerStyle={isMobile && pal.viewLight} + scrollIndicatorInsets={{right: 1}}> + <View style={styles.spacer20} /> + {currentAccount ? ( + <> + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Account</Trans> + </Text> + <View style={[styles.infoLine]}> + <Text type="lg-medium" style={pal.text}> + <Trans>Email:</Trans>{' '} </Text> - <View style={[styles.infoLine]}> - <Text type="lg-medium" style={pal.text}> - Email:{' '} - </Text> - {!store.session.emailNeedsConfirmation && ( - <> - <FontAwesomeIcon - icon="check" - size={10} - style={{color: colors.green3, marginRight: 2}} - /> - </> - )} - <Text type="lg" style={pal.text}> - {store.session.currentSession?.email}{' '} - </Text> - <Link - onPress={() => store.shell.openModal({name: 'change-email'})}> - <Text type="lg" style={pal.link}> - Change - </Text> - </Link> - </View> - <View style={[styles.infoLine]}> - <Text type="lg-medium" style={pal.text}> - Birthday:{' '} + {currentAccount.emailConfirmed && ( + <> + <FontAwesomeIcon + icon="check" + size={10} + style={{color: colors.green3, marginRight: 2}} + /> + </> + )} + <Text type="lg" style={pal.text}> + {currentAccount.email}{' '} + </Text> + <Link onPress={() => openModal({name: 'change-email'})}> + <Text type="lg" style={pal.link}> + <Trans>Change</Trans> </Text> - <Link - onPress={() => - store.shell.openModal({name: 'birth-date-settings'}) - }> - <Text type="lg" style={pal.link}> - Show - </Text> - </Link> - </View> - <View style={styles.spacer20} /> - <EmailConfirmationNotice /> - </> - ) : null} - <View style={[s.flexRow, styles.heading]}> - <Text type="xl-bold" style={pal.text}> - Signed in as - </Text> - <View style={s.flex1} /> - </View> - {isSwitching ? ( - <View style={[pal.view, styles.linkCard]}> - <ActivityIndicator /> + </Link> </View> - ) : ( - <Link - href={makeProfileLink(store.me)} - title="Your profile" - noFeedback> - <View style={[pal.view, styles.linkCard]}> - <View style={styles.avi}> - <UserAvatar size={40} avatar={store.me.avatar} /> - </View> - <View style={[s.flex1]}> - <Text type="md-bold" style={pal.text} numberOfLines={1}> - {store.me.displayName || store.me.handle} - </Text> - <Text type="sm" style={pal.textLight} numberOfLines={1}> - {store.me.handle} - </Text> - </View> - <TouchableOpacity - testID="signOutBtn" - onPress={isSwitching ? undefined : onPressSignout} - accessibilityRole="button" - accessibilityLabel="Sign out" - accessibilityHint={`Signs ${store.me.displayName} out of Bluesky`}> - <Text type="lg" style={pal.link}> - Sign out - </Text> - </TouchableOpacity> - </View> - </Link> - )} - {store.session.switchableAccounts.map(account => ( - <TouchableOpacity - testID={`switchToAccountBtn-${account.handle}`} - key={account.did} - style={[pal.view, styles.linkCard, isSwitching && styles.dimmed]} - onPress={ - isSwitching ? undefined : () => onPressSwitchAccount(account) - } - accessibilityRole="button" - accessibilityLabel={`Switch to ${account.handle}`} - accessibilityHint="Switches the account you are logged in to"> - <View style={styles.avi}> - <UserAvatar size={40} avatar={account.aviUrl} /> - </View> - <View style={[s.flex1]}> - <Text type="md-bold" style={pal.text}> - {account.displayName || account.handle} - </Text> - <Text type="sm" style={pal.textLight}> - {account.handle} + <View style={[styles.infoLine]}> + <Text type="lg-medium" style={pal.text}> + <Trans>Birthday:</Trans>{' '} + </Text> + <Link onPress={() => openModal({name: 'birth-date-settings'})}> + <Text type="lg" style={pal.link}> + <Trans>Show</Trans> </Text> - </View> - <AccountDropdownBtn handle={account.handle} /> - </TouchableOpacity> - ))} - <TouchableOpacity - testID="switchToNewAccountBtn" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={isSwitching ? undefined : onPressAddAccount} - accessibilityRole="button" - accessibilityLabel="Add account" - accessibilityHint="Create a new Bluesky account"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="plus" - style={pal.text as FontAwesomeIconStyle} - /> + </Link> </View> - <Text type="lg" style={pal.text}> - Add account - </Text> - </TouchableOpacity> + <View style={styles.spacer20} /> + + {!currentAccount.emailConfirmed && <EmailConfirmationNotice />} + </> + ) : null} + <View style={[s.flexRow, styles.heading]}> + <Text type="xl-bold" style={pal.text}> + <Trans>Signed in as</Trans> + </Text> + <View style={s.flex1} /> + </View> - <View style={styles.spacer20} /> + {isSwitchingAccounts ? ( + <View style={[pal.view, styles.linkCard]}> + <ActivityIndicator /> + </View> + ) : ( + <SettingsAccountCard account={currentAccount!} /> + )} + + {accounts + .filter(a => a.did !== currentAccount?.did) + .map(account => ( + <SettingsAccountCard key={account.did} account={account} /> + ))} - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Invite a Friend + <TouchableOpacity + testID="switchToNewAccountBtn" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={isSwitchingAccounts ? undefined : onPressAddAccount} + accessibilityRole="button" + accessibilityLabel={_(msg`Add account`)} + accessibilityHint="Create a new Bluesky account"> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="plus" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Add account</Trans> </Text> - <TouchableOpacity - testID="inviteFriendBtn" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={isSwitching ? undefined : onPressInviteCodes} - accessibilityRole="button" - accessibilityLabel="Invite" - accessibilityHint="Opens invite code list"> - <View - style={[ - styles.iconContainer, - store.me.invitesAvailable > 0 ? primaryBg : pal.btn, - ]}> - <FontAwesomeIcon - icon="ticket" - style={ - (store.me.invitesAvailable > 0 - ? primaryText - : pal.text) as FontAwesomeIconStyle - } - /> - </View> - <Text - type="lg" - style={store.me.invitesAvailable > 0 ? pal.link : pal.text}> - {formatCount(store.me.invitesAvailable)} invite{' '} - {pluralize(store.me.invitesAvailable, 'code')} available - </Text> - </TouchableOpacity> + </TouchableOpacity> - <View style={styles.spacer20} /> + <View style={styles.spacer20} /> - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Accessibility - </Text> - <View style={[pal.view, styles.toggleCard]}> - <ToggleButton - type="default-light" - label="Require alt text before posting" - labelType="lg" - isSelected={store.preferences.requireAltTextEnabled} - onPress={store.preferences.toggleRequireAltTextEnabled} + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Invite a Friend</Trans> + </Text> + + <TouchableOpacity + testID="inviteFriendBtn" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={isSwitchingAccounts ? undefined : onPressInviteCodes} + accessibilityRole="button" + accessibilityLabel={_(msg`Invite`)} + accessibilityHint="Opens invite code list" + disabled={invites?.disabled}> + <View + style={[ + styles.iconContainer, + invitesAvailable > 0 ? primaryBg : pal.btn, + ]}> + <FontAwesomeIcon + icon="ticket" + style={ + (invitesAvailable > 0 + ? primaryText + : pal.text) as FontAwesomeIconStyle + } /> </View> + <Text type="lg" style={invitesAvailable > 0 ? pal.link : pal.text}> + {invites?.disabled ? ( + <Trans> + Your invite codes are hidden when logged in using an App + Password + </Trans> + ) : invitesAvailable === 1 ? ( + <Trans>{invitesAvailable} invite code available</Trans> + ) : ( + <Trans>{invitesAvailable} invite codes available</Trans> + )} + </Text> + </TouchableOpacity> - <View style={styles.spacer20} /> + <View style={styles.spacer20} /> - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Appearance - </Text> - <View> - <View style={[styles.linkCard, pal.view, styles.selectableBtns]}> - <SelectableBtn - selected={colorMode === 'system'} - label="System" - left - onSelect={() => setColorMode('system')} - accessibilityHint="Set color theme to system setting" - /> - <SelectableBtn - selected={colorMode === 'light'} - label="Light" - onSelect={() => setColorMode('light')} - accessibilityHint="Set color theme to light" - /> - <SelectableBtn - selected={colorMode === 'dark'} - label="Dark" - right - onSelect={() => setColorMode('dark')} - accessibilityHint="Set color theme to dark" - /> - </View> + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Accessibility</Trans> + </Text> + <View style={[pal.view, styles.toggleCard]}> + <ToggleButton + type="default-light" + label="Require alt text before posting" + labelType="lg" + isSelected={requireAltTextEnabled} + onPress={() => setRequireAltTextEnabled(!requireAltTextEnabled)} + /> + </View> + + <View style={styles.spacer20} /> + + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Appearance</Trans> + </Text> + <View> + <View style={[styles.linkCard, pal.view, styles.selectableBtns]}> + <SelectableBtn + selected={colorMode === 'system'} + label="System" + left + onSelect={() => setColorMode('system')} + accessibilityHint="Set color theme to system setting" + /> + <SelectableBtn + selected={colorMode === 'light'} + label="Light" + onSelect={() => setColorMode('light')} + accessibilityHint="Set color theme to light" + /> + <SelectableBtn + selected={colorMode === 'dark'} + label="Dark" + right + onSelect={() => setColorMode('dark')} + accessibilityHint="Set color theme to dark" + /> </View> - <View style={styles.spacer20} /> + </View> + <View style={styles.spacer20} /> - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Basics + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Basics</Trans> + </Text> + <TouchableOpacity + testID="preferencesHomeFeedButton" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={openHomeFeedPreferences} + accessibilityRole="button" + accessibilityHint="" + accessibilityLabel={_(msg`Opens the home feed preferences`)}> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="sliders" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Home Feed Preferences</Trans> </Text> - <TouchableOpacity - testID="preferencesHomeFeedButton" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={openHomeFeedPreferences} - accessibilityRole="button" - accessibilityHint="" - accessibilityLabel="Opens the home feed preferences"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="sliders" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - Home Feed Preferences - </Text> - </TouchableOpacity> - <TouchableOpacity - testID="preferencesThreadsButton" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={openThreadsPreferences} - accessibilityRole="button" - accessibilityHint="" - accessibilityLabel="Opens the threads preferences"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon={['far', 'comments']} - style={pal.text as FontAwesomeIconStyle} - size={18} - /> - </View> - <Text type="lg" style={pal.text}> - Thread Preferences - </Text> - </TouchableOpacity> - <TouchableOpacity - testID="savedFeedsBtn" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - accessibilityHint="My Saved Feeds" - accessibilityLabel="Opens screen with all saved feeds" - onPress={onPressSavedFeeds}> - <View style={[styles.iconContainer, pal.btn]}> - <HashtagIcon style={pal.text} size={18} strokeWidth={3} /> - </View> - <Text type="lg" style={pal.text}> - My Saved Feeds - </Text> - </TouchableOpacity> - <TouchableOpacity - testID="languageSettingsBtn" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={isSwitching ? undefined : onPressLanguageSettings} - accessibilityRole="button" - accessibilityHint="Language settings" - accessibilityLabel="Opens configurable language settings"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="language" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - Languages - </Text> - </TouchableOpacity> - <TouchableOpacity - testID="moderationBtn" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={ - isSwitching ? undefined : () => navigation.navigate('Moderation') - } - accessibilityRole="button" - accessibilityHint="" - accessibilityLabel="Opens moderation settings"> - <View style={[styles.iconContainer, pal.btn]}> - <HandIcon style={pal.text} size={18} strokeWidth={6} /> - </View> - <Text type="lg" style={pal.text}> - Moderation - </Text> - </TouchableOpacity> - <View style={styles.spacer20} /> - - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Advanced + </TouchableOpacity> + <TouchableOpacity + testID="preferencesThreadsButton" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={openThreadsPreferences} + accessibilityRole="button" + accessibilityHint="" + accessibilityLabel={_(msg`Opens the threads preferences`)}> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon={['far', 'comments']} + style={pal.text as FontAwesomeIconStyle} + size={18} + /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Thread Preferences</Trans> </Text> - <TouchableOpacity - testID="appPasswordBtn" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={onPressAppPasswords} - accessibilityRole="button" - accessibilityHint="Open app password settings" - accessibilityLabel="Opens the app password settings page"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="lock" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - App passwords - </Text> - </TouchableOpacity> - <TouchableOpacity - testID="changeHandleBtn" - style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - onPress={isSwitching ? undefined : onPressChangeHandle} - accessibilityRole="button" - accessibilityLabel="Change handle" - accessibilityHint="Choose a new Bluesky username or create"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="at" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text} numberOfLines={1}> - Change handle - </Text> - </TouchableOpacity> - <View style={styles.spacer20} /> - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Danger Zone + </TouchableOpacity> + <TouchableOpacity + testID="savedFeedsBtn" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + accessibilityHint="My Saved Feeds" + accessibilityLabel={_(msg`Opens screen with all saved feeds`)} + onPress={onPressSavedFeeds}> + <View style={[styles.iconContainer, pal.btn]}> + <HashtagIcon style={pal.text} size={18} strokeWidth={3} /> + </View> + <Text type="lg" style={pal.text}> + <Trans>My Saved Feeds</Trans> </Text> - <TouchableOpacity - style={[pal.view, styles.linkCard]} - onPress={onPressDeleteAccount} - accessible={true} - accessibilityRole="button" - accessibilityLabel="Delete account" - accessibilityHint="Opens modal for account deletion confirmation. Requires email code."> - <View style={[styles.iconContainer, dangerBg]}> - <FontAwesomeIcon - icon={['far', 'trash-can']} - style={dangerText as FontAwesomeIconStyle} - size={18} - /> - </View> - <Text type="lg" style={dangerText}> - Delete my account… - </Text> - </TouchableOpacity> - <View style={styles.spacer20} /> - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Developer Tools + </TouchableOpacity> + <TouchableOpacity + testID="languageSettingsBtn" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={isSwitchingAccounts ? undefined : onPressLanguageSettings} + accessibilityRole="button" + accessibilityHint="Language settings" + accessibilityLabel={_(msg`Opens configurable language settings`)}> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="language" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Languages</Trans> </Text> - <TouchableOpacity - style={[pal.view, styles.linkCardNoIcon]} - onPress={onPressSystemLog} - accessibilityRole="button" - accessibilityHint="Open system log" - accessibilityLabel="Opens the system log page"> - <Text type="lg" style={pal.text}> - System log - </Text> - </TouchableOpacity> - {__DEV__ ? ( - <ToggleButton - type="default-light" - label="Experiment: Use AppView Proxy" - isSelected={debugHeaderEnabled} - onPress={toggleDebugHeader} + </TouchableOpacity> + <TouchableOpacity + testID="moderationBtn" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={ + isSwitchingAccounts + ? undefined + : () => navigation.navigate('Moderation') + } + accessibilityRole="button" + accessibilityHint="" + accessibilityLabel={_(msg`Opens moderation settings`)}> + <View style={[styles.iconContainer, pal.btn]}> + <HandIcon style={pal.text} size={18} strokeWidth={6} /> + </View> + <Text type="lg" style={pal.text}> + <Trans>Moderation</Trans> + </Text> + </TouchableOpacity> + <View style={styles.spacer20} /> + + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Advanced</Trans> + </Text> + <TouchableOpacity + testID="appPasswordBtn" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={onPressAppPasswords} + accessibilityRole="button" + accessibilityHint="Open app password settings" + accessibilityLabel={_(msg`Opens the app password settings page`)}> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="lock" + style={pal.text as FontAwesomeIconStyle} /> - ) : null} - {__DEV__ ? ( - <> - <TouchableOpacity - style={[pal.view, styles.linkCardNoIcon]} - onPress={onPressStorybook} - accessibilityRole="button" - accessibilityHint="Open storybook page" - accessibilityLabel="Opens the storybook page"> - <Text type="lg" style={pal.text}> - Storybook - </Text> - </TouchableOpacity> - <TouchableOpacity - style={[pal.view, styles.linkCardNoIcon]} - onPress={onPressResetPreferences} - accessibilityRole="button" - accessibilityHint="Reset preferences" - accessibilityLabel="Resets the preferences state"> - <Text type="lg" style={pal.text}> - Reset preferences state - </Text> - </TouchableOpacity> - <TouchableOpacity - style={[pal.view, styles.linkCardNoIcon]} - onPress={onPressResetOnboarding} - accessibilityRole="button" - accessibilityHint="Reset onboarding" - accessibilityLabel="Resets the onboarding state"> - <Text type="lg" style={pal.text}> - Reset onboarding state - </Text> - </TouchableOpacity> - </> - ) : null} - <View style={[styles.footer]}> + </View> + <Text type="lg" style={pal.text}> + <Trans>App passwords</Trans> + </Text> + </TouchableOpacity> + <TouchableOpacity + testID="changeHandleBtn" + style={[ + styles.linkCard, + pal.view, + isSwitchingAccounts && styles.dimmed, + ]} + onPress={isSwitchingAccounts ? undefined : onPressChangeHandle} + accessibilityRole="button" + accessibilityLabel={_(msg`Change handle`)} + accessibilityHint="Choose a new Bluesky username or create"> + <View style={[styles.iconContainer, pal.btn]}> + <FontAwesomeIcon + icon="at" + style={pal.text as FontAwesomeIconStyle} + /> + </View> + <Text type="lg" style={pal.text} numberOfLines={1}> + <Trans>Change handle</Trans> + </Text> + </TouchableOpacity> + <View style={styles.spacer20} /> + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Danger Zone</Trans> + </Text> + <TouchableOpacity + style={[pal.view, styles.linkCard]} + onPress={onPressDeleteAccount} + accessible={true} + accessibilityRole="button" + accessibilityLabel={_(msg`Delete account`)} + accessibilityHint="Opens modal for account deletion confirmation. Requires email code."> + <View style={[styles.iconContainer, dangerBg]}> + <FontAwesomeIcon + icon={['far', 'trash-can']} + style={dangerText as FontAwesomeIconStyle} + size={18} + /> + </View> + <Text type="lg" style={dangerText}> + <Trans>Delete my account…</Trans> + </Text> + </TouchableOpacity> + <View style={styles.spacer20} /> + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Developer Tools</Trans> + </Text> + <TouchableOpacity + style={[pal.view, styles.linkCardNoIcon]} + onPress={onPressSystemLog} + accessibilityRole="button" + accessibilityHint="Open system log" + accessibilityLabel={_(msg`Opens the system log page`)}> + <Text type="lg" style={pal.text}> + <Trans>System log</Trans> + </Text> + </TouchableOpacity> + {__DEV__ ? ( + <ToggleButton + type="default-light" + label="Experiment: Use AppView Proxy" + isSelected={debugHeaderEnabled} + onPress={toggleDebugHeader} + /> + ) : null} + {__DEV__ ? ( + <> <TouchableOpacity + style={[pal.view, styles.linkCardNoIcon]} + onPress={onPressStorybook} accessibilityRole="button" - onPress={onPressBuildInfo}> - <Text type="sm" style={[styles.buildInfo, pal.textLight]}> - Build version {AppInfo.appVersion} {AppInfo.updateChannel} + accessibilityHint="Open storybook page" + accessibilityLabel={_(msg`Opens the storybook page`)}> + <Text type="lg" style={pal.text}> + <Trans>Storybook</Trans> </Text> </TouchableOpacity> - <Text type="sm" style={[pal.textLight]}> - · - </Text> <TouchableOpacity + style={[pal.view, styles.linkCardNoIcon]} + onPress={onPressResetPreferences} accessibilityRole="button" - onPress={onPressStatusPage}> - <Text type="sm" style={[styles.buildInfo, pal.textLight]}> - Status page + accessibilityHint="Reset preferences" + accessibilityLabel={_(msg`Resets the preferences state`)}> + <Text type="lg" style={pal.text}> + <Trans>Reset preferences state</Trans> </Text> </TouchableOpacity> - </View> - <View style={s.footerSpacer} /> - </ScrollView> - </View> - ) - }), -) - -const EmailConfirmationNotice = observer( - function EmailConfirmationNoticeImpl() { - const pal = usePalette('default') - const palInverted = usePalette('inverted') - const store = useStores() - const {isMobile} = useWebMediaQueries() - - if (!store.session.emailNeedsConfirmation) { - return null - } - - return ( - <View style={{marginBottom: 20}}> - <Text type="xl-bold" style={[pal.text, styles.heading]}> - Verify email - </Text> - <View - style={[ - { - paddingVertical: isMobile ? 12 : 0, - paddingHorizontal: 18, - }, - pal.view, - ]}> - <View style={{flexDirection: 'row', marginBottom: 8}}> - <Pressable - style={[ - palInverted.view, - { - flexDirection: 'row', - gap: 6, - borderRadius: 6, - paddingHorizontal: 12, - paddingVertical: 10, - alignItems: 'center', - }, - isMobile && {flex: 1}, - ]} + <TouchableOpacity + style={[pal.view, styles.linkCardNoIcon]} + onPress={onPressResetOnboarding} accessibilityRole="button" - accessibilityLabel="Verify my email" - accessibilityHint="" - onPress={() => store.shell.openModal({name: 'verify-email'})}> - <FontAwesomeIcon - icon="envelope" - color={palInverted.colors.text} - size={16} - /> - <Text type="button" style={palInverted.text}> - Verify My Email + accessibilityHint="Reset onboarding" + accessibilityLabel={_(msg`Resets the onboarding state`)}> + <Text type="lg" style={pal.text}> + <Trans>Reset onboarding state</Trans> </Text> - </Pressable> - </View> - <Text style={pal.textLight}> - Protect your account by verifying your email. + </TouchableOpacity> + <TouchableOpacity + style={[pal.view, styles.linkCardNoIcon]} + onPress={clearAllLegacyStorage} + accessibilityRole="button" + accessibilityHint="Clear all legacy storage data" + accessibilityLabel={_(msg`Clear all legacy storage data`)}> + <Text type="lg" style={pal.text}> + <Trans> + Clear all legacy storage data (restart after this) + </Trans> + </Text> + </TouchableOpacity> + <TouchableOpacity + style={[pal.view, styles.linkCardNoIcon]} + onPress={clearAllStorage} + accessibilityRole="button" + accessibilityHint="Clear all storage data" + accessibilityLabel={_(msg`Clear all storage data`)}> + <Text type="lg" style={pal.text}> + <Trans>Clear all storage data (restart after this)</Trans> + </Text> + </TouchableOpacity> + </> + ) : null} + <View style={[styles.footer]}> + <TouchableOpacity + accessibilityRole="button" + onPress={onPressBuildInfo}> + <Text type="sm" style={[styles.buildInfo, pal.textLight]}> + <Trans> + Build version {AppInfo.appVersion} {AppInfo.updateChannel} + </Trans> + </Text> + </TouchableOpacity> + <Text type="sm" style={[pal.textLight]}> + · </Text> + <TouchableOpacity + accessibilityRole="button" + onPress={onPressStatusPage}> + <Text type="sm" style={[styles.buildInfo, pal.textLight]}> + <Trans>Status page</Trans> + </Text> + </TouchableOpacity> + </View> + <View style={s.footerSpacer} /> + </ScrollView> + </View> + ) +} + +function EmailConfirmationNotice() { + const pal = usePalette('default') + const palInverted = usePalette('inverted') + const {_} = useLingui() + const {isMobile} = useWebMediaQueries() + const {openModal} = useModalControls() + + return ( + <View style={{marginBottom: 20}}> + <Text type="xl-bold" style={[pal.text, styles.heading]}> + <Trans>Verify email</Trans> + </Text> + <View + style={[ + { + paddingVertical: isMobile ? 12 : 0, + paddingHorizontal: 18, + }, + pal.view, + ]}> + <View style={{flexDirection: 'row', marginBottom: 8}}> + <Pressable + style={[ + palInverted.view, + { + flexDirection: 'row', + gap: 6, + borderRadius: 6, + paddingHorizontal: 12, + paddingVertical: 10, + alignItems: 'center', + }, + isMobile && {flex: 1}, + ]} + accessibilityRole="button" + accessibilityLabel={_(msg`Verify my email`)} + accessibilityHint="" + onPress={() => openModal({name: 'verify-email'})}> + <FontAwesomeIcon + icon="envelope" + color={palInverted.colors.text} + size={16} + /> + <Text type="button" style={palInverted.text}> + <Trans>Verify My Email</Trans> + </Text> + </Pressable> </View> + <Text style={pal.textLight}> + <Trans>Protect your account by verifying your email.</Trans> + </Text> </View> - ) - }, -) + </View> + ) +} const styles = StyleSheet.create({ dimmed: { diff --git a/src/view/screens/Support.tsx b/src/view/screens/Support.tsx index 7106b4136..6856f6759 100644 --- a/src/view/screens/Support.tsx +++ b/src/view/screens/Support.tsx @@ -10,11 +10,14 @@ import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {HELP_DESK_URL} from 'lib/constants' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type Props = NativeStackScreenProps<CommonNavigatorParams, 'Support'> export const SupportScreen = (_props: Props) => { const pal = usePalette('default') const setMinimalShellMode = useSetMinimalShellMode() + const {_} = useLingui() useFocusEffect( React.useCallback(() => { @@ -24,19 +27,21 @@ export const SupportScreen = (_props: Props) => { return ( <View> - <ViewHeader title="Support" /> + <ViewHeader title={_(msg`Support`)} /> <CenteredView> <Text type="title-xl" style={[pal.text, s.p20, s.pb5]}> - Support + <Trans>Support</Trans> </Text> <Text style={[pal.text, s.p20]}> - The support form has been moved. If you need help, please - <TextLink - href={HELP_DESK_URL} - text=" click here" - style={pal.link} - />{' '} - or visit {HELP_DESK_URL} to get in touch with us. + <Trans> + The support form has been moved. If you need help, please + <TextLink + href={HELP_DESK_URL} + text=" click here" + style={pal.link} + />{' '} + or visit {HELP_DESK_URL} to get in touch with us. + </Trans> </Text> </CenteredView> </View> diff --git a/src/view/screens/TermsOfService.tsx b/src/view/screens/TermsOfService.tsx index b7a388b65..c20890e29 100644 --- a/src/view/screens/TermsOfService.tsx +++ b/src/view/screens/TermsOfService.tsx @@ -9,11 +9,14 @@ import {ScrollView} from 'view/com/util/Views' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {useSetMinimalShellMode} from '#/state/shell' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' type Props = NativeStackScreenProps<CommonNavigatorParams, 'TermsOfService'> export const TermsOfServiceScreen = (_props: Props) => { const pal = usePalette('default') const setMinimalShellMode = useSetMinimalShellMode() + const {_} = useLingui() useFocusEffect( React.useCallback(() => { @@ -23,11 +26,11 @@ export const TermsOfServiceScreen = (_props: Props) => { return ( <View> - <ViewHeader title="Terms of Service" /> + <ViewHeader title={_(msg`Terms of Service`)} /> <ScrollView style={[s.hContentRegion, pal.view]}> <View style={[s.p20]}> <Text style={pal.text}> - The Terms of Service have been moved to{' '} + <Trans>The Terms of Service have been moved to</Trans>{' '} <TextLink style={pal.link} href="https://blueskyweb.xyz/support/tos" diff --git a/src/view/shell/Composer.tsx b/src/view/shell/Composer.tsx index 219a594ed..d37ff4fb7 100644 --- a/src/view/shell/Composer.tsx +++ b/src/view/shell/Composer.tsx @@ -2,30 +2,21 @@ import React, {useEffect} from 'react' import {observer} from 'mobx-react-lite' import {Animated, Easing, Platform, StyleSheet, View} from 'react-native' import {ComposePost} from '../com/composer/Composer' -import {ComposerOpts} from 'state/models/ui/shell' +import {useComposerState} from 'state/shell/composer' import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {usePalette} from 'lib/hooks/usePalette' export const Composer = observer(function ComposerImpl({ - active, winHeight, - replyTo, - onPost, - quote, - mention, }: { - active: boolean winHeight: number - replyTo?: ComposerOpts['replyTo'] - onPost?: ComposerOpts['onPost'] - quote?: ComposerOpts['quote'] - mention?: ComposerOpts['mention'] }) { + const state = useComposerState() const pal = usePalette('default') const initInterp = useAnimatedValue(0) useEffect(() => { - if (active) { + if (state) { Animated.timing(initInterp, { toValue: 1, duration: 300, @@ -35,7 +26,7 @@ export const Composer = observer(function ComposerImpl({ } else { initInterp.setValue(0) } - }, [initInterp, active]) + }, [initInterp, state]) const wrapperAnimStyle = { transform: [ { @@ -50,7 +41,7 @@ export const Composer = observer(function ComposerImpl({ // rendering // = - if (!active) { + if (!state) { return <View /> } @@ -60,10 +51,10 @@ export const Composer = observer(function ComposerImpl({ aria-modal accessibilityViewIsModal> <ComposePost - replyTo={replyTo} - onPost={onPost} - quote={quote} - mention={mention} + replyTo={state.replyTo} + onPost={state.onPost} + quote={state.quote} + mention={state.mention} /> </Animated.View> ) diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx index c3ec37e57..73f9f540e 100644 --- a/src/view/shell/Composer.web.tsx +++ b/src/view/shell/Composer.web.tsx @@ -1,40 +1,35 @@ import React from 'react' -import {observer} from 'mobx-react-lite' import {StyleSheet, View} from 'react-native' +import Animated, {FadeIn, FadeInDown, FadeOut} from 'react-native-reanimated' import {ComposePost} from '../com/composer/Composer' -import {ComposerOpts} from 'state/models/ui/shell' +import {useComposerState} from 'state/shell/composer' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' const BOTTOM_BAR_HEIGHT = 61 -export const Composer = observer(function ComposerImpl({ - active, - replyTo, - quote, - onPost, - mention, -}: { - active: boolean - winHeight: number - replyTo?: ComposerOpts['replyTo'] - quote: ComposerOpts['quote'] - onPost?: ComposerOpts['onPost'] - mention?: ComposerOpts['mention'] -}) { +export function Composer({}: {winHeight: number}) { const pal = usePalette('default') const {isMobile} = useWebMediaQueries() + const state = useComposerState() // rendering // = - if (!active) { + if (!state) { return <View /> } return ( - <View style={styles.mask} aria-modal accessibilityViewIsModal> - <View + <Animated.View + style={styles.mask} + aria-modal + accessibilityViewIsModal + entering={FadeIn.duration(100)} + exiting={FadeOut}> + <Animated.View + entering={FadeInDown.duration(150)} + exiting={FadeOut} style={[ styles.container, isMobile && styles.containerMobile, @@ -42,15 +37,15 @@ export const Composer = observer(function ComposerImpl({ pal.border, ]}> <ComposePost - replyTo={replyTo} - quote={quote} - onPost={onPost} - mention={mention} + replyTo={state.replyTo} + quote={state.quote} + onPost={state.onPost} + mention={state.mention} /> - </View> - </View> + </Animated.View> + </Animated.View> ) -}) +} const styles = StyleSheet.create({ mask: { diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index 7f5e6c5e5..459a021c4 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -10,14 +10,13 @@ import { ViewStyle, } from 'react-native' import {useNavigation, StackActions} from '@react-navigation/native' -import {observer} from 'mobx-react-lite' import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' +import {useQueryClient} from '@tanstack/react-query' import {s, colors} from 'lib/styles' import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants' -import {useStores} from 'state/index' import { HomeIcon, HomeIconSolid, @@ -42,20 +41,81 @@ import {getTabState, TabState} from 'lib/routes/helpers' import {NavigationProp} from 'lib/routes/types' import {useNavigationTabState} from 'lib/hooks/useNavigationTabState' import {isWeb} from 'platform/detection' -import {formatCount, formatCountShortOnly} from 'view/com/util/numeric/format' +import {formatCountShortOnly} from 'view/com/util/numeric/format' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {useSetDrawerOpen} from '#/state/shell' +import {useModalControls} from '#/state/modals' +import {useSession, SessionAccount} from '#/state/session' +import {useProfileQuery} from '#/state/queries/profile' +import {useUnreadNotifications} from '#/state/queries/notifications/unread' +import {emitSoftReset} from '#/state/events' +import {useInviteCodesQuery} from '#/state/queries/invites' +import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed' +import {NavSignupCard} from '#/view/shell/NavSignupCard' +import {truncateAndInvalidate} from '#/state/queries/util' -export const DrawerContent = observer(function DrawerContentImpl() { +export function DrawerProfileCard({ + account, + onPressProfile, +}: { + account: SessionAccount + onPressProfile: () => void +}) { + const {_} = useLingui() + const pal = usePalette('default') + const {data: profile} = useProfileQuery({did: account.did}) + + return ( + <TouchableOpacity + testID="profileCardButton" + accessibilityLabel={_(msg`Profile`)} + accessibilityHint="Navigates to your profile" + onPress={onPressProfile}> + <UserAvatar + size={80} + avatar={profile?.avatar} + // See https://github.com/bluesky-social/social-app/pull/1801: + usePlainRNImage={true} + /> + <Text + type="title-lg" + style={[pal.text, s.bold, styles.profileCardDisplayName]} + numberOfLines={1}> + {profile?.displayName || account.handle} + </Text> + <Text + type="2xl" + style={[pal.textLight, styles.profileCardHandle]} + numberOfLines={1}> + @{account.handle} + </Text> + <Text type="xl" style={[pal.textLight, styles.profileCardFollowers]}> + <Text type="xl-medium" style={pal.text}> + {formatCountShortOnly(profile?.followersCount ?? 0)} + </Text>{' '} + {pluralize(profile?.followersCount || 0, 'follower')} ·{' '} + <Text type="xl-medium" style={pal.text}> + {formatCountShortOnly(profile?.followsCount ?? 0)} + </Text>{' '} + following + </Text> + </TouchableOpacity> + ) +} + +export function DrawerContent() { const theme = useTheme() const pal = usePalette('default') - const store = useStores() + const {_} = useLingui() + const queryClient = useQueryClient() const setDrawerOpen = useSetDrawerOpen() const navigation = useNavigation<NavigationProp>() const {track} = useAnalytics() const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} = useNavigationTabState() - - const {notifications} = store.me + const {hasSession, currentAccount} = useSession() + const numUnreadNotifications = useUnreadNotifications() // events // = @@ -68,7 +128,7 @@ export const DrawerContent = observer(function DrawerContentImpl() { if (isWeb) { // hack because we have flat navigator for web and MyProfile does not exist on the web navigator -ansh if (tab === 'MyProfile') { - navigation.navigate('Profile', {name: store.me.handle}) + navigation.navigate('Profile', {name: currentAccount!.handle}) } else { // @ts-ignore must be Home, Search, Notifications, or MyProfile navigation.navigate(tab) @@ -76,16 +136,20 @@ export const DrawerContent = observer(function DrawerContentImpl() { } else { const tabState = getTabState(state, tab) if (tabState === TabState.InsideAtRoot) { - store.emitScreenSoftReset() + emitSoftReset() } else if (tabState === TabState.Inside) { navigation.dispatch(StackActions.popToTop()) } else { + if (tab === 'Notifications') { + // fetch new notifs on view + truncateAndInvalidate(queryClient, NOTIFS_RQKEY()) + } // @ts-ignore must be Home, Search, Notifications, or MyProfile navigation.navigate(`${tab}Tab`) } } }, - [store, track, navigation, setDrawerOpen], + [track, navigation, setDrawerOpen, currentAccount, queryClient], ) const onPressHome = React.useCallback(() => onPressTab('Home'), [onPressTab]) @@ -131,11 +195,11 @@ export const DrawerContent = observer(function DrawerContentImpl() { track('Menu:FeedbackClicked') Linking.openURL( FEEDBACK_FORM_URL({ - email: store.session.currentSession?.email, - handle: store.session.currentSession?.handle, + email: currentAccount?.email, + handle: currentAccount?.handle, }), ) - }, [track, store.session.currentSession]) + }, [track, currentAccount]) const onPressHelp = React.useCallback(() => { track('Menu:HelpClicked') @@ -154,48 +218,20 @@ export const DrawerContent = observer(function DrawerContentImpl() { ]}> <SafeAreaView style={s.flex1}> <ScrollView style={styles.main}> - <View style={{}}> - <TouchableOpacity - testID="profileCardButton" - accessibilityLabel="Profile" - accessibilityHint="Navigates to your profile" - onPress={onPressProfile}> - <UserAvatar - size={80} - avatar={store.me.avatar} - // See https://github.com/bluesky-social/social-app/pull/1801: - usePlainRNImage={true} + {hasSession && currentAccount ? ( + <View style={{}}> + <DrawerProfileCard + account={currentAccount} + onPressProfile={onPressProfile} /> - <Text - type="title-lg" - style={[pal.text, s.bold, styles.profileCardDisplayName]} - numberOfLines={1}> - {store.me.displayName || store.me.handle} - </Text> - <Text - type="2xl" - style={[pal.textLight, styles.profileCardHandle]} - numberOfLines={1}> - @{store.me.handle} - </Text> - <Text - type="xl" - style={[pal.textLight, styles.profileCardFollowers]}> - <Text type="xl-medium" style={pal.text}> - {formatCountShortOnly(store.me.followersCount ?? 0)} - </Text>{' '} - {pluralize(store.me.followersCount || 0, 'follower')} ·{' '} - <Text type="xl-medium" style={pal.text}> - {formatCountShortOnly(store.me.followsCount ?? 0)} - </Text>{' '} - following - </Text> - </TouchableOpacity> - </View> + </View> + ) : ( + <NavSignupCard /> + )} - <InviteCodes style={{paddingLeft: 0}} /> + {hasSession && <InviteCodes style={{paddingLeft: 0}} />} - <View style={{height: 10}} /> + {hasSession && <View style={{height: 10}} />} <MenuItem icon={ @@ -213,8 +249,8 @@ export const DrawerContent = observer(function DrawerContentImpl() { /> ) } - label="Search" - accessibilityLabel="Search" + label={_(msg`Search`)} + accessibilityLabel={_(msg`Search`)} accessibilityHint="" bold={isAtSearch} onPress={onPressSearch} @@ -235,39 +271,43 @@ export const DrawerContent = observer(function DrawerContentImpl() { /> ) } - label="Home" - accessibilityLabel="Home" + label={_(msg`Home`)} + accessibilityLabel={_(msg`Home`)} accessibilityHint="" bold={isAtHome} onPress={onPressHome} /> - <MenuItem - icon={ - isAtNotifications ? ( - <BellIconSolid - style={pal.text as StyleProp<ViewStyle>} - size="24" - strokeWidth={1.7} - /> - ) : ( - <BellIcon - style={pal.text as StyleProp<ViewStyle>} - size="24" - strokeWidth={1.7} - /> - ) - } - label="Notifications" - accessibilityLabel="Notifications" - accessibilityHint={ - notifications.unreadCountLabel === '' - ? '' - : `${notifications.unreadCountLabel} unread` - } - count={notifications.unreadCountLabel} - bold={isAtNotifications} - onPress={onPressNotifications} - /> + + {hasSession && ( + <MenuItem + icon={ + isAtNotifications ? ( + <BellIconSolid + style={pal.text as StyleProp<ViewStyle>} + size="24" + strokeWidth={1.7} + /> + ) : ( + <BellIcon + style={pal.text as StyleProp<ViewStyle>} + size="24" + strokeWidth={1.7} + /> + ) + } + label={_(msg`Notifications`)} + accessibilityLabel={_(msg`Notifications`)} + accessibilityHint={ + numUnreadNotifications === '' + ? '' + : `${numUnreadNotifications} unread` + } + count={numUnreadNotifications} + bold={isAtNotifications} + onPress={onPressNotifications} + /> + )} + <MenuItem icon={ isAtFeeds ? ( @@ -284,68 +324,74 @@ export const DrawerContent = observer(function DrawerContentImpl() { /> ) } - label="Feeds" - accessibilityLabel="Feeds" + label={_(msg`Feeds`)} + accessibilityLabel={_(msg`Feeds`)} accessibilityHint="" bold={isAtFeeds} onPress={onPressMyFeeds} /> - <MenuItem - icon={<ListIcon strokeWidth={2} style={pal.text} size={26} />} - label="Lists" - accessibilityLabel="Lists" - accessibilityHint="" - onPress={onPressLists} - /> - <MenuItem - icon={<HandIcon strokeWidth={5} style={pal.text} size={24} />} - label="Moderation" - accessibilityLabel="Moderation" - accessibilityHint="" - onPress={onPressModeration} - /> - <MenuItem - icon={ - isAtMyProfile ? ( - <UserIconSolid - style={pal.text as StyleProp<ViewStyle>} - size="26" - strokeWidth={1.5} - /> - ) : ( - <UserIcon - style={pal.text as StyleProp<ViewStyle>} - size="26" - strokeWidth={1.5} - /> - ) - } - label="Profile" - accessibilityLabel="Profile" - accessibilityHint="" - onPress={onPressProfile} - /> - <MenuItem - icon={ - <CogIcon - style={pal.text as StyleProp<ViewStyle>} - size="26" - strokeWidth={1.75} + + {hasSession && ( + <> + <MenuItem + icon={<ListIcon strokeWidth={2} style={pal.text} size={26} />} + label={_(msg`Lists`)} + accessibilityLabel={_(msg`Lists`)} + accessibilityHint="" + onPress={onPressLists} /> - } - label="Settings" - accessibilityLabel="Settings" - accessibilityHint="" - onPress={onPressSettings} - /> + <MenuItem + icon={<HandIcon strokeWidth={5} style={pal.text} size={24} />} + label={_(msg`Moderation`)} + accessibilityLabel={_(msg`Moderation`)} + accessibilityHint="" + onPress={onPressModeration} + /> + <MenuItem + icon={ + isAtMyProfile ? ( + <UserIconSolid + style={pal.text as StyleProp<ViewStyle>} + size="26" + strokeWidth={1.5} + /> + ) : ( + <UserIcon + style={pal.text as StyleProp<ViewStyle>} + size="26" + strokeWidth={1.5} + /> + ) + } + label={_(msg`Profile`)} + accessibilityLabel={_(msg`Profile`)} + accessibilityHint="" + onPress={onPressProfile} + /> + <MenuItem + icon={ + <CogIcon + style={pal.text as StyleProp<ViewStyle>} + size="26" + strokeWidth={1.75} + /> + } + label={_(msg`Settings`)} + accessibilityLabel={_(msg`Settings`)} + accessibilityHint="" + onPress={onPressSettings} + /> + </> + )} <View style={styles.smallSpacer} /> <View style={styles.smallSpacer} /> </ScrollView> + <View style={styles.footer}> <TouchableOpacity accessibilityRole="link" - accessibilityLabel="Send feedback" + accessibilityLabel={_(msg`Send feedback`)} accessibilityHint="" onPress={onPressFeedback} style={[ @@ -361,24 +407,24 @@ export const DrawerContent = observer(function DrawerContentImpl() { icon={['far', 'message']} /> <Text type="lg-medium" style={[pal.link, s.pl10]}> - Feedback + <Trans>Feedback</Trans> </Text> </TouchableOpacity> <TouchableOpacity accessibilityRole="link" - accessibilityLabel="Send feedback" + accessibilityLabel={_(msg`Send feedback`)} accessibilityHint="" onPress={onPressHelp} style={[styles.footerBtn]}> <Text type="lg-medium" style={[pal.link, s.pl10]}> - Help + <Trans>Help</Trans> </Text> </TouchableOpacity> </View> </SafeAreaView> </View> ) -}) +} interface MenuItemProps extends ComponentProps<typeof TouchableOpacity> { icon: JSX.Element @@ -432,50 +478,54 @@ function MenuItem({ ) } -const InviteCodes = observer(function InviteCodesImpl({ - style, -}: { - style?: StyleProp<ViewStyle> -}) { +function InviteCodes({style}: {style?: StyleProp<ViewStyle>}) { const {track} = useAnalytics() - const store = useStores() const setDrawerOpen = useSetDrawerOpen() const pal = usePalette('default') - const {invitesAvailable} = store.me + const {data: invites} = useInviteCodesQuery() + const invitesAvailable = invites?.available?.length ?? 0 + const {openModal} = useModalControls() + const {_} = useLingui() + const onPress = React.useCallback(() => { track('Menu:ItemClicked', {url: '#invite-codes'}) setDrawerOpen(false) - store.shell.openModal({name: 'invite-codes'}) - }, [store, track, setDrawerOpen]) + openModal({name: 'invite-codes'}) + }, [openModal, track, setDrawerOpen]) + return ( <TouchableOpacity testID="menuItemInviteCodes" style={[styles.inviteCodes, style]} onPress={onPress} accessibilityRole="button" - accessibilityLabel={ - invitesAvailable === 1 - ? 'Invite codes: 1 available' - : `Invite codes: ${invitesAvailable} available` - } - accessibilityHint="Opens list of invite codes"> + accessibilityLabel={_(msg`Invite codes: ${invitesAvailable} available`)} + accessibilityHint={_(msg`Opens list of invite codes`)} + disabled={invites?.disabled}> <FontAwesomeIcon icon="ticket" style={[ styles.inviteCodesIcon, - store.me.invitesAvailable > 0 ? pal.link : pal.textLight, + invitesAvailable > 0 ? pal.link : pal.textLight, ]} size={18} /> <Text type="lg-medium" - style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}> - {formatCount(store.me.invitesAvailable)} invite{' '} - {pluralize(store.me.invitesAvailable, 'code')} + style={invitesAvailable > 0 ? pal.link : pal.textLight}> + {invites?.disabled ? ( + <Trans> + Your invite codes are hidden when logged in using an App Password + </Trans> + ) : invitesAvailable === 1 ? ( + <Trans>{invitesAvailable} invite code available</Trans> + ) : ( + <Trans>{invitesAvailable} invite codes available</Trans> + )} </Text> </TouchableOpacity> ) -}) +} const styles = StyleSheet.create({ view: { @@ -548,10 +598,11 @@ const styles = StyleSheet.create({ paddingLeft: 22, paddingVertical: 8, flexDirection: 'row', - alignItems: 'center', }, inviteCodesIcon: { marginRight: 6, + flexShrink: 0, + marginTop: 2, }, footer: { diff --git a/src/view/shell/NavSignupCard.tsx b/src/view/shell/NavSignupCard.tsx new file mode 100644 index 000000000..7026dd2a6 --- /dev/null +++ b/src/view/shell/NavSignupCard.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {DefaultAvatar} from '#/view/com/util/UserAvatar' +import {Text} from '#/view/com/util/text/Text' +import {Button} from '#/view/com/util/forms/Button' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' +import {useCloseAllActiveElements} from '#/state/util' + +export function NavSignupCard() { + const {_} = useLingui() + const pal = usePalette('default') + const {setShowLoggedOut} = useLoggedOutViewControls() + const closeAllActiveElements = useCloseAllActiveElements() + + const showLoggedOut = React.useCallback(() => { + closeAllActiveElements() + setShowLoggedOut(true) + }, [setShowLoggedOut, closeAllActiveElements]) + + return ( + <View + style={{ + alignItems: 'flex-start', + paddingTop: 6, + marginBottom: 24, + }}> + <DefaultAvatar type="user" size={48} /> + + <View style={{paddingTop: 12}}> + <Text type="md" style={[pal.text, s.bold]}> + <Trans>Sign up or sign in to join the conversation</Trans> + </Text> + </View> + + <View style={{flexDirection: 'row', paddingTop: 12, gap: 8}}> + <Button + onPress={showLoggedOut} + accessibilityHint={_(msg`Sign up`)} + accessibilityLabel={_(msg`Sign up`)}> + <Text type="md" style={[{color: 'white'}, s.bold]}> + <Trans>Sign up</Trans> + </Text> + </Button> + <Button + type="default" + onPress={showLoggedOut} + accessibilityHint={_(msg`Sign in`)} + accessibilityLabel={_(msg`Sign in`)}> + <Text type="md" style={[pal.text, s.bold]}> + Sign in + </Text> + </Button> + </View> + </View> + ) +} diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index d360ceead..746b4d123 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -1,12 +1,11 @@ import React, {ComponentProps} from 'react' import {GestureResponderEvent, TouchableOpacity, View} from 'react-native' import Animated from 'react-native-reanimated' +import {useQueryClient} from '@tanstack/react-query' import {StackActions} from '@react-navigation/native' import {BottomTabBarProps} from '@react-navigation/bottom-tabs' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {observer} from 'mobx-react-lite' import {Text} from 'view/com/util/text/Text' -import {useStores} from 'state/index' import {useAnalytics} from 'lib/analytics/analytics' import {clamp} from 'lib/numbers' import { @@ -24,21 +23,33 @@ import {styles} from './BottomBarStyles' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' import {useNavigationTabState} from 'lib/hooks/useNavigationTabState' import {UserAvatar} from 'view/com/util/UserAvatar' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import {useModalControls} from '#/state/modals' +import {useShellLayout} from '#/state/shell/shell-layout' +import {useUnreadNotifications} from '#/state/queries/notifications/unread' +import {emitSoftReset} from '#/state/events' +import {useSession} from '#/state/session' +import {useProfileQuery} from '#/state/queries/profile' +import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed' +import {truncateAndInvalidate} from '#/state/queries/util' type TabOptions = 'Home' | 'Search' | 'Notifications' | 'MyProfile' | 'Feeds' -export const BottomBar = observer(function BottomBarImpl({ - navigation, -}: BottomTabBarProps) { - const store = useStores() +export function BottomBar({navigation}: BottomTabBarProps) { + const {openModal} = useModalControls() + const {hasSession, currentAccount} = useSession() const pal = usePalette('default') + const {_} = useLingui() + const queryClient = useQueryClient() const safeAreaInsets = useSafeAreaInsets() const {track} = useAnalytics() + const {footerHeight} = useShellLayout() const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} = useNavigationTabState() - - const {minimalShellMode, footerMinimalShellTransform} = useMinimalShellMode() - const {notifications} = store.me + const numUnreadNotifications = useUnreadNotifications() + const {footerMinimalShellTransform} = useMinimalShellMode() + const {data: profile} = useProfileQuery({did: currentAccount?.did}) const onPressTab = React.useCallback( (tab: TabOptions) => { @@ -46,14 +57,18 @@ export const BottomBar = observer(function BottomBarImpl({ const state = navigation.getState() const tabState = getTabState(state, tab) if (tabState === TabState.InsideAtRoot) { - store.emitScreenSoftReset() + emitSoftReset() } else if (tabState === TabState.Inside) { navigation.dispatch(StackActions.popToTop()) } else { + if (tab === 'Notifications') { + // fetch new notifs on view + truncateAndInvalidate(queryClient, NOTIFS_RQKEY()) + } navigation.navigate(`${tab}Tab`) } }, - [store, track, navigation], + [track, navigation, queryClient], ) const onPressHome = React.useCallback(() => onPressTab('Home'), [onPressTab]) const onPressSearch = React.useCallback( @@ -72,8 +87,8 @@ export const BottomBar = observer(function BottomBarImpl({ onPressTab('MyProfile') }, [onPressTab]) const onLongPressProfile = React.useCallback(() => { - store.shell.openModal({name: 'switch-account'}) - }, [store]) + openModal({name: 'switch-account'}) + }, [openModal]) return ( <Animated.View @@ -83,8 +98,10 @@ export const BottomBar = observer(function BottomBarImpl({ pal.border, {paddingBottom: clamp(safeAreaInsets.bottom, 15, 30)}, footerMinimalShellTransform, - minimalShellMode && styles.disabled, - ]}> + ]} + onLayout={e => { + footerHeight.value = e.nativeEvent.layout.height + }}> <Btn testID="bottomBarHomeBtn" icon={ @@ -104,7 +121,7 @@ export const BottomBar = observer(function BottomBarImpl({ } onPress={onPressHome} accessibilityRole="tab" - accessibilityLabel="Home" + accessibilityLabel={_(msg`Home`)} accessibilityHint="" /> <Btn @@ -126,7 +143,7 @@ export const BottomBar = observer(function BottomBarImpl({ } onPress={onPressSearch} accessibilityRole="search" - accessibilityLabel="Search" + accessibilityLabel={_(msg`Search`)} accessibilityHint="" /> <Btn @@ -148,78 +165,83 @@ export const BottomBar = observer(function BottomBarImpl({ } onPress={onPressFeeds} accessibilityRole="tab" - accessibilityLabel="Feeds" + accessibilityLabel={_(msg`Feeds`)} accessibilityHint="" /> - <Btn - testID="bottomBarNotificationsBtn" - icon={ - isAtNotifications ? ( - <BellIconSolid - size={24} - strokeWidth={1.9} - style={[styles.ctrlIcon, pal.text, styles.bellIcon]} - /> - ) : ( - <BellIcon - size={24} - strokeWidth={1.9} - style={[styles.ctrlIcon, pal.text, styles.bellIcon]} - /> - ) - } - onPress={onPressNotifications} - notificationCount={notifications.unreadCountLabel} - accessible={true} - accessibilityRole="tab" - accessibilityLabel="Notifications" - accessibilityHint={ - notifications.unreadCountLabel === '' - ? '' - : `${notifications.unreadCountLabel} unread` - } - /> - <Btn - testID="bottomBarProfileBtn" - icon={ - <View style={styles.ctrlIconSizingWrapper}> - {isAtMyProfile ? ( - <View - style={[ - styles.ctrlIcon, - pal.text, - styles.profileIcon, - styles.onProfile, - {borderColor: pal.text.color}, - ]}> - <UserAvatar - avatar={store.me.avatar} - size={27} - // See https://github.com/bluesky-social/social-app/pull/1801: - usePlainRNImage={true} + + {hasSession && ( + <> + <Btn + testID="bottomBarNotificationsBtn" + icon={ + isAtNotifications ? ( + <BellIconSolid + size={24} + strokeWidth={1.9} + style={[styles.ctrlIcon, pal.text, styles.bellIcon]} /> - </View> - ) : ( - <View style={[styles.ctrlIcon, pal.text, styles.profileIcon]}> - <UserAvatar - avatar={store.me.avatar} - size={28} - // See https://github.com/bluesky-social/social-app/pull/1801: - usePlainRNImage={true} + ) : ( + <BellIcon + size={24} + strokeWidth={1.9} + style={[styles.ctrlIcon, pal.text, styles.bellIcon]} /> + ) + } + onPress={onPressNotifications} + notificationCount={numUnreadNotifications} + accessible={true} + accessibilityRole="tab" + accessibilityLabel={_(msg`Notifications`)} + accessibilityHint={ + numUnreadNotifications === '' + ? '' + : `${numUnreadNotifications} unread` + } + /> + <Btn + testID="bottomBarProfileBtn" + icon={ + <View style={styles.ctrlIconSizingWrapper}> + {isAtMyProfile ? ( + <View + style={[ + styles.ctrlIcon, + pal.text, + styles.profileIcon, + styles.onProfile, + {borderColor: pal.text.color}, + ]}> + <UserAvatar + avatar={profile?.avatar} + size={27} + // See https://github.com/bluesky-social/social-app/pull/1801: + usePlainRNImage={true} + /> + </View> + ) : ( + <View style={[styles.ctrlIcon, pal.text, styles.profileIcon]}> + <UserAvatar + avatar={profile?.avatar} + size={28} + // See https://github.com/bluesky-social/social-app/pull/1801: + usePlainRNImage={true} + /> + </View> + )} </View> - )} - </View> - } - onPress={onPressProfile} - onLongPress={onLongPressProfile} - accessibilityRole="tab" - accessibilityLabel="Profile" - accessibilityHint="" - /> + } + onPress={onPressProfile} + onLongPress={onLongPressProfile} + accessibilityRole="tab" + accessibilityLabel={_(msg`Profile`)} + accessibilityHint="" + /> + </> + )} </Animated.View> ) -}) +} interface BtnProps extends Pick< diff --git a/src/view/shell/bottom-bar/BottomBarStyles.tsx b/src/view/shell/bottom-bar/BottomBarStyles.tsx index c175ed848..ae9381440 100644 --- a/src/view/shell/bottom-bar/BottomBarStyles.tsx +++ b/src/view/shell/bottom-bar/BottomBarStyles.tsx @@ -65,7 +65,4 @@ export const styles = StyleSheet.create({ borderWidth: 1, borderRadius: 100, }, - disabled: { - pointerEvents: 'none', - }, }) diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx index ebcc527a1..3a60bd3b1 100644 --- a/src/view/shell/bottom-bar/BottomBarWeb.tsx +++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx @@ -1,6 +1,4 @@ import React from 'react' -import {observer} from 'mobx-react-lite' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useNavigationState} from '@react-navigation/native' import Animated from 'react-native-reanimated' @@ -23,9 +21,10 @@ import {Link} from 'view/com/util/Link' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' import {makeProfileLink} from 'lib/routes/links' import {CommonNavigatorParams} from 'lib/routes/types' +import {useSession} from '#/state/session' -export const BottomBarWeb = observer(function BottomBarWebImpl() { - const store = useStores() +export function BottomBarWeb() { + const {hasSession, currentAccount} = useSession() const pal = usePalette('default') const safeAreaInsets = useSafeAreaInsets() const {footerMinimalShellTransform} = useMinimalShellMode() @@ -76,55 +75,69 @@ export const BottomBarWeb = observer(function BottomBarWebImpl() { ) }} </NavItem> - <NavItem routeName="Notifications" href="/notifications"> - {({isActive}) => { - const Icon = isActive ? BellIconSolid : BellIcon - return ( - <Icon - size={24} - strokeWidth={1.9} - style={[styles.ctrlIcon, pal.text, styles.bellIcon]} - /> - ) - }} - </NavItem> - <NavItem routeName="Profile" href={makeProfileLink(store.me)}> - {({isActive}) => { - const Icon = isActive ? UserIconSolid : UserIcon - return ( - <Icon - size={28} - strokeWidth={1.5} - style={[styles.ctrlIcon, pal.text, styles.profileIcon]} - /> - ) - }} - </NavItem> + + {hasSession && ( + <> + <NavItem routeName="Notifications" href="/notifications"> + {({isActive}) => { + const Icon = isActive ? BellIconSolid : BellIcon + return ( + <Icon + size={24} + strokeWidth={1.9} + style={[styles.ctrlIcon, pal.text, styles.bellIcon]} + /> + ) + }} + </NavItem> + <NavItem + routeName="Profile" + href={ + currentAccount + ? makeProfileLink({ + did: currentAccount.did, + handle: currentAccount.handle, + }) + : '/' + }> + {({isActive}) => { + const Icon = isActive ? UserIconSolid : UserIcon + return ( + <Icon + size={28} + strokeWidth={1.5} + style={[styles.ctrlIcon, pal.text, styles.profileIcon]} + /> + ) + }} + </NavItem> + </> + )} </Animated.View> ) -}) +} const NavItem: React.FC<{ children: (props: {isActive: boolean}) => React.ReactChild href: string routeName: string }> = ({children, href, routeName}) => { + const {currentAccount} = useSession() const currentRoute = useNavigationState(state => { if (!state) { return {name: 'Home'} } return getCurrentRoute(state) }) - const store = useStores() const isActive = currentRoute.name === 'Profile' ? isTab(currentRoute.name, routeName) && (currentRoute.params as CommonNavigatorParams['Profile']).name === - store.me.handle + currentAccount?.handle : isTab(currentRoute.name, routeName) return ( - <Link href={href} style={styles.ctrl}> + <Link href={href} style={styles.ctrl} navigationAction="navigate"> {children({isActive})} </Link> ) diff --git a/src/view/shell/createNativeStackNavigatorWithAuth.tsx b/src/view/shell/createNativeStackNavigatorWithAuth.tsx new file mode 100644 index 000000000..c7b5d1d2e --- /dev/null +++ b/src/view/shell/createNativeStackNavigatorWithAuth.tsx @@ -0,0 +1,150 @@ +import * as React from 'react' +import {View} from 'react-native' + +// Based on @react-navigation/native-stack/src/createNativeStackNavigator.ts +// MIT License +// Copyright (c) 2017 React Navigation Contributors + +import { + createNavigatorFactory, + EventArg, + ParamListBase, + StackActionHelpers, + StackActions, + StackNavigationState, + StackRouter, + StackRouterOptions, + useNavigationBuilder, +} from '@react-navigation/native' +import type { + NativeStackNavigationEventMap, + NativeStackNavigationOptions, +} from '@react-navigation/native-stack' +import type {NativeStackNavigatorProps} from '@react-navigation/native-stack/src/types' +import {NativeStackView} from '@react-navigation/native-stack' + +import {BottomBarWeb} from './bottom-bar/BottomBarWeb' +import {DesktopLeftNav} from './desktop/LeftNav' +import {DesktopRightNav} from './desktop/RightNav' +import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {useOnboardingState} from '#/state/shell' +import { + useLoggedOutView, + useLoggedOutViewControls, +} from '#/state/shell/logged-out' +import {useSession} from '#/state/session' +import {isWeb} from 'platform/detection' +import {LoggedOut} from '../com/auth/LoggedOut' +import {Onboarding} from '../com/auth/Onboarding' + +type NativeStackNavigationOptionsWithAuth = NativeStackNavigationOptions & { + requireAuth?: boolean +} + +function NativeStackNavigator({ + id, + initialRouteName, + children, + screenListeners, + screenOptions, + ...rest +}: NativeStackNavigatorProps) { + // --- this is copy and pasted from the original native stack navigator --- + const {state, descriptors, navigation, NavigationContent} = + useNavigationBuilder< + StackNavigationState<ParamListBase>, + StackRouterOptions, + StackActionHelpers<ParamListBase>, + NativeStackNavigationOptionsWithAuth, + NativeStackNavigationEventMap + >(StackRouter, { + id, + initialRouteName, + children, + screenListeners, + screenOptions, + }) + React.useEffect( + () => + // @ts-expect-error: there may not be a tab navigator in parent + navigation?.addListener?.('tabPress', (e: any) => { + const isFocused = navigation.isFocused() + + // Run the operation in the next frame so we're sure all listeners have been run + // This is necessary to know if preventDefault() has been called + requestAnimationFrame(() => { + if ( + state.index > 0 && + isFocused && + !(e as EventArg<'tabPress', true>).defaultPrevented + ) { + // When user taps on already focused tab and we're inside the tab, + // reset the stack to replicate native behaviour + navigation.dispatch({ + ...StackActions.popToTop(), + target: state.key, + }) + } + }) + }), + [navigation, state.index, state.key], + ) + + // --- our custom logic starts here --- + const {hasSession} = useSession() + const activeRoute = state.routes[state.index] + const activeDescriptor = descriptors[activeRoute.key] + const activeRouteRequiresAuth = activeDescriptor.options.requireAuth ?? false + const onboardingState = useOnboardingState() + const {showLoggedOut} = useLoggedOutView() + const {setShowLoggedOut} = useLoggedOutViewControls() + const {isMobile} = useWebMediaQueries() + if (activeRouteRequiresAuth && !hasSession) { + return <LoggedOut /> + } + if (showLoggedOut) { + return <LoggedOut onDismiss={() => setShowLoggedOut(false)} /> + } + if (onboardingState.isActive) { + return <Onboarding /> + } + const newDescriptors: typeof descriptors = {} + for (let key in descriptors) { + const descriptor = descriptors[key] + const requireAuth = descriptor.options.requireAuth ?? false + newDescriptors[key] = { + ...descriptor, + render() { + if (requireAuth && !hasSession) { + return <View /> + } else { + return descriptor.render() + } + }, + } + } + return ( + <NavigationContent> + <NativeStackView + {...rest} + state={state} + navigation={navigation} + descriptors={newDescriptors} + /> + {isWeb && isMobile && <BottomBarWeb />} + {isWeb && !isMobile && ( + <> + <DesktopLeftNav /> + <DesktopRightNav /> + </> + )} + </NavigationContent> + ) +} + +export const createNativeStackNavigatorWithAuth = createNavigatorFactory< + StackNavigationState<ParamListBase>, + NativeStackNavigationOptionsWithAuth, + NativeStackNavigationEventMap, + typeof NativeStackNavigator +>(NativeStackNavigator) diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx index 3237d2cdd..ff51ffe22 100644 --- a/src/view/shell/desktop/Feeds.tsx +++ b/src/view/shell/desktop/Feeds.tsx @@ -1,17 +1,17 @@ import React from 'react' import {View, StyleSheet} from 'react-native' import {useNavigationState} from '@react-navigation/native' -import {observer} from 'mobx-react-lite' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' -import {useDesktopRightNavItems} from 'lib/hooks/useDesktopRightNavItems' import {TextLink} from 'view/com/util/Link' import {getCurrentRoute} from 'lib/routes/helpers' +import {useLingui} from '@lingui/react' +import {msg} from '@lingui/macro' +import {usePinnedFeedsInfos} from '#/state/queries/feed' -export const DesktopFeeds = observer(function DesktopFeeds() { - const store = useStores() +export function DesktopFeeds() { const pal = usePalette('default') - const items = useDesktopRightNavItems(store.preferences.pinnedFeeds) + const {_} = useLingui() + const {feeds} = usePinnedFeedsInfos() const route = useNavigationState(state => { if (!state) { @@ -23,40 +23,40 @@ export const DesktopFeeds = observer(function DesktopFeeds() { return ( <View style={[styles.container, pal.view, pal.border]}> <FeedItem href="/" title="Following" current={route.name === 'Home'} /> - {items.map(item => { - try { - const params = route.params as Record<string, string> - const routeName = - item.collection === 'app.bsky.feed.generator' - ? 'ProfileFeed' - : 'ProfileList' - return ( - <FeedItem - key={item.uri} - href={item.href} - title={item.displayName} - current={ - route.name === routeName && - params.name === item.hostname && - params.rkey === item.rkey - } - /> - ) - } catch { - return null - } - })} + {feeds + .filter(f => f.displayName !== 'Following') + .map(feed => { + try { + const params = route.params as Record<string, string> + const routeName = + feed.type === 'feed' ? 'ProfileFeed' : 'ProfileList' + return ( + <FeedItem + key={feed.uri} + href={feed.route.href} + title={feed.displayName} + current={ + route.name === routeName && + params.name === feed.route.params.name && + params.rkey === feed.route.params.rkey + } + /> + ) + } catch { + return null + } + })} <View style={{paddingTop: 8, paddingBottom: 6}}> <TextLink type="lg" href="/feeds" - text="More feeds" + text={_(msg`More feeds`)} style={[pal.link]} /> </View> </View> ) -}) +} function FeedItem({ title, diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index 39271605c..2ed294501 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -1,5 +1,4 @@ import React from 'react' -import {observer} from 'mobx-react-lite' import {StyleSheet, TouchableOpacity, View} from 'react-native' import {PressableWithHover} from 'view/com/util/PressableWithHover' import { @@ -16,7 +15,6 @@ import {UserAvatar} from 'view/com/util/UserAvatar' import {Link} from 'view/com/util/Link' import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {s, colors} from 'lib/styles' import { @@ -39,18 +37,36 @@ import {getCurrentRoute, isTab, isStateAtTabRoot} from 'lib/routes/helpers' import {NavigationProp, CommonNavigatorParams} from 'lib/routes/types' import {router} from '../../../routes' import {makeProfileLink} from 'lib/routes/links' +import {useLingui} from '@lingui/react' +import {Trans, msg} from '@lingui/macro' +import {useProfileQuery} from '#/state/queries/profile' +import {useSession} from '#/state/session' +import {useUnreadNotifications} from '#/state/queries/notifications/unread' +import {useComposerControls} from '#/state/shell/composer' +import {useFetchHandle} from '#/state/queries/handle' +import {emitSoftReset} from '#/state/events' +import {useQueryClient} from '@tanstack/react-query' +import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed' +import {NavSignupCard} from '#/view/shell/NavSignupCard' +import {truncateAndInvalidate} from '#/state/queries/util' -const ProfileCard = observer(function ProfileCardImpl() { - const store = useStores() +function ProfileCard() { + const {currentAccount} = useSession() + const {isLoading, data: profile} = useProfileQuery({did: currentAccount!.did}) const {isDesktop} = useWebMediaQueries() + const {_} = useLingui() const size = 48 - return store.me.handle ? ( + + return !isLoading && profile ? ( <Link - href={makeProfileLink(store.me)} + href={makeProfileLink({ + did: currentAccount!.did, + handle: currentAccount!.handle, + })} style={[styles.profileCard, !isDesktop && styles.profileCardTablet]} - title="My Profile" + title={_(msg`My Profile`)} asAnchor> - <UserAvatar avatar={store.me.avatar} size={size} /> + <UserAvatar avatar={profile.avatar} size={size} /> </Link> ) : ( <View style={[styles.profileCard, !isDesktop && styles.profileCardTablet]}> @@ -61,12 +77,13 @@ const ProfileCard = observer(function ProfileCardImpl() { /> </View> ) -}) +} function BackBtn() { const {isTablet} = useWebMediaQueries() const pal = usePalette('default') const navigation = useNavigation<NavigationProp>() + const {_} = useLingui() const shouldShow = useNavigationState(state => !isStateAtTabRoot(state)) const onPressBack = React.useCallback(() => { @@ -86,7 +103,7 @@ function BackBtn() { onPress={onPressBack} style={styles.backBtn} accessibilityRole="button" - accessibilityLabel="Go back" + accessibilityLabel={_(msg`Go back`)} accessibilityHint=""> <FontAwesomeIcon size={24} @@ -104,15 +121,10 @@ interface NavItemProps { iconFilled: JSX.Element label: string } -const NavItem = observer(function NavItemImpl({ - count, - href, - icon, - iconFilled, - label, -}: NavItemProps) { +function NavItem({count, href, icon, iconFilled, label}: NavItemProps) { const pal = usePalette('default') - const store = useStores() + const queryClient = useQueryClient() + const {currentAccount} = useSession() const {isDesktop, isTablet} = useWebMediaQueries() const [pathName] = React.useMemo(() => router.matchPath(href), [href]) const currentRouteInfo = useNavigationState(state => { @@ -125,7 +137,7 @@ const NavItem = observer(function NavItemImpl({ currentRouteInfo.name === 'Profile' ? isTab(currentRouteInfo.name, pathName) && (currentRouteInfo.params as CommonNavigatorParams['Profile']).name === - store.me.handle + currentAccount?.handle : isTab(currentRouteInfo.name, pathName) const {onPress} = useLinkProps({to: href}) const onPressWrapped = React.useCallback( @@ -135,12 +147,16 @@ const NavItem = observer(function NavItemImpl({ } e.preventDefault() if (isCurrent) { - store.emitScreenSoftReset() + emitSoftReset() } else { + if (href === '/notifications') { + // fetch new notifs on view + truncateAndInvalidate(queryClient, NOTIFS_RQKEY()) + } onPress() } }, - [onPress, isCurrent, store], + [onPress, isCurrent, queryClient, href], ) return ( @@ -179,12 +195,16 @@ const NavItem = observer(function NavItemImpl({ )} </PressableWithHover> ) -}) +} function ComposeBtn() { - const store = useStores() + const {currentAccount} = useSession() const {getState} = useNavigation() + const {openComposer} = useComposerControls() + const {_} = useLingui() const {isTablet} = useWebMediaQueries() + const [isFetchingHandle, setIsFetchingHandle] = React.useState(false) + const fetchHandle = useFetchHandle() const getProfileHandle = async () => { const {routes} = getState() @@ -196,13 +216,21 @@ function ComposeBtn() { ).name if (handle.startsWith('did:')) { - const cached = await store.profiles.cache.get(handle) - const profile = cached ? cached.data : undefined - // if we can't resolve handle, set to undefined - handle = profile?.handle || undefined + try { + setIsFetchingHandle(true) + handle = await fetchHandle(handle) + } catch (e) { + handle = undefined + } finally { + setIsFetchingHandle(false) + } } - if (!handle || handle === store.me.handle || handle === 'handle.invalid') + if ( + !handle || + handle === currentAccount?.handle || + handle === 'handle.invalid' + ) return undefined return handle @@ -212,17 +240,18 @@ function ComposeBtn() { } const onPressCompose = async () => - store.shell.openComposer({mention: await getProfileHandle()}) + openComposer({mention: await getProfileHandle()}) if (isTablet) { return null } return ( <TouchableOpacity + disabled={isFetchingHandle} style={[styles.newPostBtn]} onPress={onPressCompose} accessibilityRole="button" - accessibilityLabel="New post" + accessibilityLabel={_(msg`New post`)} accessibilityHint=""> <View style={styles.newPostBtnIconWrapper}> <ComposeIcon2 @@ -232,16 +261,18 @@ function ComposeBtn() { /> </View> <Text type="button" style={styles.newPostBtnLabel}> - New Post + <Trans>New Post</Trans> </Text> </TouchableOpacity> ) } -export const DesktopLeftNav = observer(function DesktopLeftNav() { - const store = useStores() +export function DesktopLeftNav() { + const {hasSession, currentAccount} = useSession() const pal = usePalette('default') + const {_} = useLingui() const {isDesktop, isTablet} = useWebMediaQueries() + const numUnread = useUnreadNotifications() return ( <View @@ -251,8 +282,16 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() { pal.view, pal.border, ]}> - {store.session.hasSession && <ProfileCard />} + {hasSession ? ( + <ProfileCard /> + ) : isDesktop ? ( + <View style={{paddingHorizontal: 12}}> + <NavSignupCard /> + </View> + ) : null} + <BackBtn /> + <NavItem href="/" icon={<HomeIcon size={isDesktop ? 24 : 28} style={pal.text} />} @@ -263,7 +302,7 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() { style={pal.text} /> } - label="Home" + label={_(msg`Home`)} /> <NavItem href="/search" @@ -281,7 +320,7 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() { style={pal.text} /> } - label="Search" + label={_(msg`Search`)} /> <NavItem href="/feeds" @@ -299,105 +338,109 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() { size={isDesktop ? 24 : 28} /> } - label="Feeds" + label={_(msg`Feeds`)} /> - <NavItem - href="/notifications" - count={store.me.notifications.unreadCountLabel} - icon={ - <BellIcon - strokeWidth={2} - size={isDesktop ? 24 : 26} - style={pal.text} + + {hasSession && ( + <> + <NavItem + href="/notifications" + count={numUnread} + icon={ + <BellIcon + strokeWidth={2} + size={isDesktop ? 24 : 26} + style={pal.text} + /> + } + iconFilled={ + <BellIconSolid + strokeWidth={1.5} + size={isDesktop ? 24 : 26} + style={pal.text} + /> + } + label={_(msg`Notifications`)} /> - } - iconFilled={ - <BellIconSolid - strokeWidth={1.5} - size={isDesktop ? 24 : 26} - style={pal.text} + <NavItem + href="/lists" + icon={ + <ListIcon + style={pal.text} + size={isDesktop ? 26 : 30} + strokeWidth={2} + /> + } + iconFilled={ + <ListIcon + style={pal.text} + size={isDesktop ? 26 : 30} + strokeWidth={3} + /> + } + label={_(msg`Lists`)} /> - } - label="Notifications" - /> - <NavItem - href="/lists" - icon={ - <ListIcon - style={pal.text} - size={isDesktop ? 26 : 30} - strokeWidth={2} + <NavItem + href="/moderation" + icon={ + <HandIcon + style={pal.text} + size={isDesktop ? 24 : 27} + strokeWidth={5.5} + /> + } + iconFilled={ + <FontAwesomeIcon + icon="hand" + style={pal.text as FontAwesomeIconStyle} + size={isDesktop ? 20 : 26} + /> + } + label={_(msg`Moderation`)} /> - } - iconFilled={ - <ListIcon - style={pal.text} - size={isDesktop ? 26 : 30} - strokeWidth={3} + <NavItem + href={currentAccount ? makeProfileLink(currentAccount) : '/'} + icon={ + <UserIcon + strokeWidth={1.75} + size={isDesktop ? 28 : 30} + style={pal.text} + /> + } + iconFilled={ + <UserIconSolid + strokeWidth={1.75} + size={isDesktop ? 28 : 30} + style={pal.text} + /> + } + label="Profile" /> - } - label="Lists" - /> - <NavItem - href="/moderation" - icon={ - <HandIcon - style={pal.text} - size={isDesktop ? 24 : 27} - strokeWidth={5.5} + <NavItem + href="/settings" + icon={ + <CogIcon + strokeWidth={1.75} + size={isDesktop ? 28 : 32} + style={pal.text} + /> + } + iconFilled={ + <CogIconSolid + strokeWidth={1.5} + size={isDesktop ? 28 : 32} + style={pal.text} + /> + } + label={_(msg`Settings`)} /> - } - iconFilled={ - <FontAwesomeIcon - icon="hand" - style={pal.text as FontAwesomeIconStyle} - size={isDesktop ? 20 : 26} - /> - } - label="Moderation" - /> - {store.session.hasSession && ( - <NavItem - href={makeProfileLink(store.me)} - icon={ - <UserIcon - strokeWidth={1.75} - size={isDesktop ? 28 : 30} - style={pal.text} - /> - } - iconFilled={ - <UserIconSolid - strokeWidth={1.75} - size={isDesktop ? 28 : 30} - style={pal.text} - /> - } - label="Profile" - /> + + <ComposeBtn /> + </> )} - <NavItem - href="/settings" - icon={ - <CogIcon - strokeWidth={1.75} - size={isDesktop ? 28 : 32} - style={pal.text} - /> - } - iconFilled={ - <CogIconSolid - strokeWidth={1.5} - size={isDesktop ? 28 : 32} - style={pal.text} - /> - } - label="Settings" - /> - {store.session.hasSession && <ComposeBtn />} </View> ) -}) +} const styles = StyleSheet.create({ leftNav: { diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx index 84d7d7854..9a5186549 100644 --- a/src/view/shell/desktop/RightNav.tsx +++ b/src/view/shell/desktop/RightNav.tsx @@ -1,5 +1,4 @@ import React from 'react' -import {observer} from 'mobx-react-lite' import {StyleSheet, TouchableOpacity, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {usePalette} from 'lib/hooks/usePalette' @@ -9,15 +8,19 @@ import {Text} from 'view/com/util/text/Text' import {TextLink} from 'view/com/util/Link' import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants' import {s} from 'lib/styles' -import {useStores} from 'state/index' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {pluralize} from 'lib/strings/helpers' import {formatCount} from 'view/com/util/numeric/format' +import {useModalControls} from '#/state/modals' +import {useLingui} from '@lingui/react' +import {Plural, Trans, msg, plural} from '@lingui/macro' +import {useSession} from '#/state/session' +import {useInviteCodesQuery} from '#/state/queries/invites' -export const DesktopRightNav = observer(function DesktopRightNavImpl() { - const store = useStores() +export function DesktopRightNav() { const pal = usePalette('default') const palError = usePalette('error') + const {_} = useLingui() + const {isSandbox, hasSession, currentAccount} = useSession() const {isTablet} = useWebMediaQueries() if (isTablet) { @@ -26,10 +29,22 @@ export const DesktopRightNav = observer(function DesktopRightNavImpl() { return ( <View style={[styles.rightNav, pal.view]}> - {store.session.hasSession && <DesktopSearch />} - {store.session.hasSession && <DesktopFeeds />} - <View style={styles.message}> - {store.session.isSandbox ? ( + <DesktopSearch /> + + {hasSession && ( + <View style={{paddingTop: 18, marginBottom: 18}}> + <DesktopFeeds /> + </View> + )} + + <View + style={[ + styles.message, + { + paddingTop: hasSession ? 0 : 18, + }, + ]}> + {isSandbox ? ( <View style={[palError.view, styles.messageLine, s.p10]}> <Text type="md" style={[palError.text, s.bold]}> SANDBOX. Posts and accounts are not permanent. @@ -37,23 +52,27 @@ export const DesktopRightNav = observer(function DesktopRightNavImpl() { </View> ) : undefined} <View style={[s.flexRow]}> - <TextLink - type="md" - style={pal.link} - href={FEEDBACK_FORM_URL({ - email: store.session.currentSession?.email, - handle: store.session.currentSession?.handle, - })} - text="Send feedback" - /> - <Text type="md" style={pal.textLight}> - · - </Text> + {hasSession && ( + <> + <TextLink + type="md" + style={pal.link} + href={FEEDBACK_FORM_URL({ + email: currentAccount!.email, + handle: currentAccount!.handle, + })} + text={_(msg`Feedback`)} + /> + <Text type="md" style={pal.textLight}> + · + </Text> + </> + )} <TextLink type="md" style={pal.link} href="https://blueskyweb.xyz/support/privacy-policy" - text="Privacy" + text={_(msg`Privacy`)} /> <Text type="md" style={pal.textLight}> · @@ -62,7 +81,7 @@ export const DesktopRightNav = observer(function DesktopRightNavImpl() { type="md" style={pal.link} href="https://blueskyweb.xyz/support/tos" - text="Terms" + text={_(msg`Terms`)} /> <Text type="md" style={pal.textLight}> · @@ -71,52 +90,80 @@ export const DesktopRightNav = observer(function DesktopRightNavImpl() { type="md" style={pal.link} href={HELP_DESK_URL} - text="Help" + text={_(msg`Help`)} /> </View> </View> - <InviteCodes /> + + {hasSession && <InviteCodes />} </View> ) -}) +} -const InviteCodes = observer(function InviteCodesImpl() { - const store = useStores() +function InviteCodes() { const pal = usePalette('default') - - const {invitesAvailable} = store.me + const {openModal} = useModalControls() + const {data: invites} = useInviteCodesQuery() + const invitesAvailable = invites?.available?.length ?? 0 + const {_} = useLingui() const onPress = React.useCallback(() => { - store.shell.openModal({name: 'invite-codes'}) - }, [store]) + openModal({name: 'invite-codes'}) + }, [openModal]) + + if (!invites) { + return null + } + + if (invites?.disabled) { + return ( + <View style={[styles.inviteCodes, pal.border]}> + <FontAwesomeIcon + icon="ticket" + style={[styles.inviteCodesIcon, pal.textLight]} + size={16} + /> + <Text type="md-medium" style={pal.textLight}> + <Trans> + Your invite codes are hidden when logged in using an App Password + </Trans> + </Text> + </View> + ) + } + return ( <TouchableOpacity style={[styles.inviteCodes, pal.border]} onPress={onPress} accessibilityRole="button" - accessibilityLabel={ - invitesAvailable === 1 - ? 'Invite codes: 1 available' - : `Invite codes: ${invitesAvailable} available` - } - accessibilityHint="Opens list of invite codes"> + accessibilityLabel={_( + plural(invitesAvailable, { + one: 'Invite codes: # available', + other: 'Invite codes: # available', + }), + )} + accessibilityHint={_(msg`Opens list of invite codes`)}> <FontAwesomeIcon icon="ticket" style={[ styles.inviteCodesIcon, - store.me.invitesAvailable > 0 ? pal.link : pal.textLight, + invitesAvailable > 0 ? pal.link : pal.textLight, ]} size={16} /> <Text type="md-medium" - style={store.me.invitesAvailable > 0 ? pal.link : pal.textLight}> - {formatCount(store.me.invitesAvailable)} invite{' '} - {pluralize(store.me.invitesAvailable, 'code')} available + style={invitesAvailable > 0 ? pal.link : pal.textLight}> + <Plural + value={formatCount(invitesAvailable)} + one="# invite code available" + other="# invite codes available" + /> </Text> </TouchableOpacity> ) -}) +} const styles = StyleSheet.create({ rightNav: { @@ -142,9 +189,10 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, paddingVertical: 12, flexDirection: 'row', - alignItems: 'center', }, inviteCodesIcon: { + marginTop: 2, marginRight: 6, + flexShrink: 0, }, }) diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index caecea4a8..f899431b6 100644 --- a/src/view/shell/desktop/Search.tsx +++ b/src/view/shell/desktop/Search.tsx @@ -1,56 +1,150 @@ import React from 'react' -import {TextInput, View, StyleSheet, TouchableOpacity} from 'react-native' +import { + ViewStyle, + TextInput, + View, + StyleSheet, + TouchableOpacity, + ActivityIndicator, +} from 'react-native' import {useNavigation, StackActions} from '@react-navigation/native' -import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' -import {observer} from 'mobx-react-lite' -import {useStores} from 'state/index' +import { + AppBskyActorDefs, + moderateProfile, + ProfileModeration, +} from '@atproto/api' +import {Trans, msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {s} from '#/lib/styles' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' +import {makeProfileLink} from '#/lib/routes/links' +import {Link} from '#/view/com/util/Link' import {usePalette} from 'lib/hooks/usePalette' import {MagnifyingGlassIcon2} from 'lib/icons' import {NavigationProp} from 'lib/routes/types' -import {ProfileCard} from 'view/com/profile/ProfileCard' import {Text} from 'view/com/util/text/Text' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' +import {useModerationOpts} from '#/state/queries/preferences' -export const DesktopSearch = observer(function DesktopSearch() { - const store = useStores() +export function SearchResultCard({ + profile, + style, + moderation, +}: { + profile: AppBskyActorDefs.ProfileViewBasic + style: ViewStyle + moderation: ProfileModeration +}) { const pal = usePalette('default') - const textInput = React.useRef<TextInput>(null) - const [isInputFocused, setIsInputFocused] = React.useState<boolean>(false) - const [query, setQuery] = React.useState<string>('') - const autocompleteView = React.useMemo<UserAutocompleteModel>( - () => new UserAutocompleteModel(store), - [store], + + return ( + <Link + href={makeProfileLink(profile)} + title={profile.handle} + asAnchor + anchorNoUnderline> + <View + style={[ + pal.border, + style, + { + borderTopWidth: 1, + flexDirection: 'row', + alignItems: 'center', + gap: 12, + paddingVertical: 8, + paddingHorizontal: 12, + }, + ]}> + <UserAvatar + size={40} + avatar={profile.avatar} + moderation={moderation.avatar} + /> + <View style={{flex: 1}}> + <Text + type="lg" + style={[s.bold, pal.text]} + numberOfLines={1} + lineHeight={1.2}> + {sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.profile, + )} + </Text> + <Text type="md" style={[pal.textLight]} numberOfLines={1}> + {sanitizeHandle(profile.handle, '@')} + </Text> + </View> + </View> + </Link> ) +} + +export function DesktopSearch() { + const {_} = useLingui() + const pal = usePalette('default') const navigation = useNavigation<NavigationProp>() + const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>( + undefined, + ) + const [isActive, setIsActive] = React.useState<boolean>(false) + const [isFetching, setIsFetching] = React.useState<boolean>(false) + const [query, setQuery] = React.useState<string>('') + const [searchResults, setSearchResults] = React.useState< + AppBskyActorDefs.ProfileViewBasic[] + >([]) - // initial setup - React.useEffect(() => { - if (store.me.did) { - autocompleteView.setup() - } - }, [autocompleteView, store.me.did]) + const moderationOpts = useModerationOpts() + const search = useActorAutocompleteFn() - const onChangeQuery = React.useCallback( - (text: string) => { + const onChangeText = React.useCallback( + async (text: string) => { setQuery(text) - if (text.length > 0 && isInputFocused) { - autocompleteView.setActive(true) - autocompleteView.setPrefix(text) + + if (text.length > 0) { + setIsFetching(true) + setIsActive(true) + + if (searchDebounceTimeout.current) + clearTimeout(searchDebounceTimeout.current) + + searchDebounceTimeout.current = setTimeout(async () => { + const results = await search({query: text}) + + if (results) { + setSearchResults(results) + setIsFetching(false) + } + }, 300) } else { - autocompleteView.setActive(false) + if (searchDebounceTimeout.current) + clearTimeout(searchDebounceTimeout.current) + setSearchResults([]) + setIsFetching(false) + setIsActive(false) } }, - [setQuery, autocompleteView, isInputFocused], + [setQuery, search, setSearchResults], ) const onPressCancelSearch = React.useCallback(() => { setQuery('') - autocompleteView.setActive(false) - }, [setQuery, autocompleteView]) - + setIsActive(false) + if (searchDebounceTimeout.current) + clearTimeout(searchDebounceTimeout.current) + }, [setQuery]) const onSubmit = React.useCallback(() => { + setIsActive(false) + if (!query.length) return + setSearchResults([]) + if (searchDebounceTimeout.current) + clearTimeout(searchDebounceTimeout.current) navigation.dispatch(StackActions.push('Search', {q: query})) - autocompleteView.setActive(false) - }, [query, navigation, autocompleteView]) + }, [query, navigation, setSearchResults]) return ( <View style={[styles.container, pal.view]}> @@ -63,19 +157,16 @@ export const DesktopSearch = observer(function DesktopSearch() { /> <TextInput testID="searchTextInput" - ref={textInput} - placeholder="Search" + placeholder={_(msg`Search`)} placeholderTextColor={pal.colors.textLight} selectTextOnFocus returnKeyType="search" value={query} style={[pal.textLight, styles.input]} - onFocus={() => setIsInputFocused(true)} - onBlur={() => setIsInputFocused(false)} - onChangeText={onChangeQuery} + onChangeText={onChangeText} onSubmitEditing={onSubmit} accessibilityRole="search" - accessibilityLabel="Search" + accessibilityLabel={_(msg`Search`)} accessibilityHint="" /> {query ? ( @@ -83,11 +174,11 @@ export const DesktopSearch = observer(function DesktopSearch() { <TouchableOpacity onPress={onPressCancelSearch} accessibilityRole="button" - accessibilityLabel="Cancel search" + accessibilityLabel={_(msg`Cancel search`)} accessibilityHint="Exits inputting search query" onAccessibilityEscape={onPressCancelSearch}> <Text type="lg" style={[pal.link]}> - Cancel + <Trans>Cancel</Trans> </Text> </TouchableOpacity> </View> @@ -95,32 +186,42 @@ export const DesktopSearch = observer(function DesktopSearch() { </View> </View> - {query !== '' && ( + {query !== '' && isActive && moderationOpts && ( <View style={[pal.view, pal.borderDark, styles.resultsContainer]}> - {autocompleteView.suggestions.length ? ( + {isFetching ? ( + <View style={{padding: 8}}> + <ActivityIndicator /> + </View> + ) : ( <> - {autocompleteView.suggestions.map((item, i) => ( - <ProfileCard key={item.did} profile={item} noBorder={i === 0} /> - ))} + {searchResults.length ? ( + searchResults.map((item, i) => ( + <SearchResultCard + key={item.did} + profile={item} + moderation={moderateProfile(item, moderationOpts)} + style={i === 0 ? {borderTopWidth: 0} : {}} + /> + )) + ) : ( + <View> + <Text style={[pal.textLight, styles.noResults]}> + <Trans>No results found for {query}</Trans> + </Text> + </View> + )} </> - ) : ( - <View> - <Text style={[pal.textLight, styles.noResults]}> - No results found for {autocompleteView.prefix} - </Text> - </View> )} </View> )} </View> ) -}) +} const styles = StyleSheet.create({ container: { position: 'relative', width: 300, - paddingBottom: 18, }, search: { paddingHorizontal: 16, @@ -150,15 +251,11 @@ const styles = StyleSheet.create({ paddingVertical: 7, }, resultsContainer: { - // @ts-ignore supported by web - // position: 'fixed', marginTop: 10, - flexDirection: 'column', width: 300, borderWidth: 1, borderRadius: 6, - paddingVertical: 4, }, noResults: { textAlign: 'center', diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 703edf27a..5562af9ac 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -1,5 +1,4 @@ import React from 'react' -import {observer} from 'mobx-react-lite' import {StatusBar} from 'expo-status-bar' import { DimensionValue, @@ -11,7 +10,6 @@ import { import {useSafeAreaInsets} from 'react-native-safe-area-context' import {Drawer} from 'react-native-drawer-layout' import {useNavigationState} from '@react-navigation/native' -import {useStores} from 'state/index' import {ModalsContainer} from 'view/com/modals/Modal' import {Lightbox} from 'view/com/lightbox/Lightbox' import {ErrorBoundary} from 'view/com/util/ErrorBoundary' @@ -25,20 +23,19 @@ import { SafeAreaProvider, initialWindowMetrics, } from 'react-native-safe-area-context' -import {useOTAUpdate} from 'lib/hooks/useOTAUpdate' import { useIsDrawerOpen, useSetDrawerOpen, useIsDrawerSwipeDisabled, } from '#/state/shell' import {isAndroid} from 'platform/detection' +import {useSession} from '#/state/session' +import {useCloseAnyActiveElement} from '#/state/util' -const ShellInner = observer(function ShellInnerImpl() { - const store = useStores() +function ShellInner() { const isDrawerOpen = useIsDrawerOpen() const isDrawerSwipeDisabled = useIsDrawerSwipeDisabled() const setIsDrawerOpen = useSetDrawerOpen() - useOTAUpdate() // this hook polls for OTA updates every few seconds const winDim = useWindowDimensions() const safeAreaInsets = useSafeAreaInsets() const containerPadding = React.useMemo( @@ -55,18 +52,20 @@ const ShellInner = observer(function ShellInnerImpl() { [setIsDrawerOpen], ) const canGoBack = useNavigationState(state => !isStateAtTabRoot(state)) + const {hasSession} = useSession() + const closeAnyActiveElement = useCloseAnyActiveElement() + React.useEffect(() => { let listener = {remove() {}} if (isAndroid) { listener = BackHandler.addEventListener('hardwareBackPress', () => { - setIsDrawerOpen(false) - return store.shell.closeAnyActiveElement() + return closeAnyActiveElement() }) } return () => { listener.remove() } - }, [store, setIsDrawerOpen]) + }, [closeAnyActiveElement]) return ( <> @@ -78,28 +77,19 @@ const ShellInner = observer(function ShellInnerImpl() { onOpen={onOpenDrawer} onClose={onCloseDrawer} swipeEdgeWidth={winDim.width / 2} - swipeEnabled={ - !canGoBack && store.session.hasSession && !isDrawerSwipeDisabled - }> + swipeEnabled={!canGoBack && hasSession && !isDrawerSwipeDisabled}> <TabsNavigator /> </Drawer> </ErrorBoundary> </View> - <Composer - active={store.shell.isComposerActive} - winHeight={winDim.height} - replyTo={store.shell.composerOpts?.replyTo} - onPost={store.shell.composerOpts?.onPost} - quote={store.shell.composerOpts?.quote} - mention={store.shell.composerOpts?.mention} - /> + <Composer winHeight={winDim.height} /> <ModalsContainer /> <Lightbox /> </> ) -}) +} -export const Shell: React.FC = observer(function ShellImpl() { +export const Shell: React.FC = function ShellImpl() { const pal = usePalette('default') const theme = useTheme() return ( @@ -112,7 +102,7 @@ export const Shell: React.FC = observer(function ShellImpl() { </View> </SafeAreaProvider> ) -}) +} const styles = StyleSheet.create({ outerContainer: { diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 843d0b284..38da860bd 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -1,9 +1,5 @@ import React, {useEffect} from 'react' -import {observer} from 'mobx-react-lite' import {View, StyleSheet, TouchableOpacity} from 'react-native' -import {useStores} from 'state/index' -import {DesktopLeftNav} from './desktop/LeftNav' -import {DesktopRightNav} from './desktop/RightNav' import {ErrorBoundary} from '../com/util/ErrorBoundary' import {Lightbox} from '../com/lightbox/Lightbox' import {ModalsContainer} from '../com/modals/Modal' @@ -13,30 +9,29 @@ import {s, colors} from 'lib/styles' import {RoutesContainer, FlatNavigator} from '../../Navigation' import {DrawerContent} from './Drawer' import {useWebMediaQueries} from '../../lib/hooks/useWebMediaQueries' -import {BottomBarWeb} from './bottom-bar/BottomBarWeb' import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' import {useAuxClick} from 'lib/hooks/useAuxClick' +import {t} from '@lingui/macro' import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell' +import {useCloseAllActiveElements} from '#/state/util' -const ShellInner = observer(function ShellInnerImpl() { - const store = useStores() +function ShellInner() { const isDrawerOpen = useIsDrawerOpen() const setDrawerOpen = useSetDrawerOpen() - const {isDesktop, isMobile} = useWebMediaQueries() + const {isDesktop} = useWebMediaQueries() const navigator = useNavigation<NavigationProp>() + const closeAllActiveElements = useCloseAllActiveElements() + useAuxClick() useEffect(() => { - navigator.addListener('state', () => { - setDrawerOpen(false) - store.shell.closeAnyActiveElement() + const unsubscribe = navigator.addListener('state', () => { + closeAllActiveElements() }) - }, [navigator, store.shell, setDrawerOpen]) + return unsubscribe + }, [navigator, closeAllActiveElements]) - const showBottomBar = isMobile && !store.onboarding.isActive - const showSideNavs = - !isMobile && store.session.hasSession && !store.onboarding.isActive return ( <View style={[s.hContentRegion, {overflow: 'hidden'}]}> <View style={s.hContentRegion}> @@ -44,28 +39,14 @@ const ShellInner = observer(function ShellInnerImpl() { <FlatNavigator /> </ErrorBoundary> </View> - {showSideNavs && ( - <> - <DesktopLeftNav /> - <DesktopRightNav /> - </> - )} - <Composer - active={store.shell.isComposerActive} - winHeight={0} - replyTo={store.shell.composerOpts?.replyTo} - quote={store.shell.composerOpts?.quote} - onPost={store.shell.composerOpts?.onPost} - mention={store.shell.composerOpts?.mention} - /> - {showBottomBar && <BottomBarWeb />} + <Composer winHeight={0} /> <ModalsContainer /> <Lightbox /> {!isDesktop && isDrawerOpen && ( <TouchableOpacity onPress={() => setDrawerOpen(false)} style={styles.drawerMask} - accessibilityLabel="Close navigation footer" + accessibilityLabel={t`Close navigation footer`} accessibilityHint="Closes bottom navigation bar"> <View style={styles.drawerContainer}> <DrawerContent /> @@ -74,9 +55,9 @@ const ShellInner = observer(function ShellInnerImpl() { )} </View> ) -}) +} -export const Shell: React.FC = observer(function ShellImpl() { +export const Shell: React.FC = function ShellImpl() { const pageBg = useColorSchemeStyle(styles.bgLight, styles.bgDark) return ( <View style={[s.hContentRegion, pageBg]}> @@ -85,7 +66,7 @@ export const Shell: React.FC = observer(function ShellImpl() { </RoutesContainer> </View> ) -}) +} const styles = StyleSheet.create({ bgLight: { |