Compare commits

..

1 Commits

Author SHA1 Message Date
Eric Bailey 6126dea6cf Enable logger in prod (#1815) 2023-11-24 17:38:35 -06:00
374 changed files with 17951 additions and 26342 deletions
+7
View File
@@ -1,3 +1,10 @@
jest.mock('sentry-expo', () => ({
init: () => jest.fn(),
Native: {
ReactNativeTracing: jest.fn().mockImplementation(() => ({
start: jest.fn(),
stop: jest.fn(),
})),
ReactNavigationInstrumentation: jest.fn(),
},
}))
-1
View File
@@ -45,7 +45,6 @@ module.exports = function (api) {
},
},
],
'macros',
'react-native-reanimated/plugin', // NOTE: this plugin MUST be last
],
env: {
+2 -2
View File
@@ -176,8 +176,8 @@
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
{% block html_head_extra -%}{%- endblock %}
<meta name="application-name" content="Bluesky">
<meta name="generator" content="bskyweb">
<meta name="application-name" name="Bluesky">
<meta name="generator" name="bskyweb">
</head>
<body>
{%- block body_all %}
-113
View File
@@ -1,113 +0,0 @@
# Internationalization
We want the official Bluesky app to be supported in as many languages as possible. If you want to help us translate the app, please open a PR or issue on the [Bluesky app repo on GitHub](https://github.com/bluesky-social/social-app)
## Tools
We are using Lingui to manage translations. You can find the documentation [here](https://lingui.dev/).
### Adding new strings
When adding a new string, do it as follows:
```jsx
// Before
import { Text } from "react-native";
<Text>Hello World</Text>
```
```jsx
// After
import { Text } from "react-native";
import { Trans } from "@lingui/macro";
<Text><Trans>Hello World</Trans></Text>
```
The `<Trans>` macro will extract the string and add it to the catalog. It is not really a component, but a macro. Further reading [here](https://lingui.dev/ref/macro.html)
However sometimes you will run into this case:
```jsx
// Before
import { Text } from "react-native";
const text = "Hello World";
<Text accessibilityLabel="Label is here">{text}</Text>
```
In this case, you cannot use the `useLingui()` hook:
```jsx
import { msg } from "@lingui/macro";
import { useLingui } from "@lingui/react";
const { _ } = useLingui();
return <Text accessibilityLabel={_(msg`Label is here`)}>{text}</Text>
```
If you want to do this outside of a React component, you can use the `t` macro instead (note: this won't react to changes if the locale is switched dynamically within the app):
```jsx
import { t } from "@lingui/macro";
const text = t`Hello World`;
```
We can then run `yarn intl:extract` to update the catalog in `src/locale/locales/{locale}/messages.po`. This will add the new string to the catalog.
We can then run `yarn intl:compile` to update the translation files in `src/locale/locales/{locale}/messages.js`. This will add the new string to the translation files.
The configuration for translations is defined in `lingui.config.js`
So the workflow is as follows:
1. Wrap messages in Trans macro
2. Run `yarn intl:extract` command to generate message catalogs
3. Translate message catalogs (send them to translators usually)
4. Run `yarn intl:compile` to create runtime catalogs
5. Load runtime catalog
6. Enjoy translated app!
### Common pitfalls
These pitfalls are memoization pitfalls that will cause the components to not re-render when the locale is changed -- causing stale translations to be shown.
```jsx
import { msg } from "@lingui/macro";
import { i18n } from "@lingui/core";
const welcomeMessage = msg`Welcome!`;
// ❌ Bad! This code won't work
export function Welcome() {
const buggyWelcome = useMemo(() => {
return i18n._(welcomeMessage);
}, []);
return <div>{buggyWelcome}</div>;
}
// ❌ Bad! This code won't work either because the reference to i18n does not change
export function Welcome() {
const { i18n } = useLingui();
const buggyWelcome = useMemo(() => {
return i18n._(welcomeMessage);
}, [i18n]);
return <div>{buggyWelcome}</div>;
}
// ✅ Good! `useMemo` has i18n context in the dependency
export function Welcome() {
const linguiCtx = useLingui();
const welcome = useMemo(() => {
return linguiCtx.i18n._(welcomeMessage);
}, [linguiCtx]);
return <div>{welcome}</div>;
}
// 🤩 Better! `useMemo` consumes the `_` function from the Lingui context
export function Welcome() {
const { _ } = useLingui();
const welcome = useMemo(() => {
return _(welcomeMessage);
}, [_]);
return <div>{welcome}</div>;
}
```
-11
View File
@@ -1,11 +0,0 @@
/** @type {import('@lingui/conf').LinguiConfig} */
module.exports = {
locales: ['en', 'cs', 'fr', 'hi', 'es'],
catalogs: [
{
path: '<rootDir>/src/locale/locales/{locale}/messages',
include: ['src'],
},
],
format: 'po',
}
+3 -9
View File
@@ -28,9 +28,7 @@
"perf:test:measure": "NODE_ENV=test flashlight test --bundleId xyz.blueskyweb.app --testCommand 'yarn perf:test' --duration 150000 --resultsFilePath .perf/results.json",
"perf:test:results": "NODE_ENV=test flashlight report .perf/results.json",
"perf:measure": "NODE_ENV=test flashlight measure",
"build:apk": "eas build -p android --profile dev-android-apk",
"intl:extract": "lingui extract",
"intl:compile": "lingui compile"
"build:apk": "eas build -p android --profile dev-android-apk"
},
"dependencies": {
"@atproto/api": "^0.6.23",
@@ -44,7 +42,6 @@
"@fortawesome/free-solid-svg-icons": "^6.1.1",
"@fortawesome/react-native-fontawesome": "^0.3.0",
"@gorhom/bottom-sheet": "^4.5.1",
"@lingui/react": "^4.5.0",
"@mattermost/react-native-paste-input": "^0.6.4",
"@miblanchard/react-native-slider": "^2.3.1",
"@react-native-async-storage/async-storage": "1.18.2",
@@ -63,7 +60,7 @@
"@segment/analytics-react-native": "^2.10.1",
"@segment/sovran-react-native": "^0.4.5",
"@sentry/react-native": "5.10.0",
"@tanstack/react-query": "^5.8.1",
"@tanstack/react-query": "^4.33.0",
"@tiptap/core": "^2.0.0-beta.220",
"@tiptap/extension-document": "^2.0.0-beta.220",
"@tiptap/extension-hard-break": "^2.0.3",
@@ -167,12 +164,10 @@
},
"devDependencies": {
"@atproto/dev-env": "^0.2.5",
"@babel/core": "^7.23.2",
"@babel/core": "^7.20.0",
"@babel/preset-env": "^7.20.0",
"@babel/runtime": "^7.20.0",
"@did-plc/server": "^0.0.1",
"@lingui/cli": "^4.5.0",
"@lingui/macro": "^4.5.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.11",
"@react-native-community/eslint-config": "^3.0.0",
"@testing-library/jest-native": "^5.4.1",
@@ -197,7 +192,6 @@
"@typescript-eslint/parser": "^5.48.2",
"babel-jest": "^29.4.2",
"babel-loader": "^9.1.2",
"babel-plugin-macros": "^3.1.0",
"babel-plugin-module-resolver": "^5.0.0",
"babel-plugin-react-native-web": "^0.18.12",
"detox": "^20.13.0",
+26 -91
View File
@@ -5,126 +5,61 @@ 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 {withSentry} from 'lib/sentry'
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'
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 SessionProvider,
useSession,
useSessionApi,
} from 'state/session'
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import * as persisted from '#/state/persisted'
import {i18n} from '@lingui/core'
import {I18nProvider} from '@lingui/react'
import {messages} from './locale/locales/en/messages'
i18n.load('en', messages)
i18n.activate('en')
enableFreeze(true)
SplashScreen.preventAutoHideAsync()
function InnerApp() {
const colorMode = useColorMode()
const {isInitialLoad} = useSession()
const {resumeSession} = useSessionApi()
const App = observer(function AppImpl() {
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
undefined,
)
// init
useEffect(() => {
initReminders()
analytics.init()
notifications.init(queryClient)
listenSessionDropped(() => {
Toast.show('Sorry! Your session expired. Please log in again.')
setupState().then(store => {
setRootStore(store)
analytics.init(store)
notifications.init(store)
store.onSessionDropped(() => {
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 (isInitialLoad) {
// TODO add a loading state
if (!rootStore) {
return null
}
/*
* Session and initial state should be loaded prior to rendering below.
*/
return (
<UnreadNotifsProvider>
<ThemeProvider theme={colorMode}>
<analytics.Provider>
<I18nProvider i18n={i18n}>
{/* All components should be within this provider */}
<RootSiblingParent>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={rootStore.shell.colorMode}>
<RootSiblingParent>
<analytics.Provider>
<RootStoreProvider value={rootStore}>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</RootSiblingParent>
</I18nProvider>
</analytics.Provider>
</RootStoreProvider>
</analytics.Provider>
</RootSiblingParent>
</ThemeProvider>
</UnreadNotifsProvider>
)
}
function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
initPersistedState().then(() => setReady(true))
}, [])
if (!isReady) {
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 (
<QueryClientProvider client={queryClient}>
<SessionProvider>
<ShellStateProvider>
<PrefsStateProvider>
<MutedThreadsProvider>
<InvitesStateProvider>
<ModalStateProvider>
<LightboxStateProvider>
<InnerApp />
</LightboxStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</MutedThreadsProvider>
</PrefsStateProvider>
</ShellStateProvider>
</SessionProvider>
</QueryClientProvider>
)
}
})
export default App
export default withSentry(App)
+22 -86
View File
@@ -1,118 +1,54 @@
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 {i18n} from '@lingui/core'
import {I18nProvider} from '@lingui/react'
import {defaultLocale, dynamicActivate} from './locale/i18n'
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 SessionProvider,
useSession,
useSessionApi,
} from 'state/session'
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
import * as persisted from '#/state/persisted'
enableFreeze(true)
function InnerApp() {
const {isInitialLoad} = useSession()
const {resumeSession} = useSessionApi()
const colorMode = useColorMode()
const App = observer(function AppImpl() {
const [rootStore, setRootStore] = useState<RootStoreModel | undefined>(
undefined,
)
// init
useEffect(() => {
initReminders()
analytics.init()
dynamicActivate(defaultLocale) // async import of locale data
const account = persisted.get('session').currentAccount
resumeSession(account)
}, [resumeSession])
setupState().then(store => {
setRootStore(store)
analytics.init(store)
})
}, [])
// show nothing prior to init
if (isInitialLoad) {
// TODO add a loading state
if (!rootStore) {
return null
}
/*
* Session and initial state should be loaded prior to rendering below.
*/
return (
<UnreadNotifsProvider>
<ThemeProvider theme={colorMode}>
<analytics.Provider>
<I18nProvider i18n={i18n}>
{/* All components should be within this provider */}
<RootSiblingParent>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={rootStore.shell.colorMode}>
<RootSiblingParent>
<analytics.Provider>
<RootStoreProvider value={rootStore}>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</RootSiblingParent>
</I18nProvider>
<ToastContainer />
</analytics.Provider>
<ToastContainer />
</RootStoreProvider>
</analytics.Provider>
</RootSiblingParent>
</ThemeProvider>
</UnreadNotifsProvider>
)
}
function App() {
const [isReady, setReady] = useState(false)
React.useEffect(() => {
initPersistedState().then(() => setReady(true))
}, [])
if (!isReady) {
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 (
<QueryClientProvider client={queryClient}>
<SessionProvider>
<ShellStateProvider>
<PrefsStateProvider>
<MutedThreadsProvider>
<InvitesStateProvider>
<ModalStateProvider>
<LightboxStateProvider>
<InnerApp />
</LightboxStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</MutedThreadsProvider>
</PrefsStateProvider>
</ShellStateProvider>
</SessionProvider>
</QueryClientProvider>
)
}
})
export default App
+17 -8
View File
@@ -1,6 +1,7 @@
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,
@@ -32,10 +33,11 @@ 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 {getRoutingInstrumentation} from 'lib/sentry'
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'
@@ -345,7 +347,7 @@ function NotificationsTabNavigator() {
)
}
function MyProfileTabNavigator() {
const MyProfileTabNavigator = observer(function MyProfileTabNavigatorImpl() {
const contentStyle = useColorSchemeStyle(styles.bgLight, styles.bgDark)
return (
<MyProfileTab.Navigator
@@ -367,17 +369,18 @@ function MyProfileTabNavigator() {
{commonScreens(MyProfileTab as typeof HomeTab)}
</MyProfileTab.Navigator>
)
}
})
/**
* The FlatNavigator is used by Web to represent the routes
* in a single ("flat") stack.
*/
const FlatNavigator = () => {
const FlatNavigator = observer(function FlatNavigatorImpl() {
const pal = usePalette('default')
const numUnread = useUnreadNotifications()
const store = useStores()
const unreadCountLabel = store.me.notifications.unreadCountLabel
const title = (page: string) => bskyTitle(page, numUnread)
const title = (page: string) => bskyTitle(page, unreadCountLabel)
return (
<Flat.Navigator
screenOptions={{
@@ -407,10 +410,10 @@ const FlatNavigator = () => {
getComponent={() => NotificationsScreen}
options={{title: title('Notifications')}}
/>
{commonScreens(Flat as typeof HomeTab, numUnread)}
{commonScreens(Flat as typeof HomeTab, unreadCountLabel)}
</Flat.Navigator>
)
}
})
/**
* The RoutesContainer should wrap all components which need access
@@ -475,6 +478,12 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
)
console.log(`Time to first paint: ${initMs} ms`)
logModuleInitTrace()
// Register the navigation container with the Sentry instrumentation (only works on native)
if (isNative) {
const routingInstrumentation = getRoutingInstrumentation()
routingInstrumentation.registerNavigationContainer(navigationRef)
}
}}>
{children}
</NavigationContainer>
+38 -52
View File
@@ -1,26 +1,16 @@
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 {z} from 'zod'
import {useSession} from '#/state/session'
import {RootStoreModel, AppInfo} from 'state/models/root-store'
import {useStores} from 'state/models/root-store'
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',
@@ -31,10 +21,10 @@ const segmentClient = createClient({
export const track = segmentClient?.track?.bind?.(segmentClient) as TrackEvent
export function useAnalytics() {
const {hasSession} = useSession()
const store = useStores()
const methods: ClientMethods = useAnalyticsOrig()
return React.useMemo(() => {
if (hasSession) {
if (store.session.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
@@ -55,18 +45,21 @@ export function useAnalytics() {
alias: () => Promise<void>,
reset: () => Promise<void>,
}
}, [hasSession, methods])
}, [store, methods])
}
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()
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()
}
}
})
@@ -74,7 +67,7 @@ export function init() {
// 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(async () => {
segmentClient.isReady.onChange(() => {
if (AppState.currentState !== 'active') {
logger.debug('Prevented a metrics ping while the app was backgrounded')
return
@@ -85,29 +78,35 @@ export function init() {
return
}
const oldAppInfo = await readAppInfo()
const oldAppInfo = store.appInfo
const newAppInfo = context.app as AppInfo
writeAppInfo(newAppInfo)
store.setAppInfo(newAppInfo)
logger.debug('Recording app info', {new: newAppInfo, old: oldAppInfo})
if (typeof oldAppInfo === 'undefined') {
segmentClient.track('Application Installed', {
version: newAppInfo.version,
build: newAppInfo.build,
})
if (store.session.hasSession) {
segmentClient.track('Application Installed', {
version: newAppInfo.version,
build: newAppInfo.build,
})
}
} else if (newAppInfo.version !== oldAppInfo.version) {
segmentClient.track('Application Updated', {
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,
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
@@ -131,16 +130,3 @@ 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
}
+11 -11
View File
@@ -4,11 +4,10 @@ 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(
{
@@ -25,10 +24,10 @@ const segmentClient = createClient(
export const track = segmentClient?.track?.bind?.(segmentClient)
export function useAnalytics() {
const {hasSession} = useSession()
const store = useStores()
const methods = useAnalyticsOrig()
return React.useMemo(() => {
if (hasSession) {
if (store.session.hasSession) {
return methods
}
// dont send analytics pings for anonymous users
@@ -41,14 +40,15 @@ export function useAnalytics() {
alias: () => {},
reset: () => {},
}
}, [hasSession, methods])
}, [store, methods])
}
export function init() {
listenSessionLoaded(account => {
if (account.did) {
if (account.did) {
const did_hashed = sha256(account.did)
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 {
+3 -14
View File
@@ -4,7 +4,7 @@ import {
AppBskyEmbedRecordWithMedia,
AppBskyEmbedRecord,
} from '@atproto/api'
import {ReasonFeedSource} from './feed/types'
import {FeedSourceInfo} from './feed/types'
import {isPostInLanguage} from '../../locale/helpers'
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
@@ -65,9 +65,9 @@ export class FeedViewPostsSlice {
)
}
get source(): ReasonFeedSource | undefined {
get source(): FeedSourceInfo | undefined {
return this.items.find(item => '__source' in item && !!item.__source)
?.__source as ReasonFeedSource
?.__source as FeedSourceInfo
}
containsUri(uri: string) {
@@ -116,17 +116,6 @@ export class FeedViewPostsSlice {
}
}
export class NoopFeedTuner {
reset() {}
tune(
feed: FeedViewPost[],
_tunerFns: FeedTunerFn[] = [],
_opts?: {dryRun: boolean; maintainOrder: boolean},
): FeedViewPostsSlice[] {
return feed.map(item => new FeedViewPostsSlice([item]))
}
}
export class FeedTuner {
seenUris: Set<string> = new Set()
+13 -12
View File
@@ -1,37 +1,38 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetAuthorFeed as GetAuthorFeed,
BskyAgent,
} from '@atproto/api'
import {RootStoreModel} from 'state/index'
import {FeedAPI, FeedAPIResponse} from './types'
export class AuthorFeedAPI implements FeedAPI {
cursor: string | undefined
constructor(
public agent: BskyAgent,
public rootStore: RootStoreModel,
public params: GetAuthorFeed.QueryParams,
) {}
reset() {
this.cursor = undefined
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.agent.getAuthorFeed({
const res = await this.rootStore.agent.getAuthorFeed({
...this.params,
limit: 1,
})
return res.data.feed[0]
}
async fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await this.agent.getAuthorFeed({
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
const res = await this.rootStore.agent.getAuthorFeed({
...this.params,
cursor,
cursor: this.cursor,
limit,
})
if (res.success) {
this.cursor = res.data.cursor
return {
cursor: res.data.cursor,
feed: this._filter(res.data.feed),
+13 -12
View File
@@ -1,37 +1,38 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetFeed as GetCustomFeed,
BskyAgent,
} from '@atproto/api'
import {RootStoreModel} from 'state/index'
import {FeedAPI, FeedAPIResponse} from './types'
export class CustomFeedAPI implements FeedAPI {
cursor: string | undefined
constructor(
public agent: BskyAgent,
public rootStore: RootStoreModel,
public params: GetCustomFeed.QueryParams,
) {}
reset() {
this.cursor = undefined
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.agent.app.bsky.feed.getFeed({
const res = await this.rootStore.agent.app.bsky.feed.getFeed({
...this.params,
limit: 1,
})
return res.data.feed[0]
}
async fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await this.agent.app.bsky.feed.getFeed({
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
const res = await this.rootStore.agent.app.bsky.feed.getFeed({
...this.params,
cursor,
cursor: this.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
+14 -12
View File
@@ -1,28 +1,30 @@
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
import {AppBskyFeedDefs} from '@atproto/api'
import {RootStoreModel} from 'state/index'
import {FeedAPI, FeedAPIResponse} from './types'
export class FollowingFeedAPI implements FeedAPI {
constructor(public agent: BskyAgent) {}
cursor: string | undefined
constructor(public rootStore: RootStoreModel) {}
reset() {
this.cursor = undefined
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.agent.getTimeline({
const res = await this.rootStore.agent.getTimeline({
limit: 1,
})
return res.data.feed[0]
}
async fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await this.agent.getTimeline({
cursor,
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
const res = await this.rootStore.agent.getTimeline({
cursor: this.cursor,
limit,
})
if (res.success) {
this.cursor = res.data.cursor
return {
cursor: res.data.cursor,
feed: res.data.feed,
+13 -12
View File
@@ -1,37 +1,38 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetActorLikes as GetActorLikes,
BskyAgent,
} from '@atproto/api'
import {RootStoreModel} from 'state/index'
import {FeedAPI, FeedAPIResponse} from './types'
export class LikesFeedAPI implements FeedAPI {
cursor: string | undefined
constructor(
public agent: BskyAgent,
public rootStore: RootStoreModel,
public params: GetActorLikes.QueryParams,
) {}
reset() {
this.cursor = undefined
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.agent.getActorLikes({
const res = await this.rootStore.agent.getActorLikes({
...this.params,
limit: 1,
})
return res.data.feed[0]
}
async fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await this.agent.getActorLikes({
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
const res = await this.rootStore.agent.getActorLikes({
...this.params,
cursor,
cursor: this.cursor,
limit,
})
if (res.success) {
this.cursor = res.data.cursor
return {
cursor: res.data.cursor,
feed: res.data.feed,
+13 -12
View File
@@ -1,37 +1,38 @@
import {
AppBskyFeedDefs,
AppBskyFeedGetListFeed as GetListFeed,
BskyAgent,
} from '@atproto/api'
import {RootStoreModel} from 'state/index'
import {FeedAPI, FeedAPIResponse} from './types'
export class ListFeedAPI implements FeedAPI {
cursor: string | undefined
constructor(
public agent: BskyAgent,
public rootStore: RootStoreModel,
public params: GetListFeed.QueryParams,
) {}
reset() {
this.cursor = undefined
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.agent.app.bsky.feed.getListFeed({
const res = await this.rootStore.agent.app.bsky.feed.getListFeed({
...this.params,
limit: 1,
})
return res.data.feed[0]
}
async fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
const res = await this.agent.app.bsky.feed.getListFeed({
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
const res = await this.rootStore.agent.app.bsky.feed.getListFeed({
...this.params,
cursor,
cursor: this.cursor,
limit,
})
if (res.success) {
this.cursor = res.data.cursor
return {
cursor: res.data.cursor,
feed: res.data.feed,
+39 -49
View File
@@ -1,12 +1,11 @@
import {AppBskyFeedDefs, AppBskyFeedGetTimeline, BskyAgent} from '@atproto/api'
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, ReasonFeedSource} from './types'
import {FeedParams} from '#/state/queries/post-feed'
import {FeedTunerFn} from '../feed-manip'
import {FeedAPI, FeedAPIResponse, FeedSourceInfo} from './types'
const REQUEST_WAIT_MS = 500 // 500ms
const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
@@ -18,49 +17,28 @@ export class MergeFeedAPI implements FeedAPI {
itemCursor = 0
sampleCursor = 0
constructor(
public agent: BskyAgent,
public params: FeedParams,
public feedTuners: FeedTunerFn[],
) {
this.following = new MergeFeedSource_Following(this.agent, this.feedTuners)
constructor(public rootStore: RootStoreModel) {
this.following = new MergeFeedSource_Following(this.rootStore)
}
reset() {
this.following = new MergeFeedSource_Following(this.agent, this.feedTuners)
this.following = new MergeFeedSource_Following(this.rootStore)
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(this.agent, feedUri, this.feedTuners),
),
)
} else {
this.customFeeds = []
}
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
const res = await this.agent.getTimeline({
const res = await this.rootStore.agent.getTimeline({
limit: 1,
})
return res.data.feed[0]
}
async fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse> {
if (!cursor) {
this.reset()
}
async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
// we capture here to ensure the data has loaded
this._captureFeedsIfNeeded()
const promises = []
@@ -98,7 +76,7 @@ export class MergeFeedAPI implements FeedAPI {
}
return {
cursor: posts.length ? String(this.itemCursor) : undefined,
cursor: posts.length ? 'fake' : undefined,
feed: posts,
}
}
@@ -129,15 +107,28 @@ 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: ReasonFeedSource | undefined
sourceInfo: FeedSourceInfo | undefined
cursor: string | undefined = undefined
queue: AppBskyFeedDefs.FeedViewPost[] = []
hasMore = true
constructor(public agent: BskyAgent, public feedTuners: FeedTunerFn[]) {}
constructor(public rootStore: RootStoreModel) {}
get numReady() {
return this.queue.length
@@ -199,12 +190,16 @@ class MergeFeedSource_Following extends MergeFeedSource {
cursor: string | undefined,
limit: number,
): Promise<AppBskyFeedGetTimeline.Response> {
const res = await this.agent.getTimeline({cursor, limit})
const res = await this.rootStore.agent.getTimeline({cursor, limit})
// run the tuner pre-emptively to ensure better mixing
const slices = this.tuner.tune(res.data.feed, this.feedTuners, {
dryRun: false,
maintainOrder: true,
})
const slices = this.tuner.tune(
res.data.feed,
this.rootStore.preferences.getFeedTuners('home'),
{
dryRun: false,
maintainOrder: true,
},
)
res.data.feed = slices.map(slice => slice.rootItem)
return res
}
@@ -213,19 +208,14 @@ class MergeFeedSource_Following extends MergeFeedSource {
class MergeFeedSource_Custom extends MergeFeedSource {
minDate: Date
constructor(
public agent: BskyAgent,
public feedUri: string,
public feedTuners: FeedTunerFn[],
) {
super(agent, feedTuners)
constructor(public rootStore: RootStoreModel, public feedUri: string) {
super(rootStore)
this.sourceInfo = {
$type: 'reasonFeedSource',
displayName: feedUri.split('/').pop() || '',
uri: feedUriToHref(feedUri),
}
this.minDate = new Date(Date.now() - POST_AGE_CUTOFF)
this.agent.app.bsky.feed
this.rootStore.agent.app.bsky.feed
.getFeedGenerator({
feed: feedUri,
})
@@ -244,7 +234,7 @@ class MergeFeedSource_Custom extends MergeFeedSource {
limit: number,
): Promise<AppBskyFeedGetTimeline.Response> {
try {
const res = await this.agent.app.bsky.feed.getFeed({
const res = await this.rootStore.agent.app.bsky.feed.getFeed({
cursor,
limit,
feed: this.feedUri,
+3 -18
View File
@@ -6,27 +6,12 @@ export interface FeedAPIResponse {
}
export interface FeedAPI {
reset(): void
peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost>
fetch({
cursor,
limit,
}: {
cursor: string | undefined
limit: number
}): Promise<FeedAPIResponse>
fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse>
}
export interface ReasonFeedSource {
$type: 'reasonFeedSource'
export interface FeedSourceInfo {
uri: string
displayName: string
}
export function isReasonFeedSource(v: unknown): v is ReasonFeedSource {
return (
!!v &&
typeof v === 'object' &&
'$type' in v &&
v.$type === 'reasonFeedSource'
)
}
+38 -10
View File
@@ -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,19 +25,46 @@ 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(
agent: BskyAgent,
store: RootStoreModel,
blob: string,
encoding: string,
): Promise<ComAtprotoRepoUploadBlob.Response> {
if (isWeb) {
// `blob` should be a data uri
return agent.uploadBlob(convertDataURIToUint8Array(blob), {
return store.agent.uploadBlob(convertDataURIToUint8Array(blob), {
encoding,
})
} else {
// `blob` should be a path to a file in the local FS
return agent.uploadBlob(
return store.agent.uploadBlob(
blob, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
{encoding},
)
@@ -54,11 +81,12 @@ interface PostOpts {
extLink?: ExternalEmbedDraft
images?: ImageModel[]
labels?: string[]
knownHandles?: Set<string>
onStateChange?: (state: string) => void
langs?: string[]
}
export async function post(agent: BskyAgent, opts: PostOpts) {
export async function post(store: RootStoreModel, opts: PostOpts) {
let embed:
| AppBskyEmbedImages.Main
| AppBskyEmbedExternal.Main
@@ -74,7 +102,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
)
opts.onStateChange?.('Processing...')
await rt.detectFacets(agent)
await rt.detectFacets(store.agent)
rt = shortenLinks(rt)
// filter out any mention facets that didn't map to a user
@@ -107,7 +135,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
await image.compress()
const path = image.compressed?.path ?? image.path
const {width, height} = image.compressed || image
const res = await uploadBlob(agent, path, 'image/jpeg')
const res = await uploadBlob(store, path, 'image/jpeg')
images.push({
image: res.data.blob,
alt: image.altText ?? '',
@@ -157,7 +185,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
}
if (encoding) {
const thumbUploadRes = await uploadBlob(
agent,
store,
opts.extLink.localThumb.path,
encoding,
)
@@ -196,7 +224,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
// add replyTo if post is a reply to another post
if (opts.replyTo) {
const replyToUrip = new AtUri(opts.replyTo)
const parentPost = await agent.getPost({
const parentPost = await store.agent.getPost({
repo: replyToUrip.host,
rkey: replyToUrip.rkey,
})
@@ -229,7 +257,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
try {
opts.onStateChange?.('Posting...')
return await agent.post({
return await store.agent.post({
text: rt.text,
facets: rt.facets,
reply,
+68
View File
@@ -0,0 +1,68 @@
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)
}
}
-11
View File
@@ -1,11 +0,0 @@
export default class BroadcastChannel {
constructor(public name: string) {}
postMessage(_data: any) {}
close() {}
onmessage: (event: MessageEvent) => void = () => {}
addEventListener(_type: string, _listener: (event: MessageEvent) => void) {}
removeEventListener(
_type: string,
_listener: (event: MessageEvent) => void,
) {}
}
-1
View File
@@ -1 +0,0 @@
export default BroadcastChannel
+2 -11
View File
@@ -1,10 +1,4 @@
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
import {Insets} from 'react-native'
const HELP_DESK_LANG = 'en-us'
export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
@@ -49,10 +43,7 @@ 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') ||
url.startsWith('https://api.bsky.app')
)
return url.startsWith('https://bsky.social')
}
export const PROD_TEAM_HANDLES = [
+30 -18
View File
@@ -1,29 +1,41 @@
import {useCallback} from 'react'
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 {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'
export function useAccountSwitcher() {
export function useAccountSwitcher(): [
boolean,
(v: boolean) => void,
(acct: AccountData) => Promise<void>,
] {
const {track} = useAnalytics()
const {selectAccount, clearCurrentAccount} = useSessionApi()
const closeAllActiveElements = useCloseAllActiveElements()
const store = useStores()
const [isSwitching, setIsSwitching] = useState(false)
const navigation = useNavigation<NavigationProp>()
const onPressSwitchAccount = useCallback(
async (acct: SessionAccount) => {
async (acct: AccountData) => {
track('Settings:SwitchAccountButtonClicked')
try {
await selectAccount(acct)
closeAllActiveElements()
Toast.show(`Signed in as ${acct.handle}`)
} catch (e) {
setIsSwitching(true)
const success = await store.session.resumeSession(acct)
store.shell.closeAllActiveElements()
if (success) {
resetNavigation()
Toast.show(`Signed in as ${acct.displayName || acct.handle}`)
} else {
Toast.show('Sorry! We need you to enter your password.')
clearCurrentAccount() // back user out to login
navigation.navigate('HomeTab')
navigation.dispatch(StackActions.popToTop())
store.session.clear()
}
},
[track, clearCurrentAccount, selectAccount, closeAllActiveElements],
[track, setIsSwitching, navigation, store],
)
return {onPressSwitchAccount}
return [isSwitching, setIsSwitching, onPressSwitchAccount]
}
@@ -1,15 +0,0 @@
// 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'
@@ -1,44 +0,0 @@
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) {
if (typeof ref.current !== 'function' && ref.current.onBeginDrag) {
ref.current.onBeginDrag(e)
}
},
onEndDrag(e) {
if (typeof ref.current !== 'function' && ref.current.onEndDrag) {
ref.current.onEndDrag(e)
}
},
onMomentumBegin(e) {
if (typeof ref.current !== 'function' && ref.current.onMomentumBegin) {
ref.current.onMomentumBegin(e)
}
},
onMomentumEnd(e) {
if (typeof ref.current !== 'function' && ref.current.onMomentumEnd) {
ref.current.onMomentumEnd(e)
}
},
onScroll(e) {
if (typeof ref.current === 'function') {
ref.current(e)
} else if (ref.current.onScroll) {
ref.current.onScroll(e)
}
},
},
deps,
)
}
+18
View File
@@ -0,0 +1,18 @@
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
}
+51
View File
@@ -0,0 +1,51 @@
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
}
+55
View File
@@ -0,0 +1,55 @@
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]),
}
}
+29
View File
@@ -0,0 +1,29 @@
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
}
+34 -19
View File
@@ -1,42 +1,57 @@
import {interpolate, useAnimatedStyle} from 'react-native-reanimated'
import {useMinimalShellMode as useMinimalShellModeState} from '#/state/shell/minimal-mode'
import {useShellLayout} from '#/state/shell/shell-layout'
import React from 'react'
import {autorun} from 'mobx'
import {useStores} from 'state/index'
import {
Easing,
interpolate,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
export function useMinimalShellMode() {
const mode = useMinimalShellModeState()
const {footerHeight, headerHeight} = useShellLayout()
const store = useStores()
const minimalShellInterp = useSharedValue(0)
const footerMinimalShellTransform = useAnimatedStyle(() => {
return {
pointerEvents: mode.value === 0 ? 'auto' : 'none',
opacity: Math.pow(1 - mode.value, 2),
opacity: interpolate(minimalShellInterp.value, [0, 1], [1, 0]),
transform: [
{
translateY: interpolate(mode.value, [0, 1], [0, footerHeight.value]),
},
{translateY: interpolate(minimalShellInterp.value, [0, 1], [0, 25])},
],
}
})
const headerMinimalShellTransform = useAnimatedStyle(() => {
return {
pointerEvents: mode.value === 0 ? 'auto' : 'none',
opacity: Math.pow(1 - mode.value, 2),
opacity: interpolate(minimalShellInterp.value, [0, 1], [1, 0]),
transform: [
{
translateY: interpolate(mode.value, [0, 1], [0, -headerHeight.value]),
},
{translateY: interpolate(minimalShellInterp.value, [0, 1], [0, -25])},
],
}
})
const fabMinimalShellTransform = useAnimatedStyle(() => {
return {
transform: [
{
translateY: interpolate(mode.value, [0, 1], [-44, 0]),
},
{translateY: interpolate(minimalShellInterp.value, [0, 1], [-44, 0])},
],
}
})
React.useEffect(() => {
return autorun(() => {
if (store.shell.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, store.shell.minimalShellMode])
return {
footerMinimalShellTransform,
headerMinimalShellTransform,
-23
View File
@@ -1,23 +0,0 @@
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
}
+4 -4
View File
@@ -1,15 +1,15 @@
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'
export function useOTAUpdate() {
const {openModal} = useModalControls()
const store = useStores()
// HELPER FUNCTIONS
const showUpdatePopup = useCallback(() => {
openModal({
store.shell.openModal({
name: 'confirm',
title: 'Update Available',
message:
@@ -20,7 +20,7 @@ export function useOTAUpdate() {
})
},
})
}, [openModal])
}, [store.shell])
const checkForUpdate = useCallback(async () => {
logger.debug('useOTAUpdate: Checking for update...')
try {
+49 -108
View File
@@ -1,125 +1,66 @@
import {useState, useCallback, useMemo} from 'react'
import {useState, useCallback, useRef} from 'react'
import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell'
import {useShellLayout} from '#/state/shell/shell-layout'
import {RootStoreModel} from 'state/index'
import {s} from 'lib/styles'
import {isWeb} from 'platform/detection'
import {
useSharedValue,
interpolate,
runOnJS,
ScrollHandlers,
} from 'react-native-reanimated'
import {useWebMediaQueries} from './useWebMediaQueries'
function clamp(num: number, min: number, max: number) {
'worklet'
return Math.min(Math.max(num, min), max)
const Y_LIMIT = 10
const useDeviceLimits = () => {
const {isDesktop} = useWebMediaQueries()
return {
dyLimitUp: isDesktop ? 30 : 10,
dyLimitDown: isDesktop ? 150 : 10,
}
}
export type OnScrollCb = (
event: NativeSyntheticEvent<NativeScrollEvent>,
) => void
export type OnScrollHandler = ScrollHandlers<any>
export type ResetCb = () => void
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)
const onBeginDrag = useCallback(
(e: NativeScrollEvent) => {
'worklet'
startDragOffset.value = e.contentOffset.y
startMode.value = mode.value
},
[mode, startDragOffset, startMode],
)
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 (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
}
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],
)
export function useOnMainScroll(
store: RootStoreModel,
): [OnScrollCb, boolean, ResetCb] {
let lastY = useRef(0)
let [isScrolledDown, setIsScrolledDown] = useState(false)
const {dyLimitUp, dyLimitDown} = useDeviceLimits()
return [
scrollHandler,
useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const y = event.nativeEvent.contentOffset.y
const dy = y - (lastY.current || 0)
lastY.current = y
if (!store.shell.minimalShellMode && dy > dyLimitDown && y > Y_LIMIT) {
store.shell.setMinimalShellMode(true)
} else if (
store.shell.minimalShellMode &&
(dy < dyLimitUp * -1 || y <= Y_LIMIT)
) {
store.shell.setMinimalShellMode(false)
}
if (
!isScrolledDown &&
event.nativeEvent.contentOffset.y > s.window.height
) {
setIsScrolledDown(true)
} else if (
isScrolledDown &&
event.nativeEvent.contentOffset.y < s.window.height
) {
setIsScrolledDown(false)
}
},
[store.shell, dyLimitDown, dyLimitUp, isScrolledDown],
),
isScrolledDown,
useCallback(() => {
setIsScrolledDown(false)
setMode(false)
}, [setMode]),
store.shell.setMinimalShellMode(false)
lastY.current = 1e8 // NOTE we set this very high so that the onScroll logic works right -prf
}, [store, setIsScrolledDown]),
]
}
+8 -4
View File
@@ -3,14 +3,18 @@ import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
import {bskyTitle} from 'lib/strings/headings'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
import {useStores} from 'state/index'
/**
* Requires consuming component to be wrapped in `observer`:
* https://stackoverflow.com/a/71488009
*/
export function useSetTitle(title?: string) {
const navigation = useNavigation<NavigationProp>()
const numUnread = useUnreadNotifications()
const {unreadCountLabel} = useStores().me.notifications
useEffect(() => {
if (title) {
navigation.setOptions({title: bskyTitle(title, numUnread)})
navigation.setOptions({title: bskyTitle(title, unreadCountLabel)})
}
}, [title, navigation, numUnread])
}, [title, navigation, unreadCountLabel])
}
-98
View File
@@ -1,98 +0,0 @@
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
}
+89
View File
@@ -0,0 +1,89 @@
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'],
},
}
+18
View File
@@ -0,0 +1,18 @@
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[]
}
+24 -16
View File
@@ -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 {ComposerOptsQuote} from 'state/shell/composer'
import {useGetPost} from '#/state/queries/post'
import {RootStoreModel} from 'state/index'
import {PostThreadModel} from 'state/models/content/post-thread'
import {ComposerOptsQuote} from 'state/models/ui/shell'
// TODO
// import {Home} from 'view/screens/Home'
@@ -22,7 +22,7 @@ import {useGetPost} from '#/state/queries/post'
// remove once that's implemented
// -prf
export async function extractBskyMeta(
agent: BskyAgent,
store: RootStoreModel,
url: string,
): Promise<LinkMeta> {
url = convertBskyAppUrlIfNeeded(url)
@@ -102,30 +102,38 @@ export async function extractBskyMeta(
}
export async function getPostAsQuote(
getPost: ReturnType<typeof useGetPost>,
store: RootStoreModel,
url: string,
): Promise<ComposerOptsQuote> {
url = convertBskyAppUrlIfNeeded(url)
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
const post = await getPost({uri: uri})
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')
}
return {
uri: post.uri,
cid: post.cid,
text: AppBskyFeedPost.isRecord(post.record) ? post.record.text : '',
indexedAt: post.indexedAt,
author: post.author,
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,
}
}
export async function getFeedAsEmbed(
agent: BskyAgent,
store: RootStoreModel,
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 agent.app.bsky.feed.getFeedGenerator({feed})
const res = await store.agent.app.bsky.feed.getFeedGenerator({feed})
return {
isLoading: false,
uri: feed,
@@ -145,13 +153,13 @@ export async function getFeedAsEmbed(
}
export async function getListAsEmbed(
agent: BskyAgent,
store: RootStoreModel,
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 agent.app.bsky.graph.getList({list})
const res = await store.agent.app.bsky.graph.getList({list})
return {
isLoading: false,
uri: list,
+6 -6
View File
@@ -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(
agent: BskyAgent,
store: RootStoreModel,
url: string,
timeout = 5e3,
): Promise<LinkMeta> {
if (isBskyAppUrl(url)) {
return extractBskyMeta(agent, url)
return extractBskyMeta(store, 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(agent.service.toString() || '')}${encodeURIComponent(
url,
)}`,
`${LINK_META_PROXY(
store.session.currentSession?.service || '',
)}${encodeURIComponent(url)}`,
{signal: controller.signal},
)
+12
View File
@@ -0,0 +1,12 @@
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,
})
}
-34
View File
@@ -1,34 +0,0 @@
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
}
+7 -3
View File
@@ -1,3 +1,4 @@
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'
@@ -21,15 +22,18 @@ async function getFile() {
})
}
export async function openPicker(): Promise<RNImage[]> {
export async function openPicker(_store: RootStoreModel): Promise<RNImage[]> {
return [await getFile()]
}
export async function openCamera(): Promise<RNImage> {
export async function openCamera(_store: RootStoreModel): Promise<RNImage> {
return await getFile()
}
export async function openCropper(opts: CropperOptions): Promise<RNImage> {
export async function openCropper(
_store: RootStoreModel,
opts: CropperOptions,
): Promise<RNImage> {
return {
path: opts.path,
mime: 'image/jpeg',
+18 -2
View File
@@ -3,10 +3,23 @@ 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'
export async function openCamera(opts: CameraOpts): Promise<RNImage> {
/**
* 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> {
const item = await openCameraFn({
width: opts.width,
height: opts.height,
@@ -26,7 +39,10 @@ export async function openCamera(opts: CameraOpts): Promise<RNImage> {
}
}
export async function openCropper(opts: CropperOptions) {
export async function openCropper(
_store: RootStoreModel,
opts: CropperOptions,
) {
const item = await openCropperFn({
...opts,
forceJpg: true, // ios only
+10 -4
View File
@@ -1,19 +1,25 @@
/// <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(_opts: CameraOpts): Promise<RNImage> {
export async function openCamera(
_store: RootStoreModel,
_opts: CameraOpts,
): Promise<RNImage> {
// const mediaType = opts.mediaType || 'photo' TODO
throw new Error('TODO')
}
export async function openCropper(opts: CropperOptions): Promise<RNImage> {
export async function openCropper(
store: RootStoreModel,
opts: CropperOptions,
): Promise<RNImage> {
// TODO handle more opts
return new Promise((resolve, reject) => {
unstable__openModal({
store.shell.openModal({
name: 'crop-image',
uri: opts.path,
onSelect: (img?: RNImage) => {
+11 -11
View File
@@ -1,19 +1,19 @@
import * as Notifications from 'expo-notifications'
import {QueryClient} from '@tanstack/react-query'
import {RootStoreModel} from '../../state'
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 {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(queryClient: QueryClient) {
listenSessionLoaded(async (account, agent) => {
export function init(store: RootStoreModel) {
store.onUnreadNotifications(count => Notifications.setBadgeCountAsync(count))
store.onSessionLoaded(async () => {
// request notifications permission once the user has logged in
const perms = await Notifications.getPermissionsAsync()
if (!perms.granted) {
@@ -24,8 +24,8 @@ export function init(queryClient: QueryClient) {
const token = await getPushToken()
if (token) {
try {
await agent.api.app.bsky.notification.registerPush({
serviceDid: SERVICE_DID(account.service),
await store.agent.api.app.bsky.notification.registerPush({
serviceDid: SERVICE_DID(store.session.data?.service),
platform: devicePlatform,
token: token.data,
appId: 'xyz.blueskyweb.app',
@@ -53,8 +53,8 @@ export function init(queryClient: QueryClient) {
)
if (t) {
try {
await agent.api.app.bsky.notification.registerPush({
serviceDid: SERVICE_DID(account.service),
await store.agent.api.app.bsky.notification.registerPush({
serviceDid: SERVICE_DID(store.session.data?.service),
platform: devicePlatform,
token: t,
appId: 'xyz.blueskyweb.app',
@@ -83,7 +83,7 @@ export function init(queryClient: QueryClient) {
)
if (event.request.trigger.type === 'push') {
// refresh notifications in the background
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
store.me.notifications.syncQueue()
// handle payload-based deeplinks
let payload
if (isIOS) {
@@ -121,7 +121,7 @@ export function init(queryClient: QueryClient) {
logger.DebugContext.notifications,
)
track('Notificatons:OpenApp')
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
store.me.notifications.refresh() // refresh notifications
resetToTab('NotificationsTab') // open notifications tab
}
},
+1 -11
View File
@@ -1,13 +1,3 @@
import {QueryClient} from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// NOTE
// refetchOnWindowFocus breaks some UIs (like feeds)
// so we NEVER want to enable this
// -prf
refetchOnWindowFocus: false,
},
},
})
export const queryClient = new QueryClient()
+19
View File
@@ -0,0 +1,19 @@
import {isAndroid} from 'platform/detection'
import {BackHandler} from 'react-native'
import {RootStoreModel} from 'state/index'
export function init(store: RootStoreModel) {
// only register back handler on android, otherwise it throws an error
if (isAndroid) {
const backHandler = BackHandler.addEventListener(
'hardwareBackPress',
() => {
return store.shell.closeAnyActiveElement()
},
)
return () => {
backHandler.remove()
}
}
return () => {}
}
+46 -2
View File
@@ -1,8 +1,52 @@
import {init} from 'sentry-expo'
import {isNative, isWeb} from 'platform/detection'
import {FC} from 'react'
import * as Sentry from 'sentry-expo'
init({
// Sentry Initialization
export const getRoutingInstrumentation = () => {
return new Sentry.Native.ReactNavigationInstrumentation() // initialize this in `onReady` prop of NavigationContainer
}
Sentry.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
// @ts-ignore exists but not in types, see https://docs.sentry.io/platforms/react-native/configuration/options/#enableAutoPerformanceTracking
enableAutoPerformanceTracking: true, // Enable auto performance tracking
tracesSampleRate: 0.5, // Set tracesSampleRate to 1.0 to capture 100% of transactions for performance monitoring. // TODO: this might be too much in production
_experiments: {
// The sampling rate for profiling is relative to TracesSampleRate.
// In this case, we'll capture profiles for 50% of transactions.
profilesSampleRate: 0.5,
},
integrations: isNative
? [
new Sentry.Native.ReactNativeTracing({
shouldCreateSpanForRequest: url => {
// Do not create spans for outgoing requests to a `/logs` endpoint as it is too noisy due to expo
return !url.match(/\/logs$/)
},
routingInstrumentation: getRoutingInstrumentation(),
}),
]
: [], // no integrations for web, yet
})
// if web, use Browser client, otherwise use Native client
export function getSentryClient() {
if (isWeb) {
return Sentry.Browser
}
return Sentry.Native
}
// wrap root App component with Sentry for automatic touch event tracking and performance monitoring
export function withSentry(Component: FC) {
if (isWeb) {
return Component // .wrap is not required or available for web
}
const sentryClient = getSentryClient()
return sentryClient.wrap(Component)
}
+1 -1
View File
@@ -1,5 +1,5 @@
import {AtUri} from '@atproto/api'
import {PROD_SERVICE} from 'lib/constants'
import {PROD_SERVICE} from 'state/index'
import TLDs from 'tlds'
import psl from 'psl'
-1
View File
@@ -164,7 +164,6 @@ export const s = StyleSheet.create({
flexRow: {flexDirection: 'row'},
flexCol: {flexDirection: 'column'},
flex1: {flex: 1},
flexGrow1: {flexGrow: 1},
alignCenter: {alignItems: 'center'},
alignBaseline: {alignItems: 'baseline'},
-20
View File
@@ -1,20 +0,0 @@
import {i18n} from '@lingui/core'
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)
}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -286,5 +286,5 @@ if (env.IS_DEV && !env.IS_TEST) {
*/
// logger.addTransport(sentryTransport);
} else if (env.IS_PROD) {
// logger.addTransport(sentryTransport)
logger.addTransport(sentryTransport)
}
-101
View File
@@ -1,101 +0,0 @@
import {useEffect, useState, useCallback, useRef} from 'react'
import EventEmitter from 'eventemitter3'
import {AppBskyFeedDefs} from '@atproto/api'
import {Shadow} from './types'
export type {Shadow} from './types'
const emitter = new EventEmitter()
export interface PostShadow {
likeUri: string | undefined
likeCount: number | undefined
repostUri: string | undefined
repostCount: number | undefined
isDeleted: boolean
}
export const POST_TOMBSTONE = Symbol('PostTombstone')
interface CacheEntry {
ts: number
value: PostShadow
}
export function usePostShadow(
post: AppBskyFeedDefs.PostView,
ifAfterTS: number,
): Shadow<AppBskyFeedDefs.PostView> | typeof POST_TOMBSTONE {
const [state, setState] = useState<CacheEntry>({
ts: Date.now(),
value: fromPost(post),
})
const firstRun = useRef(true)
const onUpdate = useCallback(
(value: Partial<PostShadow>) => {
setState(s => ({ts: Date.now(), value: {...s.value, ...value}}))
},
[setState],
)
// react to shadow updates
useEffect(() => {
emitter.addListener(post.uri, onUpdate)
return () => {
emitter.removeListener(post.uri, onUpdate)
}
}, [post.uri, onUpdate])
// react to post updates
useEffect(() => {
// dont fire on first run to avoid needless re-renders
if (!firstRun.current) {
setState({ts: Date.now(), value: fromPost(post)})
}
firstRun.current = false
}, [post])
return state.ts > ifAfterTS
? mergeShadow(post, state.value)
: {...post, isShadowed: true}
}
export function updatePostShadow(uri: string, value: Partial<PostShadow>) {
emitter.emit(uri, value)
}
export function isPostShadowed(
v: AppBskyFeedDefs.PostView | Shadow<AppBskyFeedDefs.PostView>,
): v is Shadow<AppBskyFeedDefs.PostView> {
return 'isShadowed' in v && !!v.isShadowed
}
function fromPost(post: AppBskyFeedDefs.PostView): PostShadow {
return {
likeUri: post.viewer?.like,
likeCount: post.likeCount,
repostUri: post.viewer?.repost,
repostCount: post.repostCount,
isDeleted: false,
}
}
function mergeShadow(
post: AppBskyFeedDefs.PostView,
shadow: PostShadow,
): Shadow<AppBskyFeedDefs.PostView> | typeof POST_TOMBSTONE {
if (shadow.isDeleted) {
return POST_TOMBSTONE
}
return {
...post,
likeCount: shadow.likeCount,
repostCount: shadow.repostCount,
viewer: {
...(post.viewer || {}),
like: shadow.likeUri,
repost: shadow.repostUri,
},
isShadowed: true,
}
}
-99
View File
@@ -1,99 +0,0 @@
import {useEffect, useState, useCallback, useRef} from 'react'
import EventEmitter from 'eventemitter3'
import {AppBskyActorDefs} from '@atproto/api'
import {Shadow} from './types'
export type {Shadow} from './types'
const emitter = new EventEmitter()
export interface ProfileShadow {
followingUri: string | undefined
muted: boolean | undefined
blockingUri: string | undefined
}
interface CacheEntry {
ts: number
value: ProfileShadow
}
type ProfileView =
| AppBskyActorDefs.ProfileView
| AppBskyActorDefs.ProfileViewBasic
| AppBskyActorDefs.ProfileViewDetailed
export function useProfileShadow(
profile: ProfileView,
ifAfterTS: number,
): Shadow<ProfileView> {
const [state, setState] = useState<CacheEntry>({
ts: Date.now(),
value: fromProfile(profile),
})
const firstRun = useRef(true)
const onUpdate = useCallback(
(value: Partial<ProfileShadow>) => {
setState(s => ({ts: Date.now(), value: {...s.value, ...value}}))
},
[setState],
)
// react to shadow updates
useEffect(() => {
emitter.addListener(profile.did, onUpdate)
return () => {
emitter.removeListener(profile.did, onUpdate)
}
}, [profile.did, onUpdate])
// react to profile updates
useEffect(() => {
// dont fire on first run to avoid needless re-renders
if (!firstRun.current) {
setState({ts: Date.now(), value: fromProfile(profile)})
}
firstRun.current = false
}, [profile])
return state.ts > ifAfterTS
? mergeShadow(profile, state.value)
: {...profile, isShadowed: true}
}
export function updateProfileShadow(
uri: string,
value: Partial<ProfileShadow>,
) {
emitter.emit(uri, value)
}
export function isProfileShadowed<T extends ProfileView>(
v: T | Shadow<T>,
): v is Shadow<T> {
return 'isShadowed' in v && !!v.isShadowed
}
function fromProfile(profile: ProfileView): ProfileShadow {
return {
followingUri: profile.viewer?.following,
muted: profile.viewer?.muted,
blockingUri: profile.viewer?.blocking,
}
}
function mergeShadow(
profile: ProfileView,
shadow: ProfileShadow,
): Shadow<ProfileView> {
return {
...profile,
viewer: {
...(profile.viewer || {}),
following: shadow.followingUri,
muted: shadow.muted,
blocking: shadow.blockingUri,
},
isShadowed: true,
}
}
-1
View File
@@ -1 +0,0 @@
export type Shadow<T> = T & {isShadowed: true}
-38
View File
@@ -1,38 +0,0 @@
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)
}
+54
View File
@@ -0,0 +1,54 @@
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'
-56
View File
@@ -1,56 +0,0 @@
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)
}
-86
View File
@@ -1,86 +0,0 @@
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)
}
-293
View File
@@ -1,293 +0,0 @@
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)
}
+5
View File
@@ -0,0 +1,5 @@
import {LRUMap} from 'lru_map'
export class HandleResolutionsCache {
cache: LRUMap<string, string> = new LRUMap(500)
}
+38
View File
@@ -0,0 +1,38 @@
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
}
}
+44
View File
@@ -0,0 +1,44 @@
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
}
}
}
+137
View File
@@ -0,0 +1,137 @@
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)
}
}
}
+70
View File
@@ -0,0 +1,70 @@
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,
}
}
+50
View File
@@ -0,0 +1,50 @@
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)
}
}
+224
View File
@@ -0,0 +1,224 @@
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() {
if (this.type !== 'feed-generator') {
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,
})
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')
}
}
}
+130
View File
@@ -0,0 +1,130 @@
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}
}
}
+508
View File
@@ -0,0 +1,508 @@
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,
})
// 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
}
}
}
@@ -0,0 +1,139 @@
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()
}
}
+354
View File
@@ -0,0 +1,354 @@
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
}
+306
View File
@@ -0,0 +1,306 @@
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)
}
}
+148
View File
@@ -0,0 +1,148 @@
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
}
}
+132
View File
@@ -0,0 +1,132 @@
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
})
}
})
}
+106
View File
@@ -0,0 +1,106 @@
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
}
}
@@ -0,0 +1,151 @@
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})
}
}
}
@@ -0,0 +1,143 @@
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
}
+671
View File
@@ -0,0 +1,671 @@
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
}
+199
View File
@@ -0,0 +1,199 @@
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')
}
}
}
+91
View File
@@ -0,0 +1,91 @@
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
}
}
+429
View File
@@ -0,0 +1,429 @@
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
}
+88
View File
@@ -0,0 +1,88 @@
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)
}
}
+123
View File
@@ -0,0 +1,123 @@
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)
}
}
}
+107
View File
@@ -0,0 +1,107 @@
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)
}
}
+135
View File
@@ -0,0 +1,135 @@
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)
}
}
+244
View File
@@ -0,0 +1,244 @@
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})),
)
}
}
+107
View File
@@ -0,0 +1,107 @@
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)
}
}
+136
View File
@@ -0,0 +1,136 @@
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)
}
}

Some files were not shown because too many files have changed in this diff Show More